Files
yunzerwebsiteallinone/backend/src/views/apps/cms/articles/components/edit.vue
T
2026-07-14 15:49:00 +08:00

685 lines
18 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-drawer
v-model="visible"
:title="isEdit ? '编辑文章' : '新增文章'"
size="60%"
:before-close="handleBeforeClose"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="分类" prop="cate">
<el-cascader
v-model="form.cate"
:options="cateOptions"
placeholder="选择分类"
clearable
:props="{ expandTrigger: 'hover', emitPath: false }"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="作者" prop="author">
<el-input v-model="form.author" placeholder="请输入作者姓名" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="简介" prop="desc">
<el-input
v-model="form.desc"
:rows="4"
type="textarea"
placeholder="请输入简介"
/>
</el-form-item>
<el-form-item label="封面图片" prop="image">
<div class="uploads">
<!-- 已有图片显示 -->
<div
v-if="form.image && fileList.length === 0"
class="existing-image"
>
<img
:src="API_BASE_URL + form.image.replace(/\\\//g, '/')"
alt="已有图片"
/>
<div class="image-actions">
<el-button
type="primary"
size="small"
@click="previewExistingImage"
>预览</el-button
>
<el-button type="danger" size="small" @click="removeExistingImage"
>删除</el-button
>
</div>
</div>
<!-- 上传组件 -->
<el-upload
v-model:file-list="fileList"
:auto-upload="false"
:before-upload="beforeImgUpload"
list-type="picture-card"
:limit="1"
:show-file-list="fileList.length > 0"
>
<el-icon>
<Plus />
</el-icon>
<template #file="{ file }">
<div>
<img
class="el-upload-list__item-thumbnail"
:src="file.url"
alt=""
/>
<span class="el-upload-list__item-actions">
<span
class="el-upload-list__item-preview"
@click="handlePictureCardPreview(file)"
>
<el-icon>
<ZoomIn />
</el-icon>
</span>
<span
class="el-upload-list__item-delete"
@click="handleRemove(file)"
>
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-drawer v-model="drawerVisible" width="60%" center>
<div
style="
display: flex;
justify-content: center;
align-items: center;
"
>
<img
:src="drawerImageUrl"
alt="Preview Image"
style="max-width: 100%; max-height: 70vh; object-fit: contain"
/>
</div>
</el-drawer>
<div class="upload-tip">
<span>建议尺寸:250px × 140px</span>
</div>
</div>
</el-form-item>
<el-divider></el-divider>
<el-form-item label="是否转载" prop="is_trans">
<el-radio-group v-model="form.is_trans">
<el-radio-button :value="0">否</el-radio-button>
<el-radio-button :value="1">是</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="发布地址" prop="transurl" v-if="form.is_trans === 1">
<el-input v-model="form.transurl" placeholder="请输入转载文章地址" />
</el-form-item>
<el-form-item label="内容" prop="content">
<div class="editor-container">
<TiptapEditor v-model="form.content" />
</div>
</el-form-item>
</el-form>
<template #footer>
<el-divider></el-divider>
<span class="drawer-footer">
<el-button @click="handleCancel">取消</el-button>
<el-button
v-if="!isEdit"
type="warning"
@click="handleConfirm(0)"
:loading="submitLoading"
>草稿</el-button
>
<el-button
type="primary"
@click="handleConfirm(1)"
:loading="submitLoading"
>提交</el-button
>
</span>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, nextTick, onMounted, computed } from "vue";
import { ElMessage, ElUpload } from "element-plus";
import { createArticle, editArticle, listCategories } from "@/api/article.js";
import { uploadFile } from "@/api/file.js";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
isEdit: {
type: Boolean,
default: false,
},
model: {
type: Object,
default: () => null,
},
});
const emit = defineEmits(["update:modelValue", "saved"]);
const visible = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const handleBeforeClose = (done: () => void) => {
handleCancel();
done();
};
const cateOptions = ref([]);
const form = reactive({
title: "",
author: "",
cate: "",
content: "",
image: "",
desc: "",
is_trans: 0,
transurl: null,
});
const rules = {
title: [
{ required: true, message: "请输入文章标题", trigger: "blur" },
{
min: 2,
max: 200,
message: "标题长度在 2 200 个字符",
trigger: "blur",
},
],
author: [
{ required: true, message: "请输入作者", trigger: "blur" },
{ max: 50, message: "作者姓名不能超过50个字符", trigger: "blur" },
],
content: [{ required: true, message: "请输入文章内容", trigger: "blur" }],
};
// 获取分类列表
async function fetchCategories() {
try {
const res = await listCategories();
if (res.code === 200) {
const categories = Array.isArray(res.data) ? res.data : [];
// 将分类转换为树形结构
cateOptions.value = buildCategoryTree(categories);
}
} catch (error) {
console.error("获取分类失败:", error);
}
}
// 构建分类树形结构
function buildCategoryTree(categories, parentCid = 0) {
return categories
.filter((cate) => Number(cate.cid) === Number(parentCid))
.map((cate) => {
const children = buildCategoryTree(categories, cate.id);
return {
...cate,
label: cate.name,
value: cate.id,
children: children.length > 0 ? children : undefined,
};
});
}
// 上传相关
const fileList = ref<any[]>([]);
const drawerVisible = ref(false);
const drawerImageUrl = ref("");
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function beforeImgUpload(file: File) {
const isImage = file.type.startsWith("image/");
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) ElMessage.error("仅支持图片格式");
if (!isLt10M) ElMessage.error("图片大小不能超过10MB");
return isImage && isLt10M;
}
function handlePictureCardPreview(file: any) {
drawerImageUrl.value = file.url;
drawerVisible.value = true;
}
function handleRemove(file: any) {
fileList.value = [];
form.image = "";
}
function removeExistingImage() {
form.image = "";
}
function previewExistingImage() {
const imagePath = form.image.replace(/\\\//g, "/");
drawerImageUrl.value = API_BASE_URL + imagePath;
drawerVisible.value = true;
}
// 监听对话框显示状态
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal) {
if (props.isEdit && props.model) {
// 编辑模式,填充表单数据
const modelData = props.model._raw || props.model;
nextTick(() => {
Object.assign(form, {
title: modelData.title || "",
author: modelData.author || "",
cate: modelData.cate || "",
content: modelData.content || "",
desc: modelData.desc || "",
is_trans: modelData.is_trans || 0,
transurl: modelData.transurl || null,
image: modelData.image || "",
});
fileList.value = []; // 重置文件列表
});
} else {
// 新增模式,重置表单
resetForm();
}
}
},
);
function handleCancel() {
emit("update:modelValue", false);
resetForm();
}
async function handleConfirm(status = 1) {
formRef.value?.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
let imageUrl = form.image;
// 如果有新选择的文件,先上传图片
if (fileList.value.length > 0 && fileList.value[0].raw) {
const uploadFormData = new FormData();
uploadFormData.append("file", fileList.value[0].raw);
uploadFormData.append("cate", "article");
const uploadRes = await uploadFile(uploadFormData);
// 200=新文件上传成功,201=文件已存在(使用已有文件的链接)
if (
(uploadRes.code === 200 || uploadRes.code === 201) &&
uploadRes.data &&
uploadRes.data.url
) {
imageUrl = uploadRes.data.url;
} else {
ElMessage.error("图片上传失败" + (uploadRes.msg || "未知错误"));
return;
}
}
// 添加状态参数,0为草稿,1为提交
const submitData = {
title: form.title,
author: form.author,
cate: form.cate,
content: form.content,
desc: form.desc,
image: imageUrl,
is_trans: form.is_trans,
transurl: form.is_trans === 1 ? form.transurl : null,
status: status, // 0为草稿,1为提交
};
// 根据新增/编辑模式选择不同的API调用函数
const isCreateMode = !props.isEdit;
let res;
let resp;
if (isCreateMode) {
// 新增模式,使用createArticle接口
const createArticleWithCheck = async (ignoreSimilarity = false) => {
const data = {
...submitData,
...(ignoreSimilarity && { ignore_similarity: 1 }),
};
return await createArticle(data);
};
res = await createArticleWithCheck();
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
// 检测到相似标题,显示确认对话框
if (resp && resp.code === 409) {
const similarArticles = resp.data?.similar_articles || [];
// 构建带样式的确认消息
const similarArticlesList = similarArticles
.map(
(article: any) =>
`\n<div style="margin: 8px 0; padding: 8px; background: #f5f7fa; border-radius: 4px;">\n<div style="font-weight: bold; color: #303133;">${article.title}</div>\n<div style="color: #606266; font-size: 14px; margin-top: 4px;">相似度:${article.similarity}%</div>\n</div>\n`,
)
.join("");
const confirmMessage = `\n<div>\n\n${similarArticlesList} \n<p style="margin-top: 16px; color: #303133;">是否继续创建?</p>\n</div>\n`;
// 使用Element Plus的confirm对话框
const { ElMessageBox } = await import("element-plus");
try {
await ElMessageBox.confirm(confirmMessage, "检测到相似标题", {
confirmButtonText: "继续创建",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: true,
});
// 用户确认继续,再次调用接口并忽略相似度检测
res = await createArticleWithCheck(true);
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
} catch (error) {
// 用户取消创建
return;
}
}
} else {
// 编辑模式,使用editArticle接口
// 获取文章ID
const modelData = props.model._raw || props.model;
const articleId = modelData.id;
if (!articleId) {
ElMessage.error("文章ID不存在无法更新");
return;
}
// 构建编辑请求数据,不包含文章ID(ID通过URL参数传递)
const editData = {
...submitData,
};
res = await editArticle(articleId, editData);
resp =
res && typeof res.code !== "undefined"
? res
: res && res.data
? res.data
: res;
}
if (
resp &&
resp.code === 200 &&
(resp.message === "success" || resp.msg === "success")
) {
ElMessage.success(
status === 0
? "保存草稿成功"
: props.isEdit
? "更新成功"
: "创建成功",
);
emit("update:modelValue", false);
emit("saved");
} else {
ElMessage.error(
(resp && resp.message) ||
(resp && resp.msg) ||
(status === 0
? "保存草稿失败"
: props.isEdit
? "更新失败"
: "创建失败"),
);
}
} catch (error) {
ElMessage.error(
status === 0
? "保存草稿失败"
: props.isEdit
? "更新失败"
: "创建失败",
);
} finally {
submitLoading.value = false;
}
}
});
}
function resetForm() {
// 重置表单
if (formRef.value) {
formRef.value.resetFields();
}
// 重置文件列表
fileList.value = [];
// 重置表单数据
Object.assign(form, {
title: "",
author: "",
cate: "",
content: "",
image: "",
desc: "",
is_trans: 0,
transurl: null,
});
}
// 暴露重置方法(如果需要的话)
defineExpose({
resetForm: () => {
formRef.value?.resetFields();
},
});
onMounted(() => {
fetchCategories();
});
</script>
<style lang="less" scoped>
.editor-container {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
&:focus {
outline: none !important;
}
:deep(.tiptap) {
p {
color: #1a1a2e !important;
margin: 0.5em 0 !important;
}
h1, h2, h3, h4, h5, h6 {
color: #1a1a2e !important;
font-weight: 600 !important;
}
a {
color: #3973ff !important;
text-decoration: underline !important;
&:hover {
color: #3973ff !important;
opacity: 0.8 !important;
}
}
code {
background-color: #f5f7fa !important;
color: #1a1a2e !important;
border: 1px solid #e4e7ed !important;
padding: 2px 6px !important;
border-radius: 3px !important;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
pre {
background-color: #f5f7fa !important;
border: 1px solid #e4e7ed !important;
color: #1a1a2e !important;
border-radius: 4px !important;
padding: 12px 16px;
margin: 12px 0;
overflow-x: auto;
code {
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
blockquote {
border-left: 4px solid #3973ff !important;
background-color: #f5f7fa !important;
color: #606266 !important;
padding: 0.6em 1.2em !important;
margin: 1em 0 !important;
}
table {
border-collapse: collapse !important;
border: 1px solid #e4e7ed !important;
th, td {
border: 1px solid #e4e7ed !important;
background-color: #ffffff !important;
color: #1a1a2e !important;
padding: 8px 12px;
}
th {
background-color: #f5f7fa !important;
}
}
ul, ol {
color: #1a1a2e !important;
padding-left: 24px;
margin: 8px 0;
}
li {
color: #1a1a2e !important;
line-height: 1.8;
margin: 4px 0;
}
hr {
border-top: 1px solid #e4e7ed !important;
margin: 16px 0;
}
img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
}
}
.drawer-footer {
text-align: right;
}
.uploads {
display: flex;
gap: 10px;
}
.upload-tip {
font-size: 12px;
color: #999;
}
.existing-image {
position: relative;
width: 148px;
height: 148px;
border: 1px solid #dcdfe6;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.existing-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-actions {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.existing-image:hover .image-actions {
opacity: 1;
}
::deep(.el-message-box) {
width: 800px !important;
}
</style>