Migrate backend jobs to Redis Streams

This commit is contained in:
“ydy0615”
2026-06-06 15:44:00 +08:00
parent 2c7a02f587
commit 81f711ef0b
69 changed files with 2709 additions and 6291 deletions
+94 -1
View File
@@ -7,6 +7,20 @@
<div class="doc-card__time">{{ displayTime }}</div>
</div>
<div class="doc-card__actions">
<button type="button" class="doc-card__btn" :title="'压缩文档'" @click="handleCompress">
<svg v-if="compressState === 'idle' || compressState === 'completed'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 14h6v7H4z"/>
<path d="M14 9h6v12h-6z"/>
<path d="M4 9h6v5H4z"/>
</svg>
<span v-if="compressState === 'queued' || compressState === 'processing'" class="doc-card__spinner"></span>
<svg v-else-if="compressState === 'error'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</button>
<span v-if="compressState === 'queued'" class="doc-card__status-label">排队中</span>
<button type="button" class="doc-card__btn" :title="collapsedState ? '展开文件' : '折叠文件'" @click="toggleCollapse">
<svg v-if="collapsedState" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
@@ -39,7 +53,7 @@ import { Crepe } from '@milkdown/crepe'
import { editorViewCtx } from '@milkdown/kit/core'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { hiddenTextInputPlugin, hiddenTextNode, hiddenTextRemark, hiddenTextView } from '../plugins/hiddenTextPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { fetchSuggestion, submitCompress, pollCompressStatus } from '../utils/api.js'
import { isDocumentVisible, getRecommendedDebounce, getRecommendedSyncInterval } from '../composables/useVisibility.js'
const props = defineProps({
@@ -52,14 +66,52 @@ const props = defineProps({
onUpdateContent: { type: Function, default: null },
onUpdateCollapsed: { type: Function, default: null },
onDelete: { type: Function, default: null },
onCompress: { type: Function, default: null },
})
const editorRoot = ref(null)
const collapsedState = ref(Boolean(props.collapsed))
const currentContent = ref(props.content || '')
const compressState = ref('idle') // idle | queued | processing | error
let crepe = null
let syncTimer = null
let syncingExternal = false
let compressPoller = null
const handleCompress = () => {
if (compressState.value !== 'idle') return
// 直接从嵌套编辑器获取最新内容,不依赖外部同步缓存
const content = crepe?.getMarkdown() || ''
if (!content.trim()) {
compressState.value = 'error'
setTimeout(() => { compressState.value = 'idle' }, 2000)
return
}
submitCompress(content, props.docType).then((result) => {
compressState.value = 'queued'
if (compressPoller) compressPoller.stop()
compressPoller = pollCompressStatus(result.task_id, (status, compressedContent, message) => {
compressState.value = status
if (status === 'completed') {
// 压缩完成,直接更新嵌套编辑器的内容
crepe.editor.action(replaceAll(compressedContent))
} else if (status === 'error') {
setTimeout(() => { compressState.value = 'idle' }, 3000)
}
})
if (compressState.value === 'queued') {
// Task already completed before polling started
}
}).catch(() => {
compressState.value = 'error'
setTimeout(() => { compressState.value = 'idle' }, 3000)
})
}
const typeLabel = computed(() => {
if (props.docType === 'docx') return 'DOCX'
@@ -84,6 +136,10 @@ const syncContent = () => {
if (!crepe) return
// Skip content sync when tab is hidden (energy saving for nested editors)
if (!isDocumentVisible()) return
// Don't sync during compression to avoid overwriting compressed content
if (compressState.value !== 'idle') return
if (syncTimer) clearTimeout(syncTimer)
const syncInterval = getRecommendedSyncInterval(120)
syncTimer = setTimeout(async () => {
@@ -96,11 +152,24 @@ const syncContent = () => {
const syncExternalContent = async (nextValue) => {
const value = nextValue || ''
// Reject empty content to prevent accidental document clearing (e.g., from failed compression)
if (!value || !value.trim()) {
return
}
if (!crepe) {
currentContent.value = value
return
}
if (value === currentContent.value) return
// Clear pending sync timer to prevent stale content from overwriting new content
if (syncTimer) {
clearTimeout(syncTimer)
syncTimer = null
}
syncingExternal = true
try {
crepe.editor.action(replaceAll(value))
@@ -173,6 +242,10 @@ onUnmounted(() => {
clearTimeout(syncTimer)
syncTimer = null
}
if (compressPoller) {
compressPoller.stop()
compressPoller = null
}
if (crepe) {
crepe.editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
@@ -262,6 +335,26 @@ onUnmounted(() => {
.doc-card__actions {
display: flex;
gap: 4px;
align-items: center;
}
.doc-card__status-label {
font-size: 10px;
color: #f59e0b;
white-space: nowrap;
}
.doc-card__spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(59, 130, 246, 0.2);
border-top-color: #3b82f6;
border-radius: 50%;
animation: doc-card-spin 0.6s linear infinite;
}
@keyframes doc-card-spin {
to { transform: rotate(360deg); }
}
.doc-card__btn {