Browse Source

feat(editor): add auto-save with 60s countdown and offline detection

- 60s countdown timer, auto-save every 60s
- Offline detection: pause countdown, show red warning
- Auto-save: edit mode via PUT, create mode via localStorage draft
- beforeunload + onBeforeRouteLeave double protection for unsaved changes
- Draft restore from localStorage on new article mount
main
Mohan 1 month ago
parent
commit
418a354a0d
  1. 254
      src/views/admin/ArticleEditorView.vue

254
src/views/admin/ArticleEditorView.vue

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client'
import type { ArticleDetail, Tag } from '@/generated/api'
import QuillEditor from '@/components/QuillEditor.vue'
@ -21,6 +21,149 @@ const saving = ref(false)
const error = ref<string | null>(null)
const uploading = ref(false)
// ========== ==========
const countdown = ref(60)
const isOnline = ref(navigator.onLine)
const isAutoSaving = ref(false)
const lastSavedContent = ref('')
const autoSaveMessage = ref('')
const hasUnsavedChanges = ref(false)
let countdownTimer: ReturnType<typeof setInterval> | null = null
function updateOnlineStatus() {
isOnline.value = navigator.onLine
if (!isOnline.value) {
autoSaveMessage.value = '已离线,请妥善保存'
} else {
autoSaveMessage.value = ''
}
}
async function autoSave() {
if (!isOnline.value) {
autoSaveMessage.value = '已离线,请妥善保存'
return
}
if (content.value === lastSavedContent.value) {
countdown.value = 60
return
}
isAutoSaving.value = true
try {
if (isEdit.value) {
const id = Number(route.params.id)
await articleAdminApi.apiAdminArticlesIdPut(id, {
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
} else {
localStorage.setItem('article_draft', JSON.stringify({
title: title.value,
content: content.value,
summary: summary.value,
coverImage: coverImage.value,
tagIds: selectedTagIds.value,
savedAt: new Date().toISOString()
}))
}
lastSavedContent.value = content.value
hasUnsavedChanges.value = false
autoSaveMessage.value = '已自动保存'
setTimeout(() => { autoSaveMessage.value = '' }, 2000)
countdown.value = 60
} catch {
autoSaveMessage.value = '保存失败,请检查网络'
} finally {
isAutoSaving.value = false
}
}
function startCountdown() {
countdownTimer = setInterval(() => {
if (!isOnline.value) return
countdown.value--
if (countdown.value <= 0) {
autoSave()
}
}, 1000)
}
watch(content, (val) => {
hasUnsavedChanges.value = val !== lastSavedContent.value
})
watch(title, () => { hasUnsavedChanges.value = true })
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (hasUnsavedChanges.value) {
e.preventDefault()
e.returnValue = '您有未保存的内容,确定要离开吗?'
return e.returnValue
}
}
function restoreDraft() {
if (isEdit.value) return
const draft = localStorage.getItem('article_draft')
if (!draft) return
try {
const parsed = JSON.parse(draft)
if (parsed.title || parsed.content) {
title.value = parsed.title || ''
content.value = parsed.content || ''
summary.value = parsed.summary || ''
coverImage.value = parsed.coverImage || ''
selectedTagIds.value = parsed.tagIds || []
lastSavedContent.value = content.value
hasUnsavedChanges.value = false
}
} catch {
localStorage.removeItem('article_draft')
}
}
// ========== ==========
async function onSubmit() {
if (!title.value.trim() || !content.value.trim()) {
error.value = '标题和内容为必填项'
return
}
saving.value = true
error.value = null
try {
if (isEdit.value) {
const id = Number(route.params.id)
await articleAdminApi.apiAdminArticlesIdPut(id, {
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
} else {
await articleAdminApi.apiAdminArticlesPost({
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
localStorage.removeItem('article_draft')
}
lastSavedContent.value = content.value
hasUnsavedChanges.value = false
router.push({ name: 'AdminArticleList' })
} catch (e) {
console.error(e)
error.value = '保存失败'
} finally {
saving.value = false
}
}
async function loadTags() {
try {
const resp = await tagApi.apiTagsGet()
@ -51,6 +194,8 @@ async function loadArticle() {
content.value = detail.content
coverImage.value = detail.coverImage ?? ''
selectedTagIds.value = matchTagIds(detail)
lastSavedContent.value = detail.content
hasUnsavedChanges.value = false
}
} catch (e) {
console.error(e)
@ -94,44 +239,38 @@ async function removeCover() {
coverImage.value = ''
}
async function onSubmit() {
if (!title.value.trim() || !content.value.trim()) {
error.value = '标题和内容为必填项'
return
}
saving.value = true
error.value = null
try {
if (isEdit.value) {
const id = Number(route.params.id)
await articleAdminApi.apiAdminArticlesIdPut(id, {
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
// ========== ==========
onBeforeRouteLeave((to, _from, next) => {
if (hasUnsavedChanges.value) {
const ok = window.confirm('您有未保存的内容,确定要离开吗?')
if (ok) {
next()
} else {
await articleAdminApi.apiAdminArticlesPost({
title: title.value.trim(),
summary: summary.value.trim() || undefined,
content: content.value,
coverImage: coverImage.value || undefined,
tagIds: selectedTagIds.value
})
}
router.push({ name: 'AdminArticleList' })
} catch (e) {
console.error(e)
error.value = '保存失败'
} finally {
saving.value = false
next(false)
}
} else {
next()
}
})
onMounted(() => {
window.addEventListener('online', updateOnlineStatus)
window.addEventListener('offline', updateOnlineStatus)
window.addEventListener('beforeunload', handleBeforeUnload)
startCountdown()
loadTags()
if (isEdit.value) loadArticle()
if (isEdit.value) {
loadArticle()
} else {
restoreDraft()
}
})
onBeforeUnmount(() => {
if (countdownTimer) clearInterval(countdownTimer)
window.removeEventListener('online', updateOnlineStatus)
window.removeEventListener('offline', updateOnlineStatus)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
</script>
@ -140,6 +279,21 @@ onMounted(() => {
<div v-else class="editor-wrapper">
<p v-if="error" class="error-text">{{ error }}</p>
<div class="editor-header">
<div class="auto-save-status">
<span v-if="isOnline && !autoSaveMessage && !isAutoSaving" class="countdown">
{{ countdown }}s 后自动保存
</span>
<span v-if="isAutoSaving" class="saving">保存中...</span>
<span
v-if="autoSaveMessage"
:class="['save-message', autoSaveMessage.includes('离线') || autoSaveMessage.includes('失败') ? 'error' : 'success']"
>
{{ autoSaveMessage }}
</span>
</div>
</div>
<div class="editor-layout">
<div class="editor-main">
<input
@ -208,6 +362,38 @@ onMounted(() => {
max-width: 1200px;
}
.editor-header {
display: flex;
justify-content: flex-end;
align-items: center;
margin-bottom: 16px;
}
.auto-save-status {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--ac-text-secondary);
}
.countdown {
font-variant-numeric: tabular-nums;
}
.saving {
color: var(--ac-primary-ink);
}
.save-message.success {
color: var(--ac-success-ink);
}
.save-message.error {
color: var(--ac-danger-ink);
font-weight: 500;
}
.error-text {
color: var(--ac-danger);
margin-bottom: 12px;

Loading…
Cancel
Save