685 lines
18 KiB
Vue
685 lines
18 KiB
Vue
<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>
|