first commit

This commit is contained in:
2026-06-03 10:09:03 +08:00
commit 81f5039458
6266 changed files with 188236 additions and 0 deletions
@@ -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>