802 lines
20 KiB
Vue
802 lines
20 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="page-wrap">
|
|||
|
|
<div class="container">
|
|||
|
|
<!-- 顶部:左右对称 -->
|
|||
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|||
|
|
<!-- 左:标题说明 -->
|
|||
|
|
<div class="panel h-full">
|
|||
|
|
<h1 class="text-2xl font-semibold text-slate-900">
|
|||
|
|
{{ isEditing ? '编辑资讯文章' : '发布资讯文章' }}
|
|||
|
|
</h1>
|
|||
|
|
<p class="text-sm text-slate-500 mt-2">
|
|||
|
|
支持粘贴图片、丰富排版、表格与高亮。可直接粘贴截图或拖拽图片到编辑区。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 右:当前用户 -->
|
|||
|
|
<div class="panel h-full">
|
|||
|
|
<div class="flex items-start justify-between">
|
|||
|
|
<div>
|
|||
|
|
<div class="text-sm text-slate-500">当前用户</div>
|
|||
|
|
<div v-if="me" class="mt-1.5">
|
|||
|
|
<div class="text-slate-900 font-medium leading-6 truncate">
|
|||
|
|
昵称:{{ me.username }}
|
|||
|
|
</div>
|
|||
|
|
<div class="text-slate-500 text-sm leading-5 truncate">
|
|||
|
|
邮箱:{{ me.email }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-else class="mt-1.5 text-slate-400 text-sm">读取中…</div>
|
|||
|
|
</div>
|
|||
|
|
<span
|
|||
|
|
v-if="me"
|
|||
|
|
class="inline-flex items-center gap-2 text-xs text-emerald-600"
|
|||
|
|
>
|
|||
|
|
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
|||
|
|
已登录
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-500">
|
|||
|
|
<div>自动保存:开启</div>
|
|||
|
|
<div v-if="autoSavedAt" class="text-right">
|
|||
|
|
最近:{{ fromNow(autoSavedAt) }}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 提示条 -->
|
|||
|
|
<transition name="fade">
|
|||
|
|
<div
|
|||
|
|
v-if="tip"
|
|||
|
|
class="rounded-md p-3 text-sm border mb-4"
|
|||
|
|
:class="
|
|||
|
|
tip.type === 'error'
|
|||
|
|
? 'bg-rose-50 border-rose-200 text-rose-700'
|
|||
|
|
: 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
|||
|
|
"
|
|||
|
|
>
|
|||
|
|
{{ tip.message }}
|
|||
|
|
</div>
|
|||
|
|
</transition>
|
|||
|
|
|
|||
|
|
<!-- 表单卡片 -->
|
|||
|
|
<div class="panel shadow-card">
|
|||
|
|
<div class="grid gap-6">
|
|||
|
|
<!-- 标题 -->
|
|||
|
|
<div>
|
|||
|
|
<label class="label">
|
|||
|
|
标题 <span class="text-rose-500">*</span>
|
|||
|
|
</label>
|
|||
|
|
<div class="relative">
|
|||
|
|
<input
|
|||
|
|
v-model.trim="form.title"
|
|||
|
|
type="text"
|
|||
|
|
required
|
|||
|
|
class="ui-input"
|
|||
|
|
placeholder="例如:AI 在营销自动化中的 5 个落地案例"
|
|||
|
|
maxlength="120"
|
|||
|
|
/>
|
|||
|
|
<span class="counter">{{ form.title?.length || 0 }}/120</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 摘要 -->
|
|||
|
|
<div>
|
|||
|
|
<div class="flex items-baseline justify-between">
|
|||
|
|
<label class="label">
|
|||
|
|
摘要 <span class="text-slate-400 text-xs">(选填)</span>
|
|||
|
|
</label>
|
|||
|
|
<span class="text-xs text-slate-400">
|
|||
|
|
{{ form.description?.length || 0 }}/200
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<textarea
|
|||
|
|
v-model.trim="form.description"
|
|||
|
|
rows="3"
|
|||
|
|
maxlength="200"
|
|||
|
|
class="ui-textarea"
|
|||
|
|
placeholder="一句话或一小段话,概括文章要点…"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 封面图片(选填) -->
|
|||
|
|
<div>
|
|||
|
|
<label class="label">
|
|||
|
|
封面图片 <span class="text-slate-400 text-xs">(选填)</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<div class="cover-upload">
|
|||
|
|
<!-- 预览:本地 blob 优先,其次使用已保存 URL -->
|
|||
|
|
<div v-if="form.coverPreview || form.cover" class="cover-preview">
|
|||
|
|
<img
|
|||
|
|
:src="form.coverPreview || form.cover"
|
|||
|
|
alt="封面预览"
|
|||
|
|
class="cover-img"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 选择封面(仅本地预览,不立刻上传) -->
|
|||
|
|
<label class="btn btn-outline btn-xs">
|
|||
|
|
选择封面
|
|||
|
|
<input
|
|||
|
|
class="cover-input"
|
|||
|
|
type="file"
|
|||
|
|
accept="image/*"
|
|||
|
|
@change="onPickCover"
|
|||
|
|
/>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<!-- 移除封面 -->
|
|||
|
|
<button
|
|||
|
|
v-if="form.coverPreview || form.cover"
|
|||
|
|
type="button"
|
|||
|
|
class="btn btn-ghost btn-xs"
|
|||
|
|
@click="removeCover"
|
|||
|
|
>
|
|||
|
|
移除
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<p class="mt-1 text-[10px] text-slate-400">
|
|||
|
|
建议尺寸 800x400 或相似比例,体积 < 500KB。
|
|||
|
|
封面文件仅在文章发布成功时上传保存。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 正文 -->
|
|||
|
|
<div>
|
|||
|
|
<div class="flex items-center justify-between mb-2">
|
|||
|
|
<label class="label">
|
|||
|
|
正文 <span class="text-rose-500">*</span>
|
|||
|
|
</label>
|
|||
|
|
<div class="text-xs text-slate-400">字数:{{ wordCount }}</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<RichEditor v-model="form.body" />
|
|||
|
|
|
|||
|
|
<p class="mt-2 text-xs text-slate-500">
|
|||
|
|
小技巧:截屏后直接
|
|||
|
|
<kbd class="kbd">Ctrl</kbd>+<kbd class="kbd">V</kbd>
|
|||
|
|
即可插入图片(正文内图片会即时上传)。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 标签 -->
|
|||
|
|
<div>
|
|||
|
|
<label class="label mb-1">标签</label>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<input
|
|||
|
|
v-model.trim="tagInput"
|
|||
|
|
@keydown.enter.prevent="addTag()"
|
|||
|
|
@keydown.,.prevent="addTag()"
|
|||
|
|
class="ui-input flex-1"
|
|||
|
|
placeholder="例如:AI, 营销, OpenAI"
|
|||
|
|
/>
|
|||
|
|
<button type="button" class="btn btn-outline" @click="addTag()">
|
|||
|
|
添加
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="form.tagList.length" class="mt-3 flex flex-wrap gap-2">
|
|||
|
|
<span v-for="(t, i) in form.tagList" :key="t + i" class="chip">
|
|||
|
|
{{ t }}
|
|||
|
|
<button type="button" class="chip-close" @click="removeTag(i)">
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 操作区 -->
|
|||
|
|
<div class="actions">
|
|||
|
|
<span v-if="autoSavedAt" class="text-xs text-emerald-600 mr-auto">
|
|||
|
|
已自动保存 {{ fromNow(autoSavedAt) }}
|
|||
|
|
</span>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="btn btn-ghost"
|
|||
|
|
@click="saveDraftManually"
|
|||
|
|
>
|
|||
|
|
手动保存草稿
|
|||
|
|
</button>
|
|||
|
|
<button type="button" class="btn btn-ghost" @click="clearDraft">
|
|||
|
|
清空草稿
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
class="btn btn-primary"
|
|||
|
|
:disabled="submitting"
|
|||
|
|
@click="submit"
|
|||
|
|
>
|
|||
|
|
<svg v-if="submitting" class="spinner" viewBox="0 0 50 50">
|
|||
|
|
<circle
|
|||
|
|
class="path"
|
|||
|
|
cx="25"
|
|||
|
|
cy="25"
|
|||
|
|
r="20"
|
|||
|
|
fill="none"
|
|||
|
|
stroke-width="5"
|
|||
|
|
/>
|
|||
|
|
</svg>
|
|||
|
|
<span>
|
|||
|
|
{{
|
|||
|
|
submitting
|
|||
|
|
? (isEditing ? '保存中…' : '发布中…')
|
|||
|
|
: (isEditing ? '保存修改' : '发布文章')
|
|||
|
|
}}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 额外说明 -->
|
|||
|
|
<p class="text-xs text-slate-500 mt-3">
|
|||
|
|
发布须知:请确保不含敏感信息;正文图片为即时上传,封面图片在发布时统一写入后端。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||
|
|
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
|
|||
|
|
import { navigateTo } from '#app'
|
|||
|
|
import RichEditor from '@/components/RichEditor.vue'
|
|||
|
|
import { useAuthToken, useApi } from '@/composables/useApi'
|
|||
|
|
import { useUpload } from '@/composables/useUpload'
|
|||
|
|
import { useImageExtractor } from '@/composables/useImageExtractor'
|
|||
|
|
|
|||
|
|
const route = useRoute()
|
|||
|
|
const { token } = useAuthToken()
|
|||
|
|
const api = useApi()
|
|||
|
|
const { uploadImage } = useUpload()
|
|||
|
|
const { extractFirstImage } = useImageExtractor()
|
|||
|
|
const editSlug = computed(() =>
|
|||
|
|
typeof route.query.slug === 'string' ? route.query.slug : null,
|
|||
|
|
)
|
|||
|
|
const isEditing = computed(() => Boolean(editSlug.value))
|
|||
|
|
|
|||
|
|
// 当前用户
|
|||
|
|
const me = ref(null)
|
|||
|
|
async function fetchMe() {
|
|||
|
|
if (!token.value) return
|
|||
|
|
try {
|
|||
|
|
const resp = await api.get('/user')
|
|||
|
|
me.value = resp?.user ?? null
|
|||
|
|
} catch {
|
|||
|
|
me.value = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 表单
|
|||
|
|
const form = reactive({
|
|||
|
|
title: '',
|
|||
|
|
description: '',
|
|||
|
|
body: '',
|
|||
|
|
tagList: [],
|
|||
|
|
cover: '', // 最终写入后端的 URL
|
|||
|
|
coverPreview: '', // 本地预览 blob
|
|||
|
|
})
|
|||
|
|
const coverFile = ref(null)
|
|||
|
|
|
|||
|
|
const tagInput = ref('')
|
|||
|
|
const submitting = ref(false)
|
|||
|
|
const tip = ref(null)
|
|||
|
|
const previewHtml = computed(() => form.body || '')
|
|||
|
|
|
|||
|
|
function resetForm() {
|
|||
|
|
form.title = ''
|
|||
|
|
form.description = ''
|
|||
|
|
form.body = ''
|
|||
|
|
form.tagList = []
|
|||
|
|
form.cover = ''
|
|||
|
|
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(form.coverPreview)
|
|||
|
|
}
|
|||
|
|
form.coverPreview = ''
|
|||
|
|
coverFile.value = null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadArticleForEdit(slug) {
|
|||
|
|
try {
|
|||
|
|
const res = await api.get(`/articles/${slug}`)
|
|||
|
|
const data = res?.article
|
|||
|
|
if (!data) return
|
|||
|
|
form.title = data.title || ''
|
|||
|
|
form.description = data.description || ''
|
|||
|
|
form.body = data.body || ''
|
|||
|
|
const tags = Array.isArray(data.tagList) ? data.tagList : data.tags
|
|||
|
|
form.tagList = Array.isArray(tags) ? [...tags] : []
|
|||
|
|
form.cover = data.cover || ''
|
|||
|
|
form.coverPreview = ''
|
|||
|
|
coverFile.value = null
|
|||
|
|
autoSavedAt.value = Date.now()
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('[edit-article] load article failed:', error)
|
|||
|
|
tip.value = { type: 'error', message: '加载文章详情失败' }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 生命周期
|
|||
|
|
function applyDraftData(draft = {}) {
|
|||
|
|
form.title = draft.title || ''
|
|||
|
|
form.description = draft.description || ''
|
|||
|
|
form.body = draft.body || ''
|
|||
|
|
form.tagList = Array.isArray(draft.tagList) ? [...draft.tagList] : []
|
|||
|
|
form.cover = draft.cover || ''
|
|||
|
|
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(form.coverPreview)
|
|||
|
|
}
|
|||
|
|
form.coverPreview = ''
|
|||
|
|
coverFile.value = null
|
|||
|
|
autoSavedAt.value = Date.now()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readDraft() {
|
|||
|
|
if (!process.client) return { loaded: false, hasContent: false, data: null }
|
|||
|
|
const raw = localStorage.getItem(draftKey.value)
|
|||
|
|
if (!raw) return { loaded: false, hasContent: false, data: null }
|
|||
|
|
try {
|
|||
|
|
const data = JSON.parse(raw) || {}
|
|||
|
|
const hasContent = Boolean(
|
|||
|
|
data.title ||
|
|||
|
|
data.description ||
|
|||
|
|
data.body ||
|
|||
|
|
(Array.isArray(data.tagList) && data.tagList.length) ||
|
|||
|
|
data.cover,
|
|||
|
|
)
|
|||
|
|
return { loaded: true, hasContent, data }
|
|||
|
|
} catch {
|
|||
|
|
return { loaded: false, hasContent: false, data: null }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function initEditor(slug) {
|
|||
|
|
resetForm()
|
|||
|
|
if (slug) {
|
|||
|
|
await loadArticleForEdit(slug)
|
|||
|
|
} else {
|
|||
|
|
const draftInfo = readDraft()
|
|||
|
|
if (draftInfo.hasContent && draftInfo.data) {
|
|||
|
|
applyDraftData(draftInfo.data)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
if (!token.value) {
|
|||
|
|
navigateTo('/login')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
await fetchMe()
|
|||
|
|
await initEditor(editSlug.value)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onBeforeRouteUpdate(async (to, from, next) => {
|
|||
|
|
const slug =
|
|||
|
|
typeof to.query.slug === 'string'
|
|||
|
|
? to.query.slug
|
|||
|
|
: null
|
|||
|
|
await initEditor(slug)
|
|||
|
|
next()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 正文字数
|
|||
|
|
const wordCount = computed(
|
|||
|
|
() => form.body.replace(/<[^>]+>/g, '').trim().length,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 标签
|
|||
|
|
function addTag() {
|
|||
|
|
if (!tagInput.value) return
|
|||
|
|
tagInput.value
|
|||
|
|
.split(/[,,]/)
|
|||
|
|
.map(s => s.trim())
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.forEach((t) => {
|
|||
|
|
if (!form.tagList.includes(t)) form.tagList.push(t)
|
|||
|
|
})
|
|||
|
|
tagInput.value = ''
|
|||
|
|
}
|
|||
|
|
function removeTag(i) {
|
|||
|
|
form.tagList.splice(i, 1)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 选择封面:仅更新本地预览 & 待上传文件
|
|||
|
|
function onPickCover(e) {
|
|||
|
|
const file = e.target.files?.[0]
|
|||
|
|
e.target.value = ''
|
|||
|
|
if (!file) return
|
|||
|
|
|
|||
|
|
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(form.coverPreview)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
coverFile.value = file
|
|||
|
|
form.coverPreview = URL.createObjectURL(file)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 移除封面
|
|||
|
|
function removeCover() {
|
|||
|
|
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(form.coverPreview)
|
|||
|
|
}
|
|||
|
|
coverFile.value = null
|
|||
|
|
form.coverPreview = ''
|
|||
|
|
form.cover = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 草稿
|
|||
|
|
const draftKey = computed(() =>
|
|||
|
|
editSlug.value ? `article:edit:${editSlug.value}` : 'article:new:draft:v2',
|
|||
|
|
)
|
|||
|
|
const autoSavedAt = ref(0)
|
|||
|
|
|
|||
|
|
function loadDraft() {
|
|||
|
|
const info = readDraft()
|
|||
|
|
if (!editSlug.value && info.hasContent && info.data) {
|
|||
|
|
applyDraftData(info.data)
|
|||
|
|
return { loaded: true, hasContent: true }
|
|||
|
|
}
|
|||
|
|
return info
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveDraft() {
|
|||
|
|
if (!process.client) return
|
|||
|
|
const payload = {
|
|||
|
|
title: form.title,
|
|||
|
|
description: form.description,
|
|||
|
|
body: form.body,
|
|||
|
|
tagList: form.tagList,
|
|||
|
|
cover: form.cover,
|
|||
|
|
}
|
|||
|
|
localStorage.setItem(draftKey.value, JSON.stringify(payload))
|
|||
|
|
autoSavedAt.value = Date.now()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveDraftManually() {
|
|||
|
|
saveDraft()
|
|||
|
|
tip.value = { type: 'success', message: '草稿已保存到本地' }
|
|||
|
|
setTimeout(() => { tip.value = null }, 1500)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function clearDraft() {
|
|||
|
|
if (process.client) localStorage.removeItem(draftKey.value)
|
|||
|
|
if (form.coverPreview && form.coverPreview.startsWith('blob:')) {
|
|||
|
|
URL.revokeObjectURL(form.coverPreview)
|
|||
|
|
}
|
|||
|
|
form.title = ''
|
|||
|
|
form.description = ''
|
|||
|
|
form.body = ''
|
|||
|
|
form.tagList = []
|
|||
|
|
form.cover = ''
|
|||
|
|
form.coverPreview = ''
|
|||
|
|
coverFile.value = null
|
|||
|
|
tip.value = { type: 'success', message: '草稿已清空' }
|
|||
|
|
setTimeout(() => { tip.value = null }, 1500)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let t = null
|
|||
|
|
watch(
|
|||
|
|
() => ({
|
|||
|
|
title: form.title,
|
|||
|
|
description: form.description,
|
|||
|
|
body: form.body,
|
|||
|
|
tagList: form.tagList,
|
|||
|
|
cover: form.cover,
|
|||
|
|
}),
|
|||
|
|
() => {
|
|||
|
|
clearTimeout(t)
|
|||
|
|
t = setTimeout(saveDraft, 500)
|
|||
|
|
},
|
|||
|
|
{ deep: true },
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
function fromNow(ts) {
|
|||
|
|
const s = Math.max(1, Math.round((Date.now() - ts) / 1000))
|
|||
|
|
if (s < 60) return `${s}s 前`
|
|||
|
|
const m = Math.round(s / 60)
|
|||
|
|
if (m < 60) return `${m} 分钟前`
|
|||
|
|
return `${Math.round(m / 60)} 小时前`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 提交
|
|||
|
|
async function submit() {
|
|||
|
|
if (!form.title?.trim() || !form.body?.trim()) {
|
|||
|
|
tip.value = { type: 'error', message: '标题与正文为必填项' }
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if (!token.value) {
|
|||
|
|
tip.value = { type: 'error', message: '请先登录' }
|
|||
|
|
navigateTo('/login')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
submitting.value = true
|
|||
|
|
tip.value = null
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 1. 有新封面则先上传
|
|||
|
|
let coverUrl = form.cover || ''
|
|||
|
|
if (coverFile.value) {
|
|||
|
|
const url = await uploadImage(coverFile.value)
|
|||
|
|
coverUrl = url
|
|||
|
|
form.cover = url // ✅ 回写,确保 payload / 草稿里都有
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. 如果没有封面,尝试从正文中提取第一张图片
|
|||
|
|
if (!coverUrl && form.body) {
|
|||
|
|
const firstImage = extractFirstImage(form.body)
|
|||
|
|
if (firstImage) {
|
|||
|
|
coverUrl = firstImage
|
|||
|
|
console.log('📸 自动提取封面图片:', firstImage)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 创建文章
|
|||
|
|
const payload = {
|
|||
|
|
article: {
|
|||
|
|
title: form.title.trim(),
|
|||
|
|
description: form.description?.trim() || '',
|
|||
|
|
body: form.body,
|
|||
|
|
tagList: form.tagList,
|
|||
|
|
cover: coverUrl || null,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let res
|
|||
|
|
if (isEditing.value && editSlug.value) {
|
|||
|
|
res = await api.put(`/articles/${editSlug.value}`, payload)
|
|||
|
|
} else {
|
|||
|
|
res = await api.post('/articles', payload)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
clearDraft()
|
|||
|
|
tip.value = {
|
|||
|
|
type: 'success',
|
|||
|
|
message: isEditing.value ? '修改已保存!' : '发布成功!',
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const slug = res?.article?.slug || editSlug.value
|
|||
|
|
setTimeout(() => {
|
|||
|
|
navigateTo(slug ? `/articles/${slug}` : '/')
|
|||
|
|
}, 600)
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('[new-article] submit error:', e)
|
|||
|
|
tip.value = {
|
|||
|
|
type: 'error',
|
|||
|
|
message:
|
|||
|
|
e?.statusMessage ||
|
|||
|
|
e?.data?.detail ||
|
|||
|
|
e?.data?.errors?.[0] ||
|
|||
|
|
'发布失败,请稍后重试',
|
|||
|
|
}
|
|||
|
|
} finally {
|
|||
|
|
submitting.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* 样式保持你的原版,这里不改动 */
|
|||
|
|
.page-wrap {
|
|||
|
|
margin: 100px 5%;
|
|||
|
|
}
|
|||
|
|
.container {
|
|||
|
|
max-width: 1200px;
|
|||
|
|
margin-left: auto;
|
|||
|
|
margin-right: auto;
|
|||
|
|
padding: 0 20px;
|
|||
|
|
}
|
|||
|
|
.panel {
|
|||
|
|
background: #fff;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
border-radius: 16px;
|
|||
|
|
padding: 16px 18px;
|
|||
|
|
min-height: 120px;
|
|||
|
|
}
|
|||
|
|
.shadow-card {
|
|||
|
|
box-shadow: 0 8px 28px rgba(10, 18, 33, 0.12);
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.actions {
|
|||
|
|
position: sticky;
|
|||
|
|
bottom: 0;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding-top: 12px;
|
|||
|
|
margin-top: 8px;
|
|||
|
|
background: linear-gradient(
|
|||
|
|
180deg,
|
|||
|
|
rgba(255, 255, 255, 0) 0%,
|
|||
|
|
#fff 24px
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
.label {
|
|||
|
|
display: block;
|
|||
|
|
font-size: 0.875rem;
|
|||
|
|
line-height: 1.25rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #334155;
|
|||
|
|
margin-bottom: 0.25rem;
|
|||
|
|
}
|
|||
|
|
.ui-input,
|
|||
|
|
.ui-textarea {
|
|||
|
|
width: 100%;
|
|||
|
|
max-width: 100%;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
padding: 10px 14px;
|
|||
|
|
outline: none;
|
|||
|
|
background: #fff;
|
|||
|
|
transition: box-shadow 0.15s ease, border-color 0.15s ease,
|
|||
|
|
transform 0.02s ease;
|
|||
|
|
box-shadow: 0 1px 0 rgba(2, 6, 23, 0.02) inset;
|
|||
|
|
}
|
|||
|
|
.ui-input::placeholder,
|
|||
|
|
.ui-textarea::placeholder {
|
|||
|
|
color: #9aa3b2;
|
|||
|
|
}
|
|||
|
|
.ui-input:hover,
|
|||
|
|
.ui-textarea:hover {
|
|||
|
|
border-color: #d2d6dc;
|
|||
|
|
}
|
|||
|
|
.ui-input:focus,
|
|||
|
|
.ui-textarea:focus {
|
|||
|
|
border-color: #6366f1;
|
|||
|
|
box-shadow: inset 0 0 0 1px #6366f1;
|
|||
|
|
}
|
|||
|
|
.ui-textarea {
|
|||
|
|
min-height: 110px;
|
|||
|
|
resize: vertical;
|
|||
|
|
}
|
|||
|
|
.counter {
|
|||
|
|
position: absolute;
|
|||
|
|
right: 10px;
|
|||
|
|
bottom: 8px;
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: #98a2b3;
|
|||
|
|
}
|
|||
|
|
.cover-upload {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
}
|
|||
|
|
.cover-input {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
.cover-preview {
|
|||
|
|
width: 110px;
|
|||
|
|
height: 66px;
|
|||
|
|
border-radius: 10px;
|
|||
|
|
border: 1px solid #e5e7eb;
|
|||
|
|
background: #f8fafc;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
.cover-img {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
}
|
|||
|
|
.btn-xs {
|
|||
|
|
height: 30px;
|
|||
|
|
padding: 0 10px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
.btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
border-radius: 12px;
|
|||
|
|
line-height: 1;
|
|||
|
|
border: 1px solid transparent;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: transform 0.02s ease, box-shadow 0.15s ease,
|
|||
|
|
background 0.15s ease, border-color 0.15s ease;
|
|||
|
|
}
|
|||
|
|
.btn {
|
|||
|
|
height: 40px;
|
|||
|
|
padding: 0 14px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
.btn:active {
|
|||
|
|
transform: translateY(0.5px);
|
|||
|
|
}
|
|||
|
|
.btn:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
.btn-primary {
|
|||
|
|
color: #fff;
|
|||
|
|
background: linear-gradient(135deg, #4f46e5, #2563eb);
|
|||
|
|
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.25);
|
|||
|
|
}
|
|||
|
|
.btn-primary:hover {
|
|||
|
|
filter: brightness(1.03);
|
|||
|
|
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.28);
|
|||
|
|
}
|
|||
|
|
.btn-outline {
|
|||
|
|
background: #fff;
|
|||
|
|
border-color: #e5e7eb;
|
|||
|
|
color: #334155;
|
|||
|
|
}
|
|||
|
|
.btn-outline:hover {
|
|||
|
|
border-color: #cbd5e1;
|
|||
|
|
box-shadow: 0 6px 16px rgba(2, 6, 23, 0.06);
|
|||
|
|
}
|
|||
|
|
.btn-ghost {
|
|||
|
|
background: #fff;
|
|||
|
|
border-color: #e5e7eb;
|
|||
|
|
color: #475569;
|
|||
|
|
}
|
|||
|
|
.btn-ghost:hover {
|
|||
|
|
background: #f8fafc;
|
|||
|
|
}
|
|||
|
|
.chip {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
padding: 6px 8px 6px 12px;
|
|||
|
|
background: #f1f5f9;
|
|||
|
|
color: #334155;
|
|||
|
|
border: 1px solid #e2e8f0;
|
|||
|
|
border-radius: 999px;
|
|||
|
|
}
|
|||
|
|
.chip-close {
|
|||
|
|
color: #64748b;
|
|||
|
|
}
|
|||
|
|
.chip-close:hover {
|
|||
|
|
color: #ef4444;
|
|||
|
|
}
|
|||
|
|
.kbd {
|
|||
|
|
padding: 0 0.4rem;
|
|||
|
|
border: 1px solid #cbd5e1;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
background: #f8fafc;
|
|||
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|||
|
|
}
|
|||
|
|
.spinner {
|
|||
|
|
width: 18px;
|
|||
|
|
height: 18px;
|
|||
|
|
}
|
|||
|
|
.spinner .path {
|
|||
|
|
stroke: #fff;
|
|||
|
|
stroke-linecap: round;
|
|||
|
|
animation: dash 1.2s ease-in-out infinite;
|
|||
|
|
}
|
|||
|
|
@keyframes dash {
|
|||
|
|
0% {
|
|||
|
|
stroke-dasharray: 1, 150;
|
|||
|
|
stroke-dashoffset: 0;
|
|||
|
|
}
|
|||
|
|
50% {
|
|||
|
|
stroke-dasharray: 90, 150;
|
|||
|
|
stroke-dashoffset: -35;
|
|||
|
|
}
|
|||
|
|
100% {
|
|||
|
|
stroke-dasharray: 90, 150;
|
|||
|
|
stroke-dashoffset: -124;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.fade-enter-active,
|
|||
|
|
.fade-leave-active {
|
|||
|
|
transition: opacity 0.18s ease;
|
|||
|
|
}
|
|||
|
|
.fade-enter-from,
|
|||
|
|
.fade-leave-to {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
</style>
|