Browse Source
Introduce a lightweight quick note flow with draggable/resizable modal editor, unsaved-change protection, and per-user backend storage APIs backed by a dedicated quick_notes schema. Made-with: Cursormain
16 changed files with 1399 additions and 0 deletions
@ -0,0 +1,142 @@ |
|||
<script setup lang="ts"> |
|||
import 'vditor/dist/index.css' |
|||
import { createPostBodyMarkdownEditorBridge, type CreateVditorLikeOptions } from './post-body-markdown-editor-vditor-bridge' |
|||
import { initializePostBodyMarkdownEditorVditor } from './post-body-markdown-editor-vditor-init' |
|||
import { buildQuickNoteEditorVditorOptions } from './quick-note-editor-vditor-config' |
|||
|
|||
const props = defineProps<{ |
|||
modelValue: string |
|||
resizeSignal?: string |
|||
}>() |
|||
|
|||
const emit = defineEmits<{ |
|||
'update:modelValue': [string] |
|||
}>() |
|||
|
|||
const toast = useToast() |
|||
const mountEl = ref<HTMLElement | null>(null) |
|||
const initErrorMessage = ref('') |
|||
|
|||
const vditorCtor = shallowRef<null | (new (element: HTMLElement, options: Record<string, unknown>) => { |
|||
getValue: () => string |
|||
setValue: (value: string, render?: boolean) => void |
|||
destroy: () => void |
|||
})>(null) |
|||
let unmounted = false |
|||
|
|||
const bridge = createPostBodyMarkdownEditorBridge({ |
|||
getModelValue: () => props.modelValue, |
|||
emitUpdate: (value) => emit('update:modelValue', value), |
|||
createEditor: ({ element, value, onInput }: CreateVditorLikeOptions) => { |
|||
const Vditor = vditorCtor.value |
|||
if (!Vditor) { |
|||
throw new Error('Vditor constructor is not ready') |
|||
} |
|||
return new Vditor(element, buildQuickNoteEditorVditorOptions({ |
|||
value, |
|||
onInput, |
|||
onUploadError: () => { |
|||
toast.add({ title: '图片上传失败', color: 'warning' }) |
|||
}, |
|||
})) |
|||
}, |
|||
}) |
|||
|
|||
function flushValue() { |
|||
const current = bridge.getEditor()?.getValue() |
|||
if (typeof current !== 'string') { |
|||
return |
|||
} |
|||
if (current === props.modelValue) { |
|||
return |
|||
} |
|||
emit('update:modelValue', current) |
|||
} |
|||
|
|||
defineExpose<{ |
|||
flushValue: () => void |
|||
}>({ |
|||
flushValue, |
|||
}) |
|||
|
|||
onMounted(async () => { |
|||
if (!import.meta.client) { |
|||
return |
|||
} |
|||
await initializePostBodyMarkdownEditorVditor({ |
|||
importVditor: () => import('vditor'), |
|||
isUnmounted: () => unmounted, |
|||
onReady: (ctor) => { |
|||
vditorCtor.value = ctor |
|||
if (mountEl.value) { |
|||
bridge.mount(mountEl.value) |
|||
} |
|||
}, |
|||
onError: (error) => { |
|||
initErrorMessage.value = '编辑器加载失败,请刷新重试' |
|||
toast.add({ |
|||
title: initErrorMessage.value, |
|||
color: 'error', |
|||
}) |
|||
console.error('Failed to initialize quick note editor', error) |
|||
}, |
|||
}) |
|||
}) |
|||
|
|||
watch(() => props.modelValue, () => { |
|||
bridge.syncFromProps() |
|||
}) |
|||
|
|||
watch(() => props.resizeSignal, async () => { |
|||
if (!import.meta.client) { |
|||
return |
|||
} |
|||
await nextTick() |
|||
const editor = bridge.getEditor() as { resize?: () => void } | null |
|||
editor?.resize?.() |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
unmounted = true |
|||
bridge.unmount() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<ClientOnly> |
|||
<div v-if="initErrorMessage" class="h-full w-full min-w-0 rounded-lg border border-error/50 bg-error/10 px-4 py-8 text-center"> |
|||
<p class="text-sm text-error"> |
|||
{{ initErrorMessage }} |
|||
</p> |
|||
</div> |
|||
<div |
|||
v-else |
|||
ref="mountEl" |
|||
class="h-full w-full min-w-0 rounded-lg overflow-hidden ring ring-default" |
|||
/> |
|||
<template #fallback> |
|||
<div class="text-muted text-sm py-10 text-center border border-default rounded-lg"> |
|||
编辑器加载中… |
|||
</div> |
|||
</template> |
|||
</ClientOnly> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
:deep(.vditor) { |
|||
height: 100% !important; |
|||
display: flex; |
|||
min-height: 0; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
:deep(.vditor-content) { |
|||
flex: 1; |
|||
min-height: 0; |
|||
} |
|||
|
|||
:deep(.vditor-ir) { |
|||
height: 100% !important; |
|||
overflow: auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,578 @@ |
|||
<script setup lang="ts"> |
|||
import QuickNoteEditor from './QuickNoteEditor.vue' |
|||
import { |
|||
createQuickNoteModalState, |
|||
markSaveFailed, |
|||
markSaveSucceeded, |
|||
updateDraftContent, |
|||
} from './quick-note-modal-state' |
|||
import { |
|||
clampModalRect, |
|||
createDefaultModalRect, |
|||
} from './quick-note-modal-layout' |
|||
|
|||
interface QuickNotePayload { |
|||
quickNote: { |
|||
content: string |
|||
updatedAt: string | null |
|||
} |
|||
} |
|||
|
|||
const open = defineModel<boolean>('open', { default: false }) |
|||
const router = useRouter() |
|||
|
|||
const { fetchData, getApiErrorMessage } = useClientApi() |
|||
const toast = useToast() |
|||
|
|||
const modalState = ref(createQuickNoteModalState('')) |
|||
const quickNoteEditorRef = ref<null | { flushValue: () => void }>(null) |
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
const loadError = ref('') |
|||
const fullScreen = ref(false) |
|||
const lastSavedAt = ref<string | null>(null) |
|||
const allowSilentClose = ref(false) |
|||
let removeRouteGuard: (() => void) | null = null |
|||
let syncDraftTimer: ReturnType<typeof setInterval> | null = null |
|||
|
|||
const modalRect = reactive({ left: 0, top: 0, width: 0, height: 0 }) |
|||
const dragging = ref(false) |
|||
const resizing = ref(false) |
|||
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' |
|||
let resizeDirection: ResizeDirection = 'se' |
|||
let dragStartPointerX = 0 |
|||
let dragStartPointerY = 0 |
|||
let dragStartLeft = 0 |
|||
let dragStartTop = 0 |
|||
let resizeStartPointerX = 0 |
|||
let resizeStartPointerY = 0 |
|||
let resizeStartWidth = 0 |
|||
let resizeStartHeight = 0 |
|||
let resizeStartLeft = 0 |
|||
let resizeStartTop = 0 |
|||
|
|||
const MODAL_MARGIN_PX = 24 |
|||
const MIN_MODAL_WIDTH_PX = 640 |
|||
const MIN_MODAL_HEIGHT_PX = 420 |
|||
|
|||
const draftContent = computed({ |
|||
get: () => modalState.value.draftContent, |
|||
set: (value: string) => { |
|||
modalState.value = updateDraftContent(modalState.value, value) |
|||
}, |
|||
}) |
|||
|
|||
const isDirty = computed(() => modalState.value.isDirty) |
|||
const editorResizeSignal = computed(() => `${modalRect.width}x${modalRect.height}-${fullScreen.value ? 'full' : 'windowed'}`) |
|||
const editorReloadToken = ref(0) |
|||
|
|||
function syncDraftFromEditor() { |
|||
quickNoteEditorRef.value?.flushValue() |
|||
} |
|||
|
|||
function reloadEditor() { |
|||
syncDraftFromEditor() |
|||
editorReloadToken.value += 1 |
|||
toast.add({ |
|||
title: '编辑器已重新加载', |
|||
color: 'info', |
|||
}) |
|||
} |
|||
|
|||
function startDraftSyncTimer() { |
|||
if (!import.meta.client || syncDraftTimer) { |
|||
return |
|||
} |
|||
syncDraftTimer = setInterval(() => { |
|||
if (!open.value || loading.value || saving.value) { |
|||
return |
|||
} |
|||
syncDraftFromEditor() |
|||
}, 120) |
|||
} |
|||
|
|||
function stopDraftSyncTimer() { |
|||
if (!syncDraftTimer) { |
|||
return |
|||
} |
|||
clearInterval(syncDraftTimer) |
|||
syncDraftTimer = null |
|||
} |
|||
|
|||
const cardStyle = computed(() => { |
|||
if (fullScreen.value) { |
|||
return {} |
|||
} |
|||
return { |
|||
left: `${modalRect.left}px`, |
|||
top: `${modalRect.top}px`, |
|||
width: `${modalRect.width}px`, |
|||
height: `${modalRect.height}px`, |
|||
} |
|||
}) |
|||
|
|||
const cardClass = computed(() => { |
|||
if (fullScreen.value) { |
|||
return 'h-full w-full rounded-none' |
|||
} |
|||
return 'absolute max-w-none rounded-xl' |
|||
}) |
|||
|
|||
const overlayClass = computed(() => { |
|||
return 'fixed inset-0 z-[60] bg-black/35 backdrop-blur-[1px] pointer-events-none' |
|||
}) |
|||
|
|||
function stopDragging() { |
|||
if (!dragging.value) { |
|||
return |
|||
} |
|||
dragging.value = false |
|||
if (import.meta.client) { |
|||
window.removeEventListener('pointermove', handlePointerMove) |
|||
window.removeEventListener('pointerup', stopDragging) |
|||
} |
|||
} |
|||
|
|||
function updateModalSizeFromViewport() { |
|||
if (!import.meta.client) { |
|||
return |
|||
} |
|||
const next = createDefaultModalRect({ |
|||
viewportWidth: window.innerWidth, |
|||
viewportHeight: window.innerHeight, |
|||
margin: MODAL_MARGIN_PX, |
|||
minWidth: MIN_MODAL_WIDTH_PX, |
|||
minHeight: MIN_MODAL_HEIGHT_PX, |
|||
}) |
|||
const clamped = clampModalRect({ |
|||
left: modalRect.left || next.left, |
|||
top: modalRect.top || next.top, |
|||
width: modalRect.width || next.width, |
|||
height: modalRect.height || next.height, |
|||
minWidth: MIN_MODAL_WIDTH_PX, |
|||
minHeight: MIN_MODAL_HEIGHT_PX, |
|||
viewportWidth: window.innerWidth, |
|||
viewportHeight: window.innerHeight, |
|||
margin: MODAL_MARGIN_PX, |
|||
}) |
|||
modalRect.left = clamped.left |
|||
modalRect.top = clamped.top |
|||
modalRect.width = clamped.width |
|||
modalRect.height = clamped.height |
|||
} |
|||
|
|||
function handlePointerMove(event: PointerEvent) { |
|||
if (resizing.value && !fullScreen.value) { |
|||
if (!import.meta.client) { |
|||
return |
|||
} |
|||
const deltaX = event.clientX - resizeStartPointerX |
|||
const deltaY = event.clientY - resizeStartPointerY |
|||
let nextLeft = resizeStartLeft |
|||
let nextTop = resizeStartTop |
|||
let nextWidth = resizeStartWidth |
|||
let nextHeight = resizeStartHeight |
|||
|
|||
if (resizeDirection.includes('e')) { |
|||
nextWidth = resizeStartWidth + deltaX |
|||
} |
|||
if (resizeDirection.includes('s')) { |
|||
nextHeight = resizeStartHeight + deltaY |
|||
} |
|||
if (resizeDirection.includes('w')) { |
|||
nextLeft = resizeStartLeft + deltaX |
|||
nextWidth = resizeStartWidth - deltaX |
|||
} |
|||
if (resizeDirection.includes('n')) { |
|||
nextTop = resizeStartTop + deltaY |
|||
nextHeight = resizeStartHeight - deltaY |
|||
} |
|||
|
|||
const clampedRect = clampModalRect({ |
|||
left: nextLeft, |
|||
top: nextTop, |
|||
width: nextWidth, |
|||
height: nextHeight, |
|||
minWidth: MIN_MODAL_WIDTH_PX, |
|||
minHeight: MIN_MODAL_HEIGHT_PX, |
|||
viewportWidth: window.innerWidth, |
|||
viewportHeight: window.innerHeight, |
|||
margin: MODAL_MARGIN_PX, |
|||
}) |
|||
modalRect.left = clampedRect.left |
|||
modalRect.top = clampedRect.top |
|||
modalRect.width = clampedRect.width |
|||
modalRect.height = clampedRect.height |
|||
return |
|||
} |
|||
if (!dragging.value || fullScreen.value) { |
|||
return |
|||
} |
|||
const clamped = clampModalRect({ |
|||
left: dragStartLeft + (event.clientX - dragStartPointerX), |
|||
top: dragStartTop + (event.clientY - dragStartPointerY), |
|||
width: modalRect.width, |
|||
height: modalRect.height, |
|||
minWidth: MIN_MODAL_WIDTH_PX, |
|||
minHeight: MIN_MODAL_HEIGHT_PX, |
|||
viewportWidth: window.innerWidth, |
|||
viewportHeight: window.innerHeight, |
|||
margin: MODAL_MARGIN_PX, |
|||
}) |
|||
modalRect.left = clamped.left |
|||
modalRect.top = clamped.top |
|||
} |
|||
|
|||
function startDragging(event: PointerEvent) { |
|||
if (fullScreen.value || event.button !== 0) { |
|||
return |
|||
} |
|||
dragging.value = true |
|||
dragStartPointerX = event.clientX |
|||
dragStartPointerY = event.clientY |
|||
dragStartLeft = modalRect.left |
|||
dragStartTop = modalRect.top |
|||
if (import.meta.client) { |
|||
window.addEventListener('pointermove', handlePointerMove) |
|||
window.addEventListener('pointerup', stopDragging) |
|||
} |
|||
} |
|||
|
|||
function stopResizing() { |
|||
if (!resizing.value) { |
|||
return |
|||
} |
|||
resizing.value = false |
|||
if (import.meta.client) { |
|||
window.removeEventListener('pointermove', handlePointerMove) |
|||
window.removeEventListener('pointerup', stopResizing) |
|||
} |
|||
} |
|||
|
|||
function startResizing(event: PointerEvent, direction: ResizeDirection) { |
|||
if (fullScreen.value || event.button !== 0 || !import.meta.client) { |
|||
return |
|||
} |
|||
resizing.value = true |
|||
resizeDirection = direction |
|||
resizeStartPointerX = event.clientX |
|||
resizeStartPointerY = event.clientY |
|||
resizeStartLeft = modalRect.left |
|||
resizeStartTop = modalRect.top |
|||
resizeStartWidth = modalRect.width |
|||
resizeStartHeight = modalRect.height |
|||
window.addEventListener('pointermove', handlePointerMove) |
|||
window.addEventListener('pointerup', stopResizing) |
|||
} |
|||
|
|||
function toggleFullScreen() { |
|||
fullScreen.value = !fullScreen.value |
|||
if (fullScreen.value) { |
|||
stopDragging() |
|||
stopResizing() |
|||
} else if (modalRect.width === 0 || modalRect.height === 0) { |
|||
updateModalSizeFromViewport() |
|||
} |
|||
} |
|||
|
|||
async function loadQuickNote() { |
|||
loading.value = true |
|||
loadError.value = '' |
|||
try { |
|||
const data = await fetchData<QuickNotePayload>('/api/me/quick-note') |
|||
modalState.value = createQuickNoteModalState(data.quickNote.content ?? '') |
|||
lastSavedAt.value = data.quickNote.updatedAt |
|||
} catch (error) { |
|||
loadError.value = getApiErrorMessage(error) |
|||
modalState.value = createQuickNoteModalState('') |
|||
toast.add({ |
|||
title: `速记加载失败:${loadError.value}`, |
|||
color: 'error', |
|||
}) |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function saveQuickNote() { |
|||
if (saving.value) { |
|||
return |
|||
} |
|||
saving.value = true |
|||
try { |
|||
const data = await fetchData<QuickNotePayload>('/api/me/quick-note', { |
|||
method: 'PUT', |
|||
body: { |
|||
content: modalState.value.draftContent, |
|||
}, |
|||
}) |
|||
modalState.value = markSaveSucceeded(modalState.value) |
|||
lastSavedAt.value = data.quickNote.updatedAt |
|||
toast.add({ |
|||
title: '速记已保存', |
|||
color: 'success', |
|||
}) |
|||
} catch (error) { |
|||
modalState.value = markSaveFailed(modalState.value) |
|||
toast.add({ |
|||
title: `保存失败:${getApiErrorMessage(error)}`, |
|||
color: 'error', |
|||
}) |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
function requestClose() { |
|||
syncDraftFromEditor() |
|||
if (isDirty.value && !confirmDiscard()) { |
|||
return |
|||
} |
|||
closeModal() |
|||
} |
|||
|
|||
function confirmDiscard(): boolean { |
|||
if (!import.meta.client) { |
|||
return false |
|||
} |
|||
return window.confirm('当前速记有未保存内容,确认后将丢失本次修改。') |
|||
} |
|||
|
|||
function closeModal() { |
|||
allowSilentClose.value = true |
|||
open.value = false |
|||
} |
|||
|
|||
function handleBeforeUnload(event: BeforeUnloadEvent) { |
|||
syncDraftFromEditor() |
|||
if (!open.value || !isDirty.value) { |
|||
return |
|||
} |
|||
event.preventDefault() |
|||
event.returnValue = '' |
|||
} |
|||
|
|||
watch( |
|||
() => open.value, |
|||
(isOpen, wasOpen) => { |
|||
if (wasOpen && !isOpen && !allowSilentClose.value && isDirty.value) { |
|||
if (confirmDiscard()) { |
|||
allowSilentClose.value = true |
|||
return |
|||
} |
|||
open.value = true |
|||
return |
|||
} |
|||
allowSilentClose.value = false |
|||
if (!isOpen) { |
|||
stopDraftSyncTimer() |
|||
fullScreen.value = false |
|||
stopDragging() |
|||
stopResizing() |
|||
return |
|||
} |
|||
startDraftSyncTimer() |
|||
updateModalSizeFromViewport() |
|||
void loadQuickNote() |
|||
}, |
|||
) |
|||
|
|||
onMounted(() => { |
|||
if (!import.meta.client) { |
|||
return |
|||
} |
|||
removeRouteGuard = router.beforeEach((to) => { |
|||
syncDraftFromEditor() |
|||
if (!open.value || !isDirty.value) { |
|||
return true |
|||
} |
|||
return confirmDiscard() |
|||
}) |
|||
window.addEventListener('beforeunload', handleBeforeUnload) |
|||
window.addEventListener('resize', updateModalSizeFromViewport) |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
stopDraftSyncTimer() |
|||
if (import.meta.client) { |
|||
removeRouteGuard?.() |
|||
removeRouteGuard = null |
|||
window.removeEventListener('beforeunload', handleBeforeUnload) |
|||
window.removeEventListener('resize', updateModalSizeFromViewport) |
|||
} |
|||
stopDragging() |
|||
stopResizing() |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<Teleport to="body"> |
|||
<div |
|||
v-if="open" |
|||
:class="overlayClass" |
|||
> |
|||
<div |
|||
:class="cardClass" |
|||
:style="cardStyle" |
|||
class="relative pointer-events-auto" |
|||
> |
|||
<UCard |
|||
class="h-full w-full" |
|||
:ui="{ |
|||
body: 'h-[calc(100%-7rem)] p-3 md:p-4', |
|||
footer: 'border-t border-default/80', |
|||
}" |
|||
> |
|||
<template #header> |
|||
<div |
|||
class="flex items-center justify-between gap-3 select-none" |
|||
:class="fullScreen ? 'cursor-default' : 'cursor-move'" |
|||
@pointerdown="startDragging" |
|||
> |
|||
<div class="min-w-0"> |
|||
<p class="truncate text-sm font-semibold text-highlighted"> |
|||
速记 |
|||
</p> |
|||
<p class="truncate text-xs text-muted"> |
|||
{{ lastSavedAt ? `上次保存:${new Date(lastSavedAt).toLocaleString()}` : '尚未保存过内容' }} |
|||
</p> |
|||
</div> |
|||
<div class="flex items-center gap-2" @pointerdown.stop> |
|||
<UButton |
|||
color="neutral" |
|||
variant="ghost" |
|||
size="sm" |
|||
icon="i-lucide-refresh-cw" |
|||
aria-label="重新加载编辑器" |
|||
@click="reloadEditor" |
|||
/> |
|||
<UButton |
|||
color="neutral" |
|||
variant="ghost" |
|||
size="sm" |
|||
:icon="fullScreen ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'" |
|||
:aria-label="fullScreen ? '退出全屏' : '全屏'" |
|||
@click="toggleFullScreen" |
|||
/> |
|||
<UButton |
|||
color="neutral" |
|||
variant="ghost" |
|||
size="sm" |
|||
icon="i-lucide-x" |
|||
aria-label="关闭速记" |
|||
@click="requestClose" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<div class="flex h-full min-h-0 flex-col gap-3"> |
|||
<UAlert |
|||
v-if="loadError" |
|||
color="error" |
|||
variant="subtle" |
|||
title="速记内容加载失败" |
|||
:description="loadError" |
|||
/> |
|||
<UAlert |
|||
v-if="modalState.lastSaveFailed" |
|||
color="warning" |
|||
variant="subtle" |
|||
title="最近一次保存失败" |
|||
description="请检查网络后重试保存。" |
|||
/> |
|||
<div v-if="loading" class="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-default bg-elevated/40"> |
|||
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin text-primary" /> |
|||
</div> |
|||
<div v-else class="min-h-0 flex-1"> |
|||
<QuickNoteEditor |
|||
:key="editorReloadToken" |
|||
ref="quickNoteEditorRef" |
|||
v-model="draftContent" |
|||
:resize-signal="editorResizeSignal" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<template #footer> |
|||
<div class="flex items-center justify-between gap-2"> |
|||
<span class="text-xs text-muted"> |
|||
{{ isDirty ? '有未保存修改' : '已保存' }} |
|||
</span> |
|||
<div class="flex items-center gap-2"> |
|||
<UButton |
|||
color="neutral" |
|||
variant="outline" |
|||
size="sm" |
|||
:disabled="loading || saving" |
|||
@click="requestClose" |
|||
> |
|||
关闭 |
|||
</UButton> |
|||
<UButton |
|||
color="primary" |
|||
size="sm" |
|||
:loading="saving" |
|||
:disabled="loading" |
|||
@click="saveQuickNote" |
|||
> |
|||
保存 |
|||
</UButton> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
</UCard> |
|||
<template v-if="!fullScreen"> |
|||
<button |
|||
type="button" |
|||
class="absolute left-0 top-0 z-10 h-full w-1 cursor-w-resize" |
|||
aria-label="从左侧调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'w')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute right-0 top-0 z-10 h-full w-1 cursor-e-resize" |
|||
aria-label="从右侧调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'e')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute left-0 top-0 z-10 h-1 w-full cursor-n-resize" |
|||
aria-label="从上侧调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'n')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute bottom-0 left-0 z-10 h-1 w-full cursor-s-resize" |
|||
aria-label="从下侧调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 's')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute left-0 top-0 z-20 h-3 w-3 cursor-nw-resize" |
|||
aria-label="从左上角调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'nw')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute right-0 top-0 z-20 h-3 w-3 cursor-ne-resize" |
|||
aria-label="从右上角调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'ne')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute bottom-0 left-0 z-20 h-3 w-3 cursor-sw-resize" |
|||
aria-label="从左下角调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'sw')" |
|||
/> |
|||
<button |
|||
type="button" |
|||
class="absolute bottom-0 right-0 z-20 h-3 w-3 cursor-se-resize" |
|||
aria-label="从右下角调整速记弹框尺寸" |
|||
@pointerdown.prevent.stop="startResizing($event, 'se')" |
|||
/> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
</Teleport> |
|||
</template> |
|||
@ -0,0 +1,37 @@ |
|||
import { buildPostBodyMarkdownEditorVditorOptions } from './post-body-markdown-editor-vditor-config' |
|||
|
|||
interface BuildQuickNoteVditorOptionsInput { |
|||
value: string |
|||
onInput: (value: string) => void |
|||
onUploadError: () => void |
|||
} |
|||
|
|||
const QUICK_NOTE_TOOLBAR: ReadonlyArray<string> = [ |
|||
'bold', |
|||
'italic', |
|||
'headings', |
|||
'|', |
|||
'list', |
|||
'ordered-list', |
|||
'|', |
|||
'link', |
|||
'upload', |
|||
'code', |
|||
] |
|||
|
|||
export function buildQuickNoteEditorVditorOptions(input: BuildQuickNoteVditorOptionsInput): Record<string, unknown> { |
|||
const baseOptions = buildPostBodyMarkdownEditorVditorOptions({ |
|||
value: input.value, |
|||
isMobile: true, |
|||
onInput: input.onInput, |
|||
onUploadError: input.onUploadError, |
|||
}) |
|||
|
|||
return { |
|||
...baseOptions, |
|||
height: '100%', |
|||
toolbar: QUICK_NOTE_TOOLBAR, |
|||
} |
|||
} |
|||
|
|||
export const quickNoteEditorToolbarPreset = QUICK_NOTE_TOOLBAR |
|||
@ -0,0 +1,86 @@ |
|||
import { describe, expect, test } from 'bun:test' |
|||
import { |
|||
clampModalOffset, |
|||
clampModalRect, |
|||
clampModalSize, |
|||
createDefaultModalRect, |
|||
createDefaultModalSize, |
|||
} from './quick-note-modal-layout' |
|||
|
|||
describe('quick-note modal layout helpers', () => { |
|||
test('createDefaultModalSize uses near-fullscreen viewport with margins', () => { |
|||
expect(createDefaultModalSize({ |
|||
viewportWidth: 1200, |
|||
viewportHeight: 900, |
|||
margin: 24, |
|||
minWidth: 640, |
|||
minHeight: 420, |
|||
})).toEqual({ |
|||
width: 1152, |
|||
height: 852, |
|||
}) |
|||
}) |
|||
|
|||
test('createDefaultModalRect uses margin as origin', () => { |
|||
expect(createDefaultModalRect({ |
|||
viewportWidth: 1200, |
|||
viewportHeight: 900, |
|||
margin: 24, |
|||
minWidth: 640, |
|||
minHeight: 420, |
|||
})).toEqual({ |
|||
left: 24, |
|||
top: 24, |
|||
width: 1152, |
|||
height: 852, |
|||
}) |
|||
}) |
|||
|
|||
test('clampModalSize enforces min and max bounds', () => { |
|||
expect(clampModalSize({ |
|||
width: 400, |
|||
height: 2000, |
|||
minWidth: 640, |
|||
minHeight: 420, |
|||
maxWidth: 1152, |
|||
maxHeight: 852, |
|||
})).toEqual({ |
|||
width: 640, |
|||
height: 852, |
|||
}) |
|||
}) |
|||
|
|||
test('clampModalOffset keeps dragged modal inside viewport bounds', () => { |
|||
expect(clampModalOffset({ |
|||
offsetX: 1000, |
|||
offsetY: -1000, |
|||
width: 800, |
|||
height: 500, |
|||
viewportWidth: 1200, |
|||
viewportHeight: 900, |
|||
margin: 24, |
|||
})).toEqual({ |
|||
offsetX: 176, |
|||
offsetY: -176, |
|||
}) |
|||
}) |
|||
|
|||
test('clampModalRect keeps resized modal inside viewport and min size', () => { |
|||
expect(clampModalRect({ |
|||
left: -200, |
|||
top: 50, |
|||
width: 300, |
|||
height: 1200, |
|||
minWidth: 640, |
|||
minHeight: 420, |
|||
viewportWidth: 1200, |
|||
viewportHeight: 900, |
|||
margin: 24, |
|||
})).toEqual({ |
|||
left: 24, |
|||
top: 24, |
|||
width: 640, |
|||
height: 852, |
|||
}) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,113 @@ |
|||
export interface ModalSize { |
|||
width: number |
|||
height: number |
|||
} |
|||
|
|||
export interface ModalOffset { |
|||
offsetX: number |
|||
offsetY: number |
|||
} |
|||
|
|||
export interface ModalRect { |
|||
left: number |
|||
top: number |
|||
width: number |
|||
height: number |
|||
} |
|||
|
|||
export interface CreateDefaultModalSizeInput { |
|||
viewportWidth: number |
|||
viewportHeight: number |
|||
margin: number |
|||
minWidth: number |
|||
minHeight: number |
|||
} |
|||
|
|||
export interface CreateDefaultModalRectInput extends CreateDefaultModalSizeInput {} |
|||
|
|||
export interface ClampModalSizeInput extends ModalSize { |
|||
minWidth: number |
|||
minHeight: number |
|||
maxWidth: number |
|||
maxHeight: number |
|||
} |
|||
|
|||
export interface ClampModalOffsetInput extends ModalSize { |
|||
offsetX: number |
|||
offsetY: number |
|||
viewportWidth: number |
|||
viewportHeight: number |
|||
margin: number |
|||
} |
|||
|
|||
export interface ClampModalRectInput extends ModalRect { |
|||
minWidth: number |
|||
minHeight: number |
|||
viewportWidth: number |
|||
viewportHeight: number |
|||
margin: number |
|||
} |
|||
|
|||
export function clampModalSize(input: ClampModalSizeInput): ModalSize { |
|||
return { |
|||
width: Math.max(input.minWidth, Math.min(input.width, input.maxWidth)), |
|||
height: Math.max(input.minHeight, Math.min(input.height, input.maxHeight)), |
|||
} |
|||
} |
|||
|
|||
export function createDefaultModalSize(input: CreateDefaultModalSizeInput): ModalSize { |
|||
const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2) |
|||
const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2) |
|||
return clampModalSize({ |
|||
width: maxWidth, |
|||
height: maxHeight, |
|||
minWidth: input.minWidth, |
|||
minHeight: input.minHeight, |
|||
maxWidth, |
|||
maxHeight, |
|||
}) |
|||
} |
|||
|
|||
export function createDefaultModalRect(input: CreateDefaultModalRectInput): ModalRect { |
|||
const size = createDefaultModalSize(input) |
|||
return { |
|||
left: input.margin, |
|||
top: input.margin, |
|||
width: size.width, |
|||
height: size.height, |
|||
} |
|||
} |
|||
|
|||
export function clampModalOffset(input: ClampModalOffsetInput): ModalOffset { |
|||
const centerX = (input.viewportWidth - input.width) / 2 |
|||
const centerY = (input.viewportHeight - input.height) / 2 |
|||
const minOffsetX = input.margin - centerX |
|||
const maxOffsetX = input.viewportWidth - input.margin - (centerX + input.width) |
|||
const minOffsetY = input.margin - centerY |
|||
const maxOffsetY = input.viewportHeight - input.margin - (centerY + input.height) |
|||
return { |
|||
offsetX: Math.max(minOffsetX, Math.min(input.offsetX, maxOffsetX)), |
|||
offsetY: Math.max(minOffsetY, Math.min(input.offsetY, maxOffsetY)), |
|||
} |
|||
} |
|||
|
|||
export function clampModalRect(input: ClampModalRectInput): ModalRect { |
|||
const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2) |
|||
const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2) |
|||
const size = clampModalSize({ |
|||
width: input.width, |
|||
height: input.height, |
|||
minWidth: input.minWidth, |
|||
minHeight: input.minHeight, |
|||
maxWidth, |
|||
maxHeight, |
|||
}) |
|||
const maxLeft = input.viewportWidth - input.margin - size.width |
|||
const maxTop = input.viewportHeight - input.margin - size.height |
|||
return { |
|||
left: Math.max(input.margin, Math.min(input.left, maxLeft)), |
|||
top: Math.max(input.margin, Math.min(input.top, maxTop)), |
|||
width: size.width, |
|||
height: size.height, |
|||
} |
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
import { describe, expect, test } from 'bun:test' |
|||
import { |
|||
computeIsDirty, |
|||
createQuickNoteModalState, |
|||
markSaveFailed, |
|||
markSaveSucceeded, |
|||
updateDraftContent, |
|||
} from './quick-note-modal-state' |
|||
|
|||
describe('quick-note modal state', () => { |
|||
test('draft differs from saved => dirty', () => { |
|||
expect(computeIsDirty({ savedContent: 'a', draftContent: 'b' })).toBe(true) |
|||
expect(computeIsDirty({ savedContent: 'a', draftContent: 'a' })).toBe(false) |
|||
}) |
|||
|
|||
test('saving success marks state clean', () => { |
|||
const initial = createQuickNoteModalState('hello') |
|||
const editing = updateDraftContent(initial, 'hello world') |
|||
expect(editing.isDirty).toBe(true) |
|||
|
|||
const saved = markSaveSucceeded(editing) |
|||
expect(saved.savedContent).toBe('hello world') |
|||
expect(saved.draftContent).toBe('hello world') |
|||
expect(saved.isDirty).toBe(false) |
|||
expect(saved.lastSaveFailed).toBe(false) |
|||
}) |
|||
|
|||
test('saving failure keeps draft dirty', () => { |
|||
const initial = createQuickNoteModalState('hello') |
|||
const editing = updateDraftContent(initial, 'draft changed') |
|||
const failed = markSaveFailed(editing) |
|||
|
|||
expect(failed.savedContent).toBe('hello') |
|||
expect(failed.draftContent).toBe('draft changed') |
|||
expect(failed.isDirty).toBe(true) |
|||
expect(failed.lastSaveFailed).toBe(true) |
|||
}) |
|||
|
|||
test('input after failed save resets lastSaveFailed', () => { |
|||
const initial = createQuickNoteModalState('hello') |
|||
const editing = updateDraftContent(initial, 'draft changed') |
|||
const failed = markSaveFailed(editing) |
|||
expect(failed.lastSaveFailed).toBe(true) |
|||
|
|||
const editedAgain = updateDraftContent(failed, 'draft changed again') |
|||
expect(editedAgain.lastSaveFailed).toBe(false) |
|||
expect(editedAgain.isDirty).toBe(true) |
|||
}) |
|||
|
|||
test('save success after a failure clears lastSaveFailed', () => { |
|||
const initial = createQuickNoteModalState('hello') |
|||
const failed = markSaveFailed(updateDraftContent(initial, 'draft changed')) |
|||
const saved = markSaveSucceeded(failed) |
|||
|
|||
expect(saved.lastSaveFailed).toBe(false) |
|||
expect(saved.isDirty).toBe(false) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,58 @@ |
|||
export interface QuickNoteModalState { |
|||
savedContent: string |
|||
draftContent: string |
|||
isDirty: boolean |
|||
lastSaveFailed: boolean |
|||
} |
|||
|
|||
export interface ComputeIsDirtyInput { |
|||
savedContent: string |
|||
draftContent: string |
|||
} |
|||
|
|||
export function computeIsDirty(input: ComputeIsDirtyInput): boolean { |
|||
return input.savedContent !== input.draftContent |
|||
} |
|||
|
|||
export function createQuickNoteModalState(savedContent: string): QuickNoteModalState { |
|||
return { |
|||
savedContent, |
|||
draftContent: savedContent, |
|||
isDirty: false, |
|||
lastSaveFailed: false, |
|||
} |
|||
} |
|||
|
|||
export function updateDraftContent(state: QuickNoteModalState, draftContent: string): QuickNoteModalState { |
|||
return { |
|||
...state, |
|||
draftContent, |
|||
isDirty: computeIsDirty({ |
|||
savedContent: state.savedContent, |
|||
draftContent, |
|||
}), |
|||
lastSaveFailed: false, |
|||
} |
|||
} |
|||
|
|||
export function markSaveSucceeded(state: QuickNoteModalState): QuickNoteModalState { |
|||
const nextSavedContent = state.draftContent |
|||
return { |
|||
...state, |
|||
savedContent: nextSavedContent, |
|||
draftContent: nextSavedContent, |
|||
isDirty: false, |
|||
lastSaveFailed: false, |
|||
} |
|||
} |
|||
|
|||
export function markSaveFailed(state: QuickNoteModalState): QuickNoteModalState { |
|||
return { |
|||
...state, |
|||
isDirty: computeIsDirty({ |
|||
savedContent: state.savedContent, |
|||
draftContent: state.draftContent, |
|||
}), |
|||
lastSaveFailed: true, |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
CREATE TABLE `quick_notes` ( |
|||
`id` integer PRIMARY KEY NOT NULL, |
|||
`user_id` integer NOT NULL, |
|||
`content` text NOT NULL, |
|||
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, |
|||
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL, |
|||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE UNIQUE INDEX `quick_notes_user_id_unique` ON `quick_notes` (`user_id`); |
|||
@ -0,0 +1,13 @@ |
|||
import { getQuickNoteByUserId } from "#server/service/quick-note"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const user = await event.context.auth.requireUser(); |
|||
const quickNote = await getQuickNoteByUserId(user.id); |
|||
|
|||
return R.success({ |
|||
quickNote: { |
|||
content: quickNote?.content ?? "", |
|||
updatedAt: quickNote?.updatedAt ?? null, |
|||
}, |
|||
}); |
|||
}); |
|||
@ -0,0 +1,18 @@ |
|||
import { upsertQuickNoteByUserId } from "#server/service/quick-note"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const user = await event.context.auth.requireUser(); |
|||
const body = await readBody<{ content?: unknown }>(event); |
|||
|
|||
if (typeof body?.content !== "string") { |
|||
throw createError({ statusCode: 400, statusMessage: "content 必须为字符串" }); |
|||
} |
|||
|
|||
const quickNote = await upsertQuickNoteByUserId(user.id, body.content); |
|||
return R.success({ |
|||
quickNote: { |
|||
content: quickNote.content, |
|||
updatedAt: quickNote.updatedAt, |
|||
}, |
|||
}); |
|||
}); |
|||
@ -0,0 +1,172 @@ |
|||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; |
|||
import { eq, sql } from "drizzle-orm"; |
|||
|
|||
process.env.DATABASE_URL ??= "file:./db.sqlite"; |
|||
|
|||
(globalThis as { createError?: (input: unknown) => unknown }).createError = (input: unknown) => { |
|||
if (input instanceof Error) { |
|||
return input; |
|||
} |
|||
const payload = (input ?? {}) as { statusMessage?: string }; |
|||
const error = new Error(payload.statusMessage ?? "Error") as Error & Record<string, unknown>; |
|||
Object.assign(error, payload); |
|||
return error; |
|||
}; |
|||
|
|||
const { dbGlobal } = await import("drizzle-pkg/database/sqlite/db-bun"); |
|||
mock.module("drizzle-pkg/lib/db", () => ({ dbGlobal })); |
|||
|
|||
const { users } = await import("drizzle-pkg/lib/schema/auth"); |
|||
const { quickNotes } = await import("drizzle-pkg/lib/schema/content"); |
|||
const { |
|||
QUICK_NOTE_MAX_LENGTH, |
|||
getQuickNoteByUserId, |
|||
isQuickNoteUniqueViolation, |
|||
normalizeQuickNoteContent, |
|||
upsertQuickNoteByUserId, |
|||
validateQuickNoteContentLength, |
|||
} = await import("./index"); |
|||
|
|||
const TEST_USER = { |
|||
id: 920001, |
|||
username: "quick_note_u1", |
|||
password: "pw", |
|||
}; |
|||
|
|||
async function resetRows() { |
|||
await dbGlobal.delete(quickNotes).where(eq(quickNotes.userId, TEST_USER.id)); |
|||
await dbGlobal.delete(users).where(eq(users.id, TEST_USER.id)); |
|||
} |
|||
|
|||
describe("quick-note service", () => { |
|||
beforeAll(async () => { |
|||
await dbGlobal.run(sql` |
|||
CREATE TABLE IF NOT EXISTS quick_notes ( |
|||
id INTEGER PRIMARY KEY NOT NULL, |
|||
user_id INTEGER NOT NULL, |
|||
content TEXT NOT NULL, |
|||
created_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL, |
|||
updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL |
|||
) |
|||
`);
|
|||
await dbGlobal.run(sql` |
|||
CREATE UNIQUE INDEX IF NOT EXISTS quick_notes_user_id_unique ON quick_notes (user_id) |
|||
`);
|
|||
await resetRows(); |
|||
}); |
|||
|
|||
beforeEach(async () => { |
|||
await resetRows(); |
|||
await dbGlobal.insert(users).values(TEST_USER); |
|||
}); |
|||
|
|||
test("exports max length constant", () => { |
|||
expect(QUICK_NOTE_MAX_LENGTH).toBe(200000); |
|||
}); |
|||
|
|||
test("normalizeQuickNoteContent converts CRLF to LF", () => { |
|||
expect(normalizeQuickNoteContent("a\r\nb\r\n")).toBe("a\nb\n"); |
|||
}); |
|||
|
|||
test("validateQuickNoteContentLength throws 400 when too long", () => { |
|||
const tooLong = "a".repeat(QUICK_NOTE_MAX_LENGTH + 1); |
|||
expect(() => validateQuickNoteContentLength(tooLong)).toThrow("速记内容过长"); |
|||
try { |
|||
validateQuickNoteContentLength(tooLong); |
|||
} catch (error) { |
|||
expect(error).toMatchObject({ statusCode: 400, statusMessage: "速记内容过长" }); |
|||
} |
|||
}); |
|||
|
|||
test("validateQuickNoteContentLength allows content at max length", () => { |
|||
const maxLengthContent = "a".repeat(QUICK_NOTE_MAX_LENGTH); |
|||
expect(() => validateQuickNoteContentLength(maxLengthContent)).not.toThrow(); |
|||
}); |
|||
|
|||
test("isQuickNoteUniqueViolation returns true for quick_notes user unique conflict", () => { |
|||
expect( |
|||
isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: quick_notes.user_id")), |
|||
).toBe(true); |
|||
}); |
|||
|
|||
test("isQuickNoteUniqueViolation returns false for non-quick-note conflict", () => { |
|||
expect( |
|||
isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: posts.user_id, posts.slug")), |
|||
).toBe(false); |
|||
expect(isQuickNoteUniqueViolation(new Error("random error"))).toBe(false); |
|||
}); |
|||
|
|||
test("getQuickNoteByUserId returns null when not found", async () => { |
|||
const row = await getQuickNoteByUserId(TEST_USER.id); |
|||
expect(row).toBeNull(); |
|||
}); |
|||
|
|||
test("upsertQuickNoteByUserId inserts then updates and normalizes newlines", async () => { |
|||
const inserted = await upsertQuickNoteByUserId(TEST_USER.id, "line1\r\nline2"); |
|||
expect(inserted.userId).toBe(TEST_USER.id); |
|||
expect(inserted.content).toBe("line1\nline2"); |
|||
|
|||
const updated = await upsertQuickNoteByUserId(TEST_USER.id, "next\r\nvalue"); |
|||
expect(updated.id).toBe(inserted.id); |
|||
expect(updated.content).toBe("next\nvalue"); |
|||
|
|||
const fromDb = await getQuickNoteByUserId(TEST_USER.id); |
|||
expect(fromDb?.id).toBe(inserted.id); |
|||
expect(fromDb?.content).toBe("next\nvalue"); |
|||
}); |
|||
|
|||
test("upsertQuickNoteByUserId supports empty string content", async () => { |
|||
const saved = await upsertQuickNoteByUserId(TEST_USER.id, ""); |
|||
expect(saved.content).toBe(""); |
|||
|
|||
const fromDb = await getQuickNoteByUserId(TEST_USER.id); |
|||
expect(fromDb).not.toBeNull(); |
|||
expect(fromDb?.content).toBe(""); |
|||
}); |
|||
|
|||
test("upsertQuickNoteByUserId normalizes all CRLF to LF", async () => { |
|||
const raw = "\r\nline1\r\nline2\r\n"; |
|||
const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw); |
|||
expect(saved.content).toBe("\nline1\nline2\n"); |
|||
|
|||
const fromDb = await getQuickNoteByUserId(TEST_USER.id); |
|||
expect(fromDb?.content).toBe("\nline1\nline2\n"); |
|||
}); |
|||
|
|||
test("upsertQuickNoteByUserId validates length after normalize", async () => { |
|||
const raw = "a\r\n".repeat(100000); |
|||
expect(raw.length).toBeGreaterThan(QUICK_NOTE_MAX_LENGTH); |
|||
expect(normalizeQuickNoteContent(raw).length).toBe(QUICK_NOTE_MAX_LENGTH); |
|||
|
|||
const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw); |
|||
expect(saved.content.length).toBe(QUICK_NOTE_MAX_LENGTH); |
|||
}); |
|||
|
|||
test("upsertQuickNoteByUserId falls back to update on unique conflict and returns row", async () => { |
|||
const originalInsert = dbGlobal.insert.bind(dbGlobal); |
|||
let injected = false; |
|||
(dbGlobal as { insert: typeof dbGlobal.insert }).insert = ((...args: Parameters<typeof dbGlobal.insert>) => { |
|||
const builder = originalInsert(...args) as { values: (payload: unknown) => Promise<unknown> } & Record<string, unknown>; |
|||
return { |
|||
...builder, |
|||
values: async (payload: unknown) => { |
|||
const result = await builder.values(payload); |
|||
if (!injected) { |
|||
injected = true; |
|||
throw new Error("UNIQUE constraint failed: quick_notes.user_id"); |
|||
} |
|||
return result; |
|||
}, |
|||
}; |
|||
}) as typeof dbGlobal.insert; |
|||
|
|||
try { |
|||
const saved = await upsertQuickNoteByUserId(TEST_USER.id, "fallback\r\nok"); |
|||
expect(saved.userId).toBe(TEST_USER.id); |
|||
expect(saved.content).toBe("fallback\nok"); |
|||
expect(injected).toBe(true); |
|||
} finally { |
|||
(dbGlobal as { insert: typeof dbGlobal.insert }).insert = originalInsert as typeof dbGlobal.insert; |
|||
} |
|||
}); |
|||
}); |
|||
@ -0,0 +1,74 @@ |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { quickNotes } from "drizzle-pkg/lib/schema/content"; |
|||
import { eq } from "drizzle-orm"; |
|||
import { nextIntegerId } from "../../utils/sqlite-id"; |
|||
|
|||
export const QUICK_NOTE_MAX_LENGTH = 200000; |
|||
|
|||
export function normalizeQuickNoteContent(content: string): string { |
|||
return content.replaceAll("\r\n", "\n"); |
|||
} |
|||
|
|||
export function validateQuickNoteContentLength(content: string) { |
|||
if (content.length > QUICK_NOTE_MAX_LENGTH) { |
|||
throw createError({ statusCode: 400, statusMessage: "速记内容过长" }); |
|||
} |
|||
} |
|||
|
|||
export function isQuickNoteUniqueViolation(error: unknown): boolean { |
|||
const message = error instanceof Error ? error.message : String(error ?? ""); |
|||
if (!message.includes("UNIQUE constraint failed")) { |
|||
return false; |
|||
} |
|||
return message.includes("quick_notes.user_id") || message.includes("quick_notes_user_id_unique"); |
|||
} |
|||
|
|||
export async function getQuickNoteByUserId(userId: number) { |
|||
const [row] = await dbGlobal |
|||
.select() |
|||
.from(quickNotes) |
|||
.where(eq(quickNotes.userId, userId)) |
|||
.limit(1); |
|||
return row ?? null; |
|||
} |
|||
|
|||
export async function upsertQuickNoteByUserId(userId: number, content: string) { |
|||
const normalizedContent = normalizeQuickNoteContent(content); |
|||
validateQuickNoteContentLength(normalizedContent); |
|||
|
|||
const existing = await getQuickNoteByUserId(userId); |
|||
if (!existing) { |
|||
try { |
|||
const id = await nextIntegerId(quickNotes, quickNotes.id); |
|||
await dbGlobal.insert(quickNotes).values({ |
|||
id, |
|||
userId, |
|||
content: normalizedContent, |
|||
}); |
|||
} catch (error) { |
|||
// 处理并发下“先查后插”触发的唯一键冲突:回退为 update。
|
|||
if (!isQuickNoteUniqueViolation(error)) { |
|||
throw error; |
|||
} |
|||
await dbGlobal |
|||
.update(quickNotes) |
|||
.set({ content: normalizedContent }) |
|||
.where(eq(quickNotes.userId, userId)); |
|||
} |
|||
const inserted = await getQuickNoteByUserId(userId); |
|||
if (!inserted) { |
|||
throw new Error(`速记写入失败:userId=${userId}`); |
|||
} |
|||
return inserted; |
|||
} |
|||
|
|||
await dbGlobal |
|||
.update(quickNotes) |
|||
.set({ content: normalizedContent }) |
|||
.where(eq(quickNotes.userId, userId)); |
|||
const updated = await getQuickNoteByUserId(userId); |
|||
if (!updated) { |
|||
throw new Error(`速记更新失败:userId=${userId}`); |
|||
} |
|||
return updated; |
|||
} |
|||
Loading…
Reference in new issue