802 lines
20 KiB
Vue
Raw Permalink Normal View History

2025-12-04 10:04:21 +08:00
<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 或相似比例体积 &lt; 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>