Files
Jog/jog-frontend/src/views/app/ArticleEditor.vue
T
2026-05-23 03:17:46 +08:00

412 lines
13 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="article-editor">
<h2>{{ isEdit ? '编辑文章' : '写文章' }}</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.categoryId" placeholder="选择分类" clearable>
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
</el-select>
</el-form-item>
<el-form-item label="标签">
<div class="tag-wrapper">
<div class="tag-select-row">
<el-select v-model="form.tagIds" multiple placeholder="选择标签(最多5个)" :max-collapse-tags="5" class="tag-select">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id" />
</el-select>
<el-button
type="primary"
size="small"
:loading="recommending"
@click="handleRecommendTags"
class="recommend-btn"
>
智能推荐
</el-button>
</div>
<div v-if="recommendedTags.length" class="tag-recommend-result">
<span class="recommend-label">推荐标签</span>
<el-checkbox-group v-model="selectedRecommendedTags" class="recommend-checkboxes">
<el-checkbox v-for="tag in recommendedTags" :key="tag" :label="tag" />
</el-checkbox-group>
<el-button type="success" size="small" @click="confirmAddTags" :disabled="!selectedRecommendedTags.length">
确认添加
</el-button>
<el-button size="small" @click="clearRecommendedTags">取消</el-button>
</div>
</div>
</el-form-item>
<el-form-item label="内容">
<div class="editor-wrapper">
<div v-if="editor" class="toolbar">
<el-button-group>
<el-button size="small" @click="editor.chain().focus().toggleBold().run()" :type="editor.isActive('bold') ? 'primary' : ''">B</el-button>
<el-button size="small" @click="editor.chain().focus().toggleItalic().run()" :type="editor.isActive('italic') ? 'primary' : ''">I</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 2 }).run()" :type="editor.isActive('heading', { level: 2 }) ? 'primary' : ''">H2</el-button>
<el-button size="small" @click="editor.chain().focus().toggleHeading({ level: 3 }).run()" :type="editor.isActive('heading', { level: 3 }) ? 'primary' : ''">H3</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBulletList().run()" :type="editor.isActive('bulletList') ? 'primary' : ''">列表</el-button>
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
</el-button-group>
</div>
<EditorContent :editor="editor" class="editor-content" />
</div>
</el-form-item>
<el-form-item label="摘要">
<div class="summary-wrapper">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="文章摘要(可手动撰写,也可点击下方按钮由 AI 生成)" />
<div class="summary-actions">
<el-button
type="primary"
size="small"
:loading="summarizing"
@click="handleGenerateSummary"
>
AI 生成摘要
</el-button>
<span v-if="lastSummaryMeta" class="summary-meta">
{{ lastSummaryMeta }}
</span>
</div>
</div>
</el-form-item>
<el-form-item label="可见性">
<el-radio-group v-model="form.visibility">
<el-radio :value="0">公开</el-radio>
<el-radio :value="1">私密</el-radio>
<el-radio :value="2">仅粉丝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit(false)" :loading="saving">保存草稿</el-button>
<el-button type="success" @click="handleSubmit(true)" :loading="saving">发布</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag } from '@/api/article'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const saving = ref(false)
const summarizing = ref(false)
const lastSummaryMeta = ref('')
const recommending = ref(false)
const recommendedTags = ref([])
const selectedRecommendedTags = ref([])
const categories = ref([])
const tags = ref([])
const form = ref({
title: '',
categoryId: null,
tagIds: [],
summary: '',
visibility: 0,
allowComment: true,
})
const MIN_CONTENT_LENGTH = 20
function getPlainTextContent() {
const html = editor.value?.getHTML() || ''
const tmp = document.createElement('div')
tmp.innerHTML = html
return (tmp.textContent || tmp.innerText || '').trim()
}
async function handleGenerateSummary() {
const plain = getPlainTextContent()
if (plain.length < MIN_CONTENT_LENGTH) {
ElMessage.warning('正文过短,无法生成摘要')
return
}
if (form.value.summary && form.value.summary.trim()) {
try {
await ElMessageBox.confirm('已有摘要,确认覆盖?', '提示', {
confirmButtonText: '覆盖',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
}
summarizing.value = true
lastSummaryMeta.value = ''
try {
const res = await generateArticleSummary({
title: form.value.title || '',
content: plain,
})
form.value.summary = res.data.summary
const meta = res.data
lastSummaryMeta.value = `模型: ${meta.model || '-'} 耗时: ${meta.costMillis ?? '-'}ms` +
(meta.promptTokens != null ? ` tokens: ${meta.promptTokens}/${meta.completionTokens}` : '')
ElMessage.success('摘要生成成功')
} catch (err) {
if (err?.code === 'ECONNABORTED') {
ElMessage.error('AI 服务响应超时,请稍后再试')
}
} finally {
summarizing.value = false
}
}
async function handleRecommendTags() {
const plain = getPlainTextContent()
if (plain.length < 10) {
ElMessage.warning('正文过短,无法推荐标签')
return
}
recommending.value = true
recommendedTags.value = []
selectedRecommendedTags.value = []
try {
const res = await recommendTags({
title: form.value.title || '',
content: plain,
})
const allTags = res.data.tags || []
// 过滤已选中的标签
const existingNames = tags.value
.filter(t => form.value.tagIds.includes(t.id))
.map(t => t.name)
recommendedTags.value = allTags.filter(t => !existingNames.includes(t))
if (!recommendedTags.value.length) {
ElMessage.info('没有新的推荐标签')
} else {
ElMessage.success(`推荐了 ${recommendedTags.value.length} 个标签`)
}
} catch (err) {
if (err?.code === 'ECONNABORTED') {
ElMessage.error('AI 服务响应超时,请稍后再试')
}
} finally {
recommending.value = false
}
}
async function resolveTagId(tagName) {
const name = (tagName || '').trim()
if (!name) return null
const existing = tags.value.find(t => t.name === name)
if (existing) return existing.id
try {
const res = await createAppTag(name)
return res.data?.id ?? null
} catch {
ElMessage.warning(`标签「${name}」创建失败,已跳过`)
return null
}
}
async function confirmAddTags() {
if (!selectedRecommendedTags.value.length) return
const idsToAdd = []
for (const tagName of selectedRecommendedTags.value) {
const tagId = await resolveTagId(tagName)
if (tagId != null) idsToAdd.push(tagId)
}
if (!idsToAdd.length) return
await loadTags()
const merged = [...form.value.tagIds]
for (const tagId of idsToAdd) {
if (merged.includes(tagId)) continue
if (merged.length >= 5) {
ElMessage.warning('最多选择 5 个标签')
break
}
merged.push(tagId)
}
form.value.tagIds = merged
recommendedTags.value = []
selectedRecommendedTags.value = []
ElMessage.success('标签添加完成')
}
function clearRecommendedTags() {
recommendedTags.value = []
selectedRecommendedTags.value = []
}
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: '开始写作...' }),
],
content: '',
})
async function loadCategories() {
const res = await getCategories()
categories.value = res.data
}
async function loadTags() {
const res = await getTags()
tags.value = res.data
}
async function loadArticle() {
if (!isEdit.value) return
const res = await getArticleDetail(route.params.id)
const article = res.data
form.value.title = article.title
form.value.categoryId = article.categoryId
form.value.tagIds = article.tags?.map(t => t.id) || []
form.value.summary = article.summary
form.value.visibility = article.visibility
if (editor.value) {
editor.value.commands.setContent(article.content || '')
}
}
async function handleSubmit(publish) {
if (!form.value.title) {
ElMessage.warning('请输入标题')
return
}
saving.value = true
try {
const data = {
...form.value,
content: editor.value?.getHTML() || '',
status: publish ? 1 : 0,
}
if (isEdit.value) {
await updateArticle(route.params.id, data)
ElMessage.success('更新成功')
} else {
const res = await createArticle(data)
ElMessage.success(publish ? '发布成功' : '已保存草稿')
if (!isEdit.value) {
router.push(`/app/editor/${res.data}`)
}
}
} finally {
saving.value = false
}
}
onMounted(() => {
loadCategories()
loadTags()
loadArticle()
})
onBeforeUnmount(() => {
editor.value?.destroy()
})
</script>
<style scoped>
.article-editor { max-width: 900px; }
.article-editor h2 {
color: var(--color-primary-800);
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-xl);
}
.editor-wrapper {
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
width: 100%;
overflow: hidden;
transition: border-color var(--transition-fast);
}
.editor-wrapper:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-light);
}
.toolbar {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--border-color-light);
background: var(--bg-soft);
}
.editor-content { min-height: 400px; padding: var(--spacing-lg); }
.editor-content :deep(.tiptap) {
outline: none;
min-height: 360px;
font-size: var(--font-size-md);
line-height: var(--line-height-loose);
}
.editor-content :deep(.tiptap p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: var(--text-placeholder);
pointer-events: none;
height: 0;
}
.editor-content :deep(.tiptap h1),
.editor-content :deep(.tiptap h2),
.editor-content :deep(.tiptap h3) {
color: var(--text-primary);
margin-top: var(--spacing-lg);
margin-bottom: var(--spacing-sm);
}
.editor-content :deep(.tiptap blockquote) {
border-left: 4px solid var(--color-primary-300);
padding-left: var(--spacing-lg);
color: var(--text-secondary);
margin: var(--spacing-md) 0;
}
.summary-wrapper {
width: 100%;
}
.summary-actions {
margin-top: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.summary-meta {
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.tag-wrapper {
width: 100%;
}
.tag-select-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.tag-select {
flex: 1;
}
.recommend-btn {
flex-shrink: 0;
}
.tag-recommend-result {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-soft);
border: 1px solid var(--border-color-light);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.recommend-label {
font-size: var(--font-size-sm);
color: var(--text-secondary);
flex-shrink: 0;
}
.recommend-checkboxes {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
</style>