Files
guilixu-admin/src/views/cms/cmsArticle/components/articleEdit.vue
T
gxwebsoft 07add26fe6 refactor(cmsArticle): 统一文章编辑器为单一 WangEditor 富文本模式
- 删除原有 TinyMCE 富文本和 MdEditor Markdown 双模式切换及相关逻辑
- 替换文章编辑弹窗内容详情为 RichTextEditor 组件
- 新增图片库和视频库选择按钮,支持插入图片和视频 HTML 标签
- 实现一键排版和首行缩进按钮,直接操作内容 HTML 字符串
- 回显时 Markdown 内容转换为 HTML,保存时固定 editor 类型为 1
- 移除无用的配置和样式,简化代码逻辑,提升编辑体验
- 留下文章编辑器类型设置字段待后续清理,展示端保持一致 HTML 渲染方式
2026-08-11 21:58:09 +08:00

1348 lines
39 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>
<a-drawer
width="70%"
:visible="visible"
:maskClosable="false"
:maxable="maxable"
:title="isUpdate ? '编辑文章' : '添加文章'"
:body-style="{ paddingBottom: '18px' }"
@update:visible="updateVisible"
:confirm-loading="loading"
@ok="save"
>
<template #extra>
<a-button type="primary" style="margin-right: 8px" @click="save"
>保存</a-button
>
</template>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="styleResponsive ? { md: 2, sm: 5, xs: 24 } : { flex: '90px' }"
:wrapper-col="
styleResponsive ? { md: 20, sm: 20, xs: 20 } : { flex: '1' }
"
>
<a-spin :spinning="loading">
<!-- <a-tabs type="card" v-model:active-key="active" @change="onChange">-->
<!-- <a-tab-pane tab="基本信息" key="base">-->
<a-form-item label="封面图" name="files">
<SelectFile
:placeholder="`请选择图片`"
:limit="6"
:data="files"
@done="chooseFile"
@del="onDeleteFile"
/>
</a-form-item>
<a-form-item label="所属栏目" name="categoryId">
<a-tree-select
allow-clear
:tree-data="navigationList"
tree-default-expand-all
style="width: 320px"
placeholder="请选择栏目"
:value="form.categoryId || undefined"
:listHeight="700"
:dropdown-style="{ overflow: 'auto' }"
@update:value="(value?: number) => (form.categoryId = value)"
@change="onCategoryId"
/>
</a-form-item>
<a-form-item label="文章标题" name="title">
<div class="title-input-container">
<a-input
allow-clear
placeholder="文章标题"
v-model:value="form.title"
@pressEnter="save"
:maxlength="100"
/>
</div>
</a-form-item>
<a-form-item label="关键词" name="tags">
<a-select
v-model:value="form.tags"
mode="tags"
placeholder="按回车分隔"
/>
</a-form-item>
<a-form-item label="摘要">
<a-textarea
:rows="3"
:maxlength="200"
show-count
placeholder="请输入文章摘要"
@focus="onComments"
v-model:value="form.comments"
/>
</a-form-item>
<a-form-item label="内容详情" name="content">
<RichTextEditor
ref="editorRef"
v-model="content"
:disabled="disabled"
:height="500"
placeholder="支持直接粘贴或拖拽图片,也可点击下方按钮从图片库选择"
/>
<div class="file-selector-tip">
💡 提示支持直接粘贴/拖拽上传图片点击下方按钮从图片库/视频库选择或使用排版」「首行缩进优化文章
</div>
<a-space class="editor-toolbar-ext mt-2" wrap>
<a-button size="small" @click="openImageSelector">📷 从图片库选择</a-button>
<a-button size="small" @click="openVideoSelector">🎬 从视频库选择</a-button>
<a-button size="small" @click="handleAutoFormat">🎨 一键排版</a-button>
<a-button size="small" @click="toggleParagraphIndent">📐 首行缩进</a-button>
</a-space>
<a-space
class="py-2 flex items-center text-gray-400"
v-if="lang == 'zh_CN'"
>
<a-switch
checked-children="AI翻译"
v-model:checked="form.translation"
/>
<div v-if="form.translation">启用后将自动翻译其他语言版本</div>
</a-space>
</a-form-item>
<a-form-item
label="状态"
name="status"
v-if="setting.setting?.articleReview"
>
<a-radio-group v-model:value="form.status">
<a-radio :value="1">待审核</a-radio>
<a-radio :value="0">已发布</a-radio>
</a-radio-group>
</a-form-item>
<a-divider class="py-4 mb-3" style="height: 20px" />
<a-form-item label="上传视频" name="video">
<div class="video-upload-container">
<!-- 视频预览 -->
<div v-if="form.video" class="video-preview">
<video
:src="form.video"
controls
style="max-width: 100%; max-height: 300px; border-radius: 8px"
></video>
<a-button
type="link"
danger
@click="removeVideo"
style="margin-top: 8px"
>
删除视频
</a-button>
</div>
<!-- 上传按钮 -->
<div v-else>
<a-upload
:before-upload="beforeVideoUpload"
:custom-request="handleVideoUpload"
:show-upload-list="false"
accept="video/*"
>
<a-button type="primary">
<upload-outlined /> 选择视频文件
</a-button>
</a-upload>
<div class="upload-tip"> 支持上传视频文件大小不超过200MB </div>
</div>
<!-- 上传进度 -->
<div v-if="videoUploading" class="upload-progress">
<a-progress
:percent="uploadProgress"
:status="uploadProgress === 100 ? 'success' : 'active'"
/>
<div class="progress-text">
{{ uploadProgressText }}
</div>
</div>
</div>
</a-form-item>
<a-form-item label="PDF链接" name="pdfUrl" extra="用于PDF文件预览">
<a-input
allow-clear
placeholder="https://oss.xxx.cn/xxx.pdf"
v-model:value="form.pdfUrl"
/>
</a-form-item>
<a-form-item label="文章编号" name="code" extra="用于getByCode查询">
<a-input allow-clear placeholder="code" v-model:value="form.code" />
</a-form-item>
<a-form-item label="文章来源" name="source">
<source-select
v-model:value="form.source"
style="width: 206px"
:placeholder="`文章来源`"
/>
</a-form-item>
<a-form-item label="产品概述" name="overview">
<a-textarea
:rows="3"
show-count
placeholder="请输入描述"
v-model:value="form.overview"
/>
</a-form-item>
<a-divider class="py-4 mb-3" style="height: 20px" />
<a-form-item
label="虚拟阅读量"
name="virtualViews"
:extra="`用户看到的阅读量(${
Number(form?.actualViews) + Number(form?.virtualViews)
}) = 实际阅读量(${form.actualViews}) + 虚拟阅读量(${
form.virtualViews
})`"
>
<a-input-number
:min="0"
:max="999999999"
style="width: 206px"
placeholder="请输入虚拟阅读量"
v-model:value="form.virtualViews"
/>
</a-form-item>
<a-form-item label="访问权限" name="permission">
<a-radio-group v-model:value="form.permission">
<a-radio :value="0">所有人可见</a-radio>
<a-radio :value="1">登录可见</a-radio>
<a-radio :value="2">密码可见</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item
label="访问密码"
name="password"
v-if="form.permission == 2"
>
<a-input-password
allow-clear
placeholder="请输入查看密码"
v-model:value="password"
/>
</a-form-item>
<a-form-item label="排序" name="sortNumber">
<a-input-number
:min="0"
:max="9999"
style="width: 206px"
placeholder="请输入排序号"
v-model:value="form.sortNumber"
/>
</a-form-item>
<a-form-item label="发布时间" name="createTime" v-if="isUpdate">
<a-date-picker
v-model:value="form.createTime"
show-time
placeholder="Select Time"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</a-form-item>
<!-- </a-tab-pane>-->
<!-- <a-tab-pane tab="其他选项" key="other">-->
<!-- </a-tabs>-->
</a-spin>
</a-form>
</a-drawer>
<!-- 文件库选择弹窗 -->
<SelectData
v-model:visible="showFileSelector"
title="选择图片"
type="image"
class="file-selector-modal"
@done="onFileSelected"
/>
<!-- 视频库选择弹窗 -->
<SelectData
v-model:visible="showVideoSelector"
title="选择视频"
type="video"
class="file-selector-modal"
@done="onVideoSelected"
/>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue';
import { Form, message, Modal } from 'ant-design-vue';
import { UploadOutlined } from '@ant-design/icons-vue';
import { assignObject, htmlToText, uuid } from 'ele-admin-pro';
import { addCmsArticle, updateCmsArticle } from '@/api/cms/cmsArticle';
import { CmsArticle } from '@/api/cms/cmsArticle/model';
import { useThemeStore } from '@/store/modules/theme';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { ItemType } from 'ele-admin-pro/es/ele-image-upload/types';
import { FormInstance, RuleObject } from 'ant-design-vue/es/form';
import { FileRecord } from '@/api/system/file/model';
import RichTextEditor from '@/components/RichTextEditor.vue';
import markdownit from 'markdown-it';
import { CmsArticleCategory } from '@/api/cms/cmsArticleCategory/model';
import { CmsNavigation } from '@/api/cms/cmsNavigation/model';
import SourceSelect from '@/views/cms/cmsArticle/dictionary/source-select.vue';
import { useWebsiteSettingStore } from '@/store/modules/setting';
import SelectData from '@/components/SelectFile/components/select-data.vue';
import request from '@/utils/request';
import { SERVER_API_URL } from '@/config/setting';
// 是否是修改
const isUpdate = ref(false);
const useForm = Form.useForm;
const setting = useWebsiteSettingStore();
const { locale } = useI18n();
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: CmsArticle | null;
// 商户ID
merchantId?: number;
categoryId?: number;
// 栏目数据
navigationList?: CmsNavigation[];
// 栏目数据
categoryList?: CmsArticleCategory[];
}>();
const emit = defineEmits<{
(e: 'done'): void;
(e: 'update:visible', visible: boolean): void;
}>();
// 提交状态
const loading = ref(false);
// 是否显示最大化切换按钮
const maxable = ref(true);
// 表格选中数据
const formRef = ref<FormInstance | null>(null);
const images = ref<ItemType[]>([]);
const content = ref('');
const disabled = ref(false);
// 当前选项卡
// const active = ref('base');
const files = ref<ItemType[]>([]);
const category = ref<string[]>([]);
const password = ref();
const lang = localStorage.getItem('i18n-lang');
// 视频上传相关状态
const videoUploading = ref(false);
const uploadProgress = ref(0);
const uploadProgressText = ref('');
// 用户信息
const form = reactive<CmsArticle>({
articleId: undefined,
// 文章模型
model: 'detail',
// 文章标识
code: undefined,
// 封面图
image: '',
// 文章标题
title: '',
type: 0,
// 展现方式
showType: 10,
// 文章来源
source: undefined,
// 产品概述
overview: undefined,
// 标签集
tags: undefined,
// 父级栏目ID
parentId: undefined,
// 栏目ID
categoryId: undefined,
// 栏目名称
categoryName: undefined,
// 文章内容
content: '',
// 编辑器类型 1富文本 2Markdown
editor: 1,
// 虚拟阅读量
virtualViews: 0,
// 实际阅读量
actualViews: 0,
recommend: undefined,
translation: true,
permission: 0,
password: undefined,
password2: undefined,
// 用户ID
userId: undefined,
files: '',
// 视频URL
video: undefined,
lang: locale.value || undefined,
// 排序
sortNumber: 100,
// 备注
comments: undefined,
// 状态
status: 1,
// 创建时间
createTime: '',
// 更新时间
updateTime: ''
});
/* 更新visible */
const updateVisible = (value: boolean) => {
emit('update:visible', value);
};
// 表单验证规则
const rules = reactive({
title: [
{
required: true,
message: '请选择文章标题',
type: 'string',
trigger: 'blur'
}
],
categoryId: [
{
required: true,
message: '请选择栏目',
type: 'number',
trigger: 'blur'
}
],
content: [
{
required: true,
type: 'string',
message: '请输入文章内容',
trigger: 'blur',
validator: async (_rule: RuleObject, _: string) => {
if (content.value == '') {
return Promise.reject('请输入文字内容');
}
return Promise.resolve();
}
}
]
});
// 选择栏目
const onCategoryId = (id: number) => {
form.categoryId = id;
// 💾 在新增模式下,用户手动选择栏目时也保存到本地存储
if (!isUpdate.value && id) {
saveLastCategory(id);
}
};
const onComments = () => {
if (form.comments == undefined) {
form.comments = htmlToText(content.value);
form.comments = form.comments.slice(0, 120);
}
};
const chooseFile = (data: FileRecord) => {
files.value.push({
uid: data.id,
url: data.url,
status: 'done'
});
form.files = JSON.stringify(files.value.map((d) => d.url));
};
const onDeleteFile = (index: number) => {
files.value.splice(index, 1);
};
// 视频上传前的验证
const beforeVideoUpload = (file: File) => {
// 检查文件类型
const isVideo = file.type.startsWith('video/');
if (!isVideo) {
message.error('只能上传视频文件!');
return false;
}
// 检查文件大小(200MB = 200 * 1024 * 1024 bytes
const maxSize = 200 * 1024 * 1024;
if (file.size > maxSize) {
message.error('视频文件大小不能超过 200MB');
return false;
}
return true;
};
// 处理视频上传
const handleVideoUpload = async (options: any) => {
const { file } = options;
videoUploading.value = true;
uploadProgress.value = 0;
uploadProgressText.value = '准备上传...';
try {
const formData = new FormData();
formData.append('file', file);
// 使用 axios 上传以支持进度跟踪
const res = await request.post<any>(
SERVER_API_URL + '/oss/upload',
formData,
{
onUploadProgress: (progressEvent: any) => {
if (progressEvent.total) {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total
);
uploadProgress.value = percentCompleted;
// 计算已上传和总大小
const loadedMB = (progressEvent.loaded / (1024 * 1024)).toFixed(
2
);
const totalMB = (progressEvent.total / (1024 * 1024)).toFixed(2);
uploadProgressText.value = `正在上传: ${loadedMB}MB / ${totalMB}MB`;
}
}
}
);
if (res.data.code === 0 && res.data.data) {
// 获取上传后的URL,视频文件不添加图片处理参数
let videoUrl =
res.data.data.url || res.data.data.path || res.data.data.downloadUrl;
// 如果URL中包含图片处理参数,移除它(因为这是视频文件)
if (videoUrl && videoUrl.includes('?x-oss-process=')) {
videoUrl = videoUrl.split('?x-oss-process=')[0];
}
form.video = videoUrl;
message.success('视频上传成功!');
uploadProgressText.value = '上传完成';
} else {
throw new Error(res.data.message || '上传失败');
}
} catch (error: any) {
console.error('视频上传失败:', error);
message.error('视频上传失败:' + (error.message || '未知错误'));
uploadProgress.value = 0;
} finally {
// 延迟隐藏进度条,让用户看到完成状态
setTimeout(() => {
videoUploading.value = false;
uploadProgress.value = 0;
uploadProgressText.value = '';
}, 1500);
}
};
// 删除视频
const removeVideo = () => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个视频吗?',
okText: '确定',
cancelText: '取消',
onOk: () => {
form.video = undefined;
message.success('视频已删除');
}
});
};
const editorRef = ref<any>(null);
// markdown-it 实例:编辑回显时把老 Markdown 内容转成 HTML 喂给富文本编辑器
const md = markdownit();
const toEditorContent = (val: string): string => {
if (!val) return '';
// 已是 HTML(含标签)则原样返回,否则用 markdown-it 转 HTML
if (/<[a-z][\s\S]*>/i.test(val)) return val;
return md.render(val);
};
// 文件库选择弹窗状态
const showFileSelector = ref(false);
const fileSelectCallback = ref<((url: string) => void) | null>(null);
// 视频库选择弹窗状态
const showVideoSelector = ref(false);
const videoSelectCallback = ref<((url: string) => void) | null>(null);
// 从文件库选择图片的回调
const onFileSelected = (data: FileRecord) => {
if (fileSelectCallback.value) {
// 使用文件的完整URL,确保有值
const imageUrl = data.url || data.path || '';
if (imageUrl) {
fileSelectCallback.value(imageUrl);
message.success('图片插入成功');
}
fileSelectCallback.value = null;
}
showFileSelector.value = false;
};
// 从视频库选择视频的回调
const onVideoSelected = (data: FileRecord) => {
if (videoSelectCallback.value) {
// 使用文件的完整URL,确保有值
const videoUrl = data.path || data.downloadUrl || '';
if (videoUrl) {
videoSelectCallback.value(videoUrl);
message.success('视频插入成功');
}
videoSelectCallback.value = null;
}
showVideoSelector.value = false;
};
// 从图片库选择图片:插入到 WangEditor 当前光标处
const openImageSelector = () => {
fileSelectCallback.value = (url: string) => {
editorRef.value?.insertHtml(
`<img src="${url}" alt="图片" style="max-width:100%;"/>`
);
};
showFileSelector.value = true;
};
// 从视频库选择视频:插入到 WangEditor 当前光标处
const openVideoSelector = () => {
videoSelectCallback.value = (url: string) => {
editorRef.value?.insertHtml(
`<video controls style="max-width:100%;height:auto;"><source src="${url}" type="video/mp4"></video>`
);
};
showVideoSelector.value = true;
};
// 🎨 智能一键排版 - 人性化设计
const handleAutoFormat = () => {
try {
// 1. 检查内容
const content = content.value;
if (
!content ||
content.trim() === '' ||
content === '<p><br></p>' ||
content === '<p></p>'
) {
message.warning({
content: '📝 请先输入一些内容,然后再使用一键排版功能',
duration: 3
});
return;
}
// 2. 显示友好的加载提示
const loadingMsg = message.loading({
content: '✨ 正在为您的文章进行智能排版优化...',
duration: 0
});
// 3. 延迟执行,让用户看到加载效果
setTimeout(() => {
try {
const optimizedContent = smartFormatContent(content);
content.value = optimizedContent;
loadingMsg();
// 4. 显示成功提示
message.success({
content: '🎉 排版优化完成!您的文章现在看起来更专业了',
duration: 4
});
// 5. 可选:显示优化统计
showOptimizationStats(content, optimizedContent);
} catch (error) {
loadingMsg();
console.error('排版优化失败:', error);
message.error({
content: '😅 排版优化遇到了问题,请检查文章内容后重试',
duration: 4
});
}
}, 800); // 给用户一个良好的反馈体验
} catch (error) {
console.error('一键排版功能错误:', error);
message.error({
content: '🔧 功能暂时不可用,请刷新页面后重试',
duration: 4
});
}
};
// 📊 显示优化统计信息
const showOptimizationStats = (
originalContent: string,
optimizedContent: string
) => {
const stats = analyzeOptimization(originalContent, optimizedContent);
if (stats.optimizations.length > 0) {
message.info({
content: `📈 本次优化: ${stats.optimizations.join('、')}`,
duration: 6
});
}
};
// 🔍 分析优化效果
const analyzeOptimization = (original: string, optimized: string) => {
const optimizations: string[] = [];
// 检查各种优化项目
if (
(optimized.match(/<h[1-6][^>]*style/g) || []).length >
(original.match(/<h[1-6][^>]*style/g) || []).length
) {
optimizations.push('标题样式');
}
if (
(optimized.match(/<p[^>]*style/g) || []).length >
(original.match(/<p[^>]*style/g) || []).length
) {
optimizations.push('段落格式');
}
if (
(optimized.match(/<img[^>]*style/g) || []).length >
(original.match(/<img[^>]*style/g) || []).length
) {
optimizations.push('图片布局');
}
if (
(optimized.match(/<ul[^>]*style|<ol[^>]*style/g) || []).length >
(original.match(/<ul[^>]*style|<ol[^>]*style/g) || []).length
) {
optimizations.push('列表格式');
}
return { optimizations };
};
// 🎨 智能排版核心函数 - 简单而强大
const smartFormatContent = (content: string): string => {
let optimized = content;
// 1. 🏷️ 标题优化 - 让标题更有层次感
optimized = optimized.replace(
/<h1([^>]*)>/g,
'<h1$1 style="font-size: 28px; font-weight: 700; margin: 24px 0 16px 0; line-height: 1.3; color: #1a1a1a; border-bottom: 2px solid #e8e8e8; padding-bottom: 10px;">'
);
optimized = optimized.replace(
/<h2([^>]*)>/g,
'<h2$1 style="font-size: 24px; font-weight: 600; margin: 20px 0 14px 0; line-height: 1.4; color: #2c2c2c;">'
);
optimized = optimized.replace(
/<h3([^>]*)>/g,
'<h3$1 style="font-size: 20px; font-weight: 600; margin: 18px 0 12px 0; line-height: 1.4; color: #3c3c3c;">'
);
optimized = optimized.replace(
/<h4([^>]*)>/g,
'<h4$1 style="font-size: 16px; font-weight: 600; margin: 14px 0 8px 0; line-height: 1.4; color: #4c4c4c;">'
);
optimized = optimized.replace(
/<h5([^>]*)>/g,
'<h5$1 style="font-size: 14px; font-weight: 600; margin: 12px 0 6px 0; line-height: 1.4; color: #5c5c5c;">'
);
optimized = optimized.replace(
/<h6([^>]*)>/g,
'<h6$1 style="font-size: 13px; font-weight: 600; margin: 10px 0 5px 0; line-height: 1.4; color: #6c6c6c;">'
);
// 2. 📝 段落优化 - 让阅读更舒适
optimized = optimized.replace(/<p([^>]*)>/g, (match, attrs) => {
if (!attrs.includes('style=')) {
return `<p${attrs} style="line-height: 1.8; margin: 16px 0; text-indent: 2em; color: #333;">`;
}
return match;
});
// 3. 🖼️ 图片优化 - 让图片更美观
optimized = optimized.replace(/<img([^>]*?)>/g, (match, attrs) => {
if (!attrs.includes('style=')) {
const hasAlt = attrs.includes('alt=');
return `<img${attrs} style="max-width: 100%; height: auto; margin: 20px auto; display: block; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.1);"${
!hasAlt ? ' alt="图片"' : ''
}>`;
}
return match;
});
// 4. 📋 列表优化 - 让列表更清晰
optimized = optimized.replace(
/<ul([^>]*)>/g,
'<ul$1 style="margin: 16px 0; padding-left: 24px; line-height: 1.6;">'
);
optimized = optimized.replace(
/<ol([^>]*)>/g,
'<ol$1 style="margin: 16px 0; padding-left: 24px; line-height: 1.6;">'
);
optimized = optimized.replace(
/<li([^>]*)>/g,
'<li$1 style="margin: 8px 0; color: #333;">'
);
// 5. 💬 引用优化 - 让引用更突出
optimized = optimized.replace(
/<blockquote([^>]*)>/g,
'<blockquote$1 style="margin: 20px 0; padding: 16px 20px; border-left: 4px solid #1890ff; background: linear-gradient(90deg, #f6f8fa 0%, #ffffff 100%); font-style: italic; color: #555;">'
);
// 6. 💻 代码优化 - 让代码更专业
optimized = optimized.replace(
/<code([^>]*)>/g,
'<code$1 style="background-color: #f1f3f4; padding: 2px 6px; border-radius: 4px; font-family: \'Fira Code\', Consolas, Monaco, monospace; font-size: 0.9em; color: #d73a49;">'
);
optimized = optimized.replace(
/<pre([^>]*)>/g,
'<pre$1 style="margin: 20px 0; padding: 20px; background-color: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; overflow-x: auto; font-family: \'Fira Code\', Consolas, Monaco, monospace; font-size: 14px; line-height: 1.5;">'
);
// 7. 📊 表格优化 - 让表格更美观
optimized = optimized.replace(
/<table([^>]*)>/g,
'<table$1 style="width: 100%; border-collapse: collapse; margin: 20px 0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden;">'
);
optimized = optimized.replace(
/<th([^>]*)>/g,
'<th$1 style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px; text-align: left; font-weight: 600;">'
);
optimized = optimized.replace(
/<td([^>]*)>/g,
'<td$1 style="padding: 12px; border-bottom: 1px solid #eee; color: #333;">'
);
// 8. 🔗 链接优化 - 让链接更友好
optimized = optimized.replace(
/<a([^>]*)>/g,
'<a$1 style="color: #1890ff; text-decoration: none; border-bottom: 1px solid transparent; transition: border-bottom 0.2s ease;" onmouseover="this.style.borderBottom=\'1px solid #1890ff\'" onmouseout="this.style.borderBottom=\'1px solid transparent\'">'
);
// 9. ➖ 分隔线优化 - 让分隔线更优雅
optimized = optimized.replace(
/<hr([^>]*)>/g,
'<hr$1 style="border: none; height: 2px; background: linear-gradient(90deg, transparent, #e8e8e8, transparent); margin: 30px 0;">'
);
// 10. 🧹 清理多余空白
optimized = optimized.replace(/\s+/g, ' '); // 清理多余空格
optimized = optimized.replace(/<p[^>]*>\s*<\/p>/g, ''); // 清理空段落
optimized = optimized.replace(/(<\/[^>]+>)\s+(<[^>]+>)/g, '$1$2'); // 清理标签间空白
return optimized;
};
// 🔄 段落首行缩进切换功能
const toggleParagraphIndent = () => {
try {
const content = content.value;
if (
!content ||
content.trim() === '' ||
content === '<p><br></p>' ||
content === '<p></p>'
) {
message.warning({
content: '📝 请先输入一些段落内容,然后再切换首行缩进',
duration: 3
});
return;
}
// 检查当前是否有首行缩进
const hasIndent =
content.includes('text-indent: 2em') ||
content.includes('text-indent:2em');
let newContent: string;
let actionText: string;
if (hasIndent) {
// 移除首行缩进
newContent = removeIndentFromParagraphs(content);
actionText = '已移除段落首行缩进';
} else {
// 添加首行缩进
newContent = addIndentToParagraphs(content);
actionText = '已添加段落首行缩进';
}
content.value = newContent;
message.success({
content: `📐 ${actionText}`,
duration: 3
});
} catch (error) {
console.error('首行缩进切换失败:', error);
message.error({
content: '🔧 首行缩进切换失败,请重试',
duration: 3
});
}
};
// 为段落添加首行缩进
const addIndentToParagraphs = (content: string): string => {
return content.replace(/<p([^>]*)>/g, (match, attrs) => {
// 如果已经有 style 属性
if (attrs.includes('style=')) {
// 检查是否已经有 text-indent
if (attrs.includes('text-indent')) {
// 更新现有的 text-indent
return match.replace(/text-indent:\s*[^;]+;?/g, 'text-indent: 2em;');
} else {
// 在现有 style 中添加 text-indent
return match.replace(
/style="([^"]*)"/,
'style="$1 text-indent: 2em;"'
);
}
} else {
// 添加新的 style 属性
return `<p${attrs} style="text-indent: 2em;">`;
}
});
};
// 从段落移除首行缩进
const removeIndentFromParagraphs = (content: string): string => {
return content.replace(/<p([^>]*)>/g, (match, attrs) => {
if (attrs.includes('text-indent')) {
// 移除 text-indent 属性
let newAttrs = attrs.replace(/text-indent:\s*[^;]+;?\s*/g, '');
// 如果 style 属性变空了,移除整个 style 属性
newAttrs = newAttrs.replace(/style="\s*"/g, '');
newAttrs = newAttrs.replace(/style=''\s*/g, '');
return `<p${newAttrs}>`;
}
return match;
});
};
const { resetFields } = useForm(form, rules);
// 💾 保存和恢复栏目选择的功能
const LAST_CATEGORY_KEY = 'cms_article_last_category';
// 保存最后选择的栏目到本地存储
const saveLastCategory = (categoryId: number | undefined) => {
if (categoryId) {
localStorage.setItem(LAST_CATEGORY_KEY, categoryId.toString());
}
};
// 从本地存储获取最后选择的栏目
const getLastCategory = (): number | undefined => {
const saved = localStorage.getItem(LAST_CATEGORY_KEY);
return saved ? parseInt(saved) : undefined;
};
/* 保存编辑 */
const save = () => {
if (!formRef.value) {
return;
}
formRef.value
.validate()
.then(() => {
loading.value = true;
if (password.value) {
form.password = password.value;
}
if (form.tags) {
form.tags = JSON.stringify(form.tags);
}
// 取第一张图片作为封面图
if (files.value.length > 0) {
form.image = files.value[0].url;
form.files = JSON.stringify(files.value.map((d) => d.url));
} else {
form.image = '';
form.files = '';
}
const formData = {
...form,
editor: 1,
status: setting.setting?.articleReview ? 1 : 0,
content: content.value
};
const saveOrUpdate = isUpdate.value ? updateCmsArticle : addCmsArticle;
saveOrUpdate(formData)
.then((msg) => {
loading.value = false;
message.success(msg);
// 💾 保存成功后,记住当前选择的栏目(仅在新增时)
if (!isUpdate.value && form.categoryId) {
saveLastCategory(form.categoryId);
}
updateVisible(false);
emit('done');
})
.catch((e) => {
message.error(e.message);
})
.finally(() => {
loading.value = false;
});
})
.catch(() => {});
};
watch(
() => props.visible,
(visible) => {
if (visible) {
images.value = [];
category.value = [];
files.value = [];
content.value = '';
if (props.data) {
// 编辑模式:加载现有文章数据
loading.value = true;
const data = props.data;
// 文章详情
assignObject(form, data);
if (data.content) {
content.value = toEditorContent(data.content);
}
if (!data.source) {
form.source = undefined;
}
if (data.tags) {
form.tags = JSON.parse(form.tags);
} else {
form.tags = undefined;
}
if (data.files) {
const arr = JSON.parse(data.files);
arr.map((url: string) => {
files.value.push({
uid: uuid(),
url: url,
status: 'done'
});
});
}
if (data.image && !data.files) {
files.value.push({
uid: uuid(),
url: data.image,
status: 'done'
});
}
loading.value = false;
isUpdate.value = true;
} else {
// 新增模式:恢复上次选择的栏目
isUpdate.value = false;
// 🎯 优先级设置栏目:
// 1. 如果传入了 categoryId(从栏目页面点击添加),使用传入的
// 2. 否则使用上次保存的栏目
if (props.categoryId) {
form.categoryId = props.categoryId;
} else {
const lastCategory = getLastCategory();
if (lastCategory) {
form.categoryId = lastCategory;
}
}
}
} else {
resetFields();
}
},
{ immediate: true }
);
</script>
<style lang="less" scoped>
.editor-content {
:deep(.tox-tinymce) {
border-radius: 6px;
}
}
// 文件选择提示
.file-selector-tip {
color: #666;
font-size: 12px;
margin-top: 4px;
}
// 文件选择弹窗样式
:deep(.file-selector-modal) {
.ant-modal {
z-index: 10000 !important;
}
.ant-modal-mask {
z-index: 9999 !important;
}
}
// 排版选项弹窗样式
:deep(.format-options-modal) {
.ant-modal {
z-index: 10000 !important;
}
.ant-modal-mask {
z-index: 9999 !important;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.format-presets {
.format-preset-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.format-preset-card {
border: 2px solid #e8e8e8;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
background: #ffffff;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
transform: translateY(-2px);
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.preset-icon {
font-size: 24px;
}
}
.preset-description {
color: #666;
font-size: 14px;
margin-bottom: 12px;
line-height: 1.5;
}
.preset-preview {
background: #f8f9fa;
border-radius: 4px;
padding: 12px;
.preview-title {
font-weight: 600;
font-size: 14px;
color: #1a1a1a;
margin-bottom: 6px;
}
.preview-text {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
}
.format-tips {
background: #f6f8fa;
border-radius: 8px;
padding: 16px;
border-left: 4px solid #1890ff;
h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
}
ul {
margin: 0;
padding-left: 20px;
li {
color: #666;
font-size: 13px;
line-height: 1.6;
margin-bottom: 4px;
}
}
}
}
// 📝 编辑器选择器样式
.editor-selector-container {
margin-bottom: 16px;
.editor-selector {
padding: 16px;
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border: 1px solid #e8e8e8;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
.selector-label {
font-size: 14px;
font-weight: 600;
color: #333;
margin-right: 16px;
}
.editor-radio-group {
.editor-radio {
margin-right: 24px;
.radio-content {
display: flex;
flex-direction: column;
align-items: flex-start;
.radio-icon {
font-size: 18px;
margin-bottom: 4px;
}
.radio-text {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.radio-desc {
font-size: 12px;
color: #666;
line-height: 1.4;
}
}
&:hover {
.radio-content {
.radio-text {
color: #1890ff;
}
}
}
}
}
}
}
// 📝 编辑器包装器样式
.editor-wrapper {
margin-top: 16px;
}
// 📝 Markdown编辑器工具栏扩展样式
.markdown-toolbar-extension {
margin-bottom: 12px;
padding: 12px;
background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
border: 1px solid #e8e8e8;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
.ant-btn {
border-radius: 6px;
font-size: 13px;
height: 32px;
display: inline-flex;
align-items: center;
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
}
}
// 视频上传容器样式
.video-upload-container {
.video-preview {
display: flex;
flex-direction: column;
align-items: flex-start;
video {
border: 1px solid #e8e8e8;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
.upload-tip {
margin-top: 8px;
color: #999;
font-size: 12px;
}
.upload-progress {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e8e8e8;
.progress-text {
margin-top: 8px;
text-align: center;
color: #666;
font-size: 13px;
}
}
}
</style>