first commit
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
<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">
|
||||
<WangEditor 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 WangEditor from "@/views/components/WangEditor.vue";
|
||||
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(.w-e-text),
|
||||
:deep(.w-e-text-container) {
|
||||
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>
|
||||
Reference in New Issue
Block a user