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