07add26fe6
- 删除原有 TinyMCE 富文本和 MdEditor Markdown 双模式切换及相关逻辑 - 替换文章编辑弹窗内容详情为 RichTextEditor 组件 - 新增图片库和视频库选择按钮,支持插入图片和视频 HTML 标签 - 实现一键排版和首行缩进按钮,直接操作内容 HTML 字符串 - 回显时 Markdown 内容转换为 HTML,保存时固定 editor 类型为 1 - 移除无用的配置和样式,简化代码逻辑,提升编辑体验 - 留下文章编辑器类型设置字段待后续清理,展示端保持一致 HTML 渲染方式
1348 lines
39 KiB
Vue
1348 lines
39 KiB
Vue
<!-- 编辑弹窗 -->
|
||
<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>
|