@ -1,6 +1,6 @@
< script setup lang = "ts" >
< 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 { articleAdminApi , articleApi , tagApi , mediaApi } from '@/api/client'
import type { ArticleDetail , Tag } from '@/generated/api'
import type { ArticleDetail , Tag } from '@/generated/api'
import QuillEditor from '@/components/QuillEditor.vue'
import QuillEditor from '@/components/QuillEditor.vue'
@ -21,6 +21,149 @@ const saving = ref(false)
const error = ref < string | null > ( null )
const error = ref < string | null > ( null )
const uploading = ref ( false )
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 ( ) {
async function loadTags ( ) {
try {
try {
const resp = await tagApi . apiTagsGet ( )
const resp = await tagApi . apiTagsGet ( )
@ -51,6 +194,8 @@ async function loadArticle() {
content . value = detail . content
content . value = detail . content
coverImage . value = detail . coverImage ? ? ''
coverImage . value = detail . coverImage ? ? ''
selectedTagIds . value = matchTagIds ( detail )
selectedTagIds . value = matchTagIds ( detail )
lastSavedContent . value = detail . content
hasUnsavedChanges . value = false
}
}
} catch ( e ) {
} catch ( e ) {
console . error ( e )
console . error ( e )
@ -94,44 +239,38 @@ async function removeCover() {
coverImage . value = ''
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 {
} 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 ( ( ) => {
onMounted ( ( ) => {
window . addEventListener ( 'online' , updateOnlineStatus )
window . addEventListener ( 'offline' , updateOnlineStatus )
window . addEventListener ( 'beforeunload' , handleBeforeUnload )
startCountdown ( )
loadTags ( )
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 >
< / script >
@ -140,6 +279,21 @@ onMounted(() => {
< div v -else class = "editor-wrapper" >
< div v -else class = "editor-wrapper" >
< p v-if ="error" class="error-text" > {{ error }} < / p >
< 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-layout" >
< div class = "editor-main" >
< div class = "editor-main" >
< input
< input
@ -208,6 +362,38 @@ onMounted(() => {
max - width : 1200 px ;
max - width : 1200 px ;
}
}
. editor - header {
display : flex ;
justify - content : flex - end ;
align - items : center ;
margin - bottom : 16 px ;
}
. auto - save - status {
display : flex ;
align - items : center ;
gap : 12 px ;
font - size : 13 px ;
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 {
. error - text {
color : var ( -- ac - danger ) ;
color : var ( -- ac - danger ) ;
margin - bottom : 12 px ;
margin - bottom : 12 px ;