Files
tyapi-frontend/src/pages/admin/articles/components/ArticleEditDialog.vue
2025-11-24 16:06:44 +08:00

340 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑文章' : '新增文章'"
width="80%"
:close-on-click-modal="false"
@open="handleDialogOpen"
v-loading="loading"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="space-y-6">
<!-- 基本信息 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文章标题" prop="title">
<el-input v-model="form.title" placeholder="请输入文章标题" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="文章分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="选择分类" clearable class="w-full">
<el-option v-for="category in categories" :key="category.id" :label="category.name"
:value="category.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="文章摘要" prop="summary">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要" maxlength="500"
show-word-limit />
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<el-input v-model="form.cover_image" placeholder="请输入封面图片URL" />
</el-form-item>
<el-form-item label="文章标签" prop="tag_ids">
<el-select v-model="form.tag_ids" multiple placeholder="选择标签" clearable class="w-full">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: tag.color }"></div>
{{ tag.name }}
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="推荐状态" prop="is_featured">
<el-switch v-model="form.is_featured" active-text="推荐" inactive-text="普通" />
</el-form-item>
</div>
<!-- 文章内容 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章内容</h3>
<el-form-item label="文章内容" prop="content">
<Editor style="width: 100%;" v-model="form.content" :init="editorInit"
tinymceScriptSrc="https://cdn.jsdelivr.net/npm/tinymce@7.9.1/tinymce.min.js" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api';
import Editor from '@tinymce/tinymce-vue';
import { ElMessage } from 'element-plus';
import { computed, reactive, ref, watch } from 'vue';
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
categories: {
type: Array,
default: () => []
},
tags: {
type: Array,
default: () => []
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
const articleDetail = ref(null)
// 表单数据
const form = reactive({
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
// 表单验证规则
const rules = {
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
],
summary: [
{ max: 500, message: '摘要长度不能超过 500 个字符', trigger: 'blur' }
]
}
// TinyMCE 配置
const editorInit = {
menubar: false,
dragdrop: true, // 启用拖拽图片功能
valid_elements: '*[*]',
valid_elements: 'section[*],*[*]', // 允许 section 及其属性
valid_children: '+body[section],+section[p,div,span]', // 允许 body 包含 sectionsection 包含段落等
file_picker_types: 'image',
invalid_elements: 'script',
statusbar: false,
placeholder: '开始编写吧', // 占位符
theme: 'silver', // 主题 必须引入
license_key: 'gpl', // 使用开源许可
paste_as_text: false, // 允许 HTML 粘贴
paste_enable_default_filters: false, // 禁用默认 HTML 过滤
paste_webkit_styles: 'all', // 允许所有 Webkit 内联样式
paste_retain_style_properties: 'all', // 保留所有 inline style
extended_valid_elements: '*[*]', // 确保所有 HTML 属性都被保留
promotion: false, // 移除 Upgrade 按钮
branding: false, // 移除 TinyMCE 品牌信息
toolbar_mode: 'wrap',
contextmenu: 'styleControls | insertBefore insertAfter | copyElement | removeIndent | deleteElement | image link',
content_style: `
body::-webkit-scrollbar {
width: 8px;
}
body::-webkit-scrollbar-track {
background: #f8fafc;
}
body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.element-highlight {
outline: 1px solid #3b82f6 !important;
}
`,
// 设置工具栏样式
toolbar_location: 'top',
// 如果需要固定工具栏
toolbar_sticky: true,
toolbar: [
'undo redo bold italic underline forecolor backcolor alignleft aligncenter alignright image insertBeforeSection insertSection styleControls mySaveBtn help_article'
]
,
plugins: [
'anchor', 'autolink', 'charmap', 'codesample', 'emoticons', 'image', 'link',
'lists', 'media', 'searchreplace', 'table', 'visualblocks', 'wordcount'
],
setup: function (editor) {
editor.on('paste', function (e) {
e.preventDefault(); // 阻止默认粘贴行为
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.types.indexOf('text/html') > -1) {
// 获取原始HTML内容
const htmlContent = clipboardData.getData('text/html');
// 直接插入原始HTML避免修改
editor.insertContent(htmlContent);
} else {
// 如果没有HTML回退到纯文本
const text = clipboardData.getData('text/plain');
editor.insertContent(text);
}
});
}
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.article)
// 获取文章详情
const fetchArticleDetail = async (articleId) => {
if (!articleId) return
loading.value = true
try {
const response = await articleApi.getArticleDetail(articleId)
articleDetail.value = response.data
// 使用详情数据填充表单
fillFormWithDetail(articleDetail.value)
} catch (error) {
ElMessage.error('获取文章详情失败')
console.error('获取文章详情失败:', error)
} finally {
loading.value = false
}
}
// 使用详情数据填充表单
const fillFormWithDetail = (detail) => {
if (!detail) return
Object.assign(form, {
title: detail.title || '',
content: detail.content || '',
summary: detail.summary || '',
cover_image: detail.cover_image || '',
category_id: detail.category_id || '',
tag_ids: detail.tags ? detail.tags.map(tag => tag.id) : [],
is_featured: detail.is_featured || false
})
}
// 对话框打开时获取详情
const handleDialogOpen = () => {
if (props.article?.id && isEdit.value) {
fetchArticleDetail(props.article.id)
}
}
// 监听文章数据变化,初始化表单
watch(() => props.article, (newArticle) => {
if (newArticle && isEdit.value) {
// 编辑模式如果有详情数据则使用详情数据否则使用props数据
if (articleDetail.value) {
fillFormWithDetail(articleDetail.value)
} else {
// 使用props数据作为临时填充
Object.assign(form, {
title: newArticle.title || '',
content: newArticle.content || '',
summary: newArticle.summary || '',
cover_image: newArticle.cover_image || '',
category_id: newArticle.category_id || '',
tag_ids: newArticle.tag_ids || [],
is_featured: newArticle.is_featured || false
})
}
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 重置表单(使用函数声明,避免提升前调用报错)
function resetForm() {
Object.assign(form, {
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateArticle(props.article.id, form)
ElMessage.success('文章更新成功')
} else {
// 新增模式
await articleApi.createArticle(form)
ElMessage.success('文章创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新文章失败' : '创建文章失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>