563 lines
14 KiB
Vue
563 lines
14 KiB
Vue
<template>
|
||
<div class="knowledge-edit">
|
||
<!-- 顶部标题栏 -->
|
||
<div class="edit-header">
|
||
<el-button type="primary" link @click="goBack">
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
返回
|
||
</el-button>
|
||
<h2 class="edit-title">{{ isEdit ? "编辑知识" : "新建知识" }}</h2>
|
||
</div>
|
||
|
||
<!-- 基本信息 -->
|
||
<div class="edit-meta">
|
||
<div class="meta-card">
|
||
<h3 class="meta-title">基本信息</h3>
|
||
<el-divider />
|
||
<el-form
|
||
:model="formData"
|
||
:rules="rules"
|
||
ref="formRef"
|
||
label-width="80px"
|
||
size="default"
|
||
>
|
||
<el-row :gutter="20">
|
||
<el-col :span="24">
|
||
<el-form-item label="标题" prop="title">
|
||
<el-input v-model="formData.title" placeholder="请输入标题" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="20">
|
||
<el-col :span="6">
|
||
<el-form-item label="分类" prop="category">
|
||
<el-cascader
|
||
v-model="cascaderValue"
|
||
:options="categoryTreeOptions"
|
||
:props="cascaderProps"
|
||
placeholder="请选择分类"
|
||
clearable
|
||
style="width: 100%"
|
||
@change="handleCategoryChange"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="标签" prop="tags">
|
||
<el-select
|
||
v-model="formData.tags"
|
||
multiple
|
||
placeholder="请选择标签"
|
||
style="width: 100%"
|
||
allow-create
|
||
default-first-option
|
||
>
|
||
<el-option
|
||
v-for="tag in tagList"
|
||
:key="tag"
|
||
:label="tag"
|
||
:value="tag"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="作者" prop="author">
|
||
<el-input v-model="formData.author" placeholder="请输入作者" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="权限" prop="share">
|
||
<el-radio-group v-model="formData.share">
|
||
<el-radio-button :value="0">个人</el-radio-button>
|
||
<el-radio-button :value="1">共享</el-radio-button>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</el-form>
|
||
<div class="meta-actions">
|
||
<el-button type="primary" @click="handleSubmit">
|
||
<el-icon><Check /></el-icon>
|
||
保存
|
||
</el-button>
|
||
<el-button @click="goBack">取消</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 正文内容区 - 左右结构 -->
|
||
<div class="edit-body">
|
||
<!-- 左侧编辑器 -->
|
||
<div class="editor-panel">
|
||
<div class="editor-card">
|
||
<h3 class="editor-title">编辑正文</h3>
|
||
<el-divider />
|
||
<div class="editor-content">
|
||
<WangEditor v-model="formData.content" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 右侧预览 -->
|
||
<div class="preview-panel">
|
||
<div class="preview-card">
|
||
<h3 class="preview-title">预览效果</h3>
|
||
<el-divider />
|
||
<div class="markdown-body" v-html="compiledMarkdown"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onMounted, watch, nextTick } from "vue";
|
||
import { useRouter, useRoute } from "vue-router";
|
||
import { marked } from "marked";
|
||
import { ArrowLeft, Check } from '@element-plus/icons-vue'
|
||
// @ts-ignore
|
||
import { getKnowledgeDetail, createKnowledge, updateKnowledge, getCategoryList, getTagList } from "@/api/knowledge";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import type { FormInstance, FormRules } from "element-plus";
|
||
import WangEditor from '@/views/components/WangEditor.vue';
|
||
|
||
const formRef = ref<FormInstance>();
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
const formData = reactive<{
|
||
title: string;
|
||
category: string;
|
||
categoryId: number;
|
||
tags: string[];
|
||
author: string;
|
||
content: string;
|
||
share: number;
|
||
}>({
|
||
title: "",
|
||
category: "",
|
||
categoryId: 0,
|
||
tags: [],
|
||
author: "",
|
||
content: "",
|
||
share: 0,
|
||
});
|
||
|
||
const rules = reactive<FormRules>({
|
||
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
|
||
category: [{ required: true, message: "请选择分类", trigger: "change" }],
|
||
author: [{ required: true, message: "请输入作者", trigger: "blur" }],
|
||
content: [{ required: true, message: "请输入正文", trigger: "blur" }],
|
||
});
|
||
|
||
interface CategoryItem {
|
||
categoryId: number;
|
||
categoryName: string;
|
||
parentId?: number;
|
||
children?: CategoryItem[];
|
||
}
|
||
const categoryList = ref<CategoryItem[]>([]);
|
||
const categoryTreeOptions = computed(() => {
|
||
return buildCategoryTree(categoryList.value);
|
||
});
|
||
const cascaderValue = ref<number | null>(null);
|
||
const cascaderProps = {
|
||
value: 'categoryId',
|
||
label: 'categoryName',
|
||
children: 'children',
|
||
checkStrictly: true,
|
||
emitPath: false,
|
||
};
|
||
const tagList = ref<string[]>([]);
|
||
|
||
const id = computed(() => route.params.id || route.query.id);
|
||
const isEdit = computed(() => {
|
||
const currentId = id.value;
|
||
return !!currentId && currentId !== "new" && currentId !== "";
|
||
});
|
||
|
||
const compiledMarkdown = computed(() => marked(formData.content || ""));
|
||
|
||
watch(
|
||
() => formData.content,
|
||
() => {
|
||
// 内容变化时自动更新预览
|
||
}
|
||
);
|
||
|
||
const getLoginUser = () => {
|
||
const userStr = localStorage.getItem("user");
|
||
if (userStr) {
|
||
try {
|
||
const user = JSON.parse(userStr);
|
||
return user.username || user.name || user.userName || "";
|
||
} catch (e) {
|
||
return "";
|
||
}
|
||
}
|
||
return "";
|
||
};
|
||
|
||
const fetchDetail = async () => {
|
||
try {
|
||
const currentId = id.value as string;
|
||
if (currentId && currentId !== "new") {
|
||
const res = await getKnowledgeDetail(parseInt(currentId as string));
|
||
const data = res.code === 0 && res.data ? res.data : res.data || res;
|
||
|
||
formData.title = data.title || "";
|
||
formData.category = data.categoryName || "";
|
||
formData.categoryId = data.categoryId || 0;
|
||
formData.author = data.author || "";
|
||
formData.content = data.content || "";
|
||
formData.share = data.share || 0;
|
||
|
||
// 设置级联选择器的值
|
||
// 注意:这里需要等待 categoryList 加载完成后再设置
|
||
// 所以使用 nextTick 确保数据已更新
|
||
nextTick(() => {
|
||
if (formData.categoryId) {
|
||
cascaderValue.value = formData.categoryId;
|
||
} else if (formData.category) {
|
||
// 如果没有 categoryId,尝试通过 categoryName 查找
|
||
const foundCategory = categoryList.value.find(
|
||
(item) => item.categoryName === formData.category
|
||
);
|
||
if (foundCategory) {
|
||
formData.categoryId = foundCategory.categoryId;
|
||
cascaderValue.value = foundCategory.categoryId;
|
||
}
|
||
} else {
|
||
cascaderValue.value = null;
|
||
}
|
||
});
|
||
|
||
if (data.tags) {
|
||
try {
|
||
const parsed =
|
||
typeof data.tags === "string" ? JSON.parse(data.tags) : data.tags;
|
||
formData.tags = Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
formData.tags = Array.isArray(data.tags) ? data.tags : [];
|
||
}
|
||
} else {
|
||
formData.tags = [];
|
||
}
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error("获取详情失败");
|
||
}
|
||
};
|
||
|
||
const loadCategoryAndTag = async () => {
|
||
try {
|
||
const [catRes, tagRes] = await Promise.all([
|
||
getCategoryList(),
|
||
getTagList(),
|
||
]);
|
||
|
||
const categories =
|
||
catRes.code === 0 && catRes.data ? catRes.data : catRes.data || [];
|
||
const tags =
|
||
tagRes.code === 0 && tagRes.data ? tagRes.data : tagRes.data || [];
|
||
|
||
categoryList.value = Array.isArray(categories) ? categories : [];
|
||
tagList.value = Array.isArray(tags)
|
||
? tags.map((tag) => tag.tagName || tag)
|
||
: [];
|
||
} catch (e: any) {
|
||
console.error("加载分类和标签失败:", e);
|
||
categoryList.value = [];
|
||
tagList.value = [];
|
||
}
|
||
};
|
||
|
||
// 构建分类树形结构
|
||
const buildCategoryTree = (list: CategoryItem[]): CategoryItem[] => {
|
||
if (!list || list.length === 0) return [];
|
||
|
||
const map = new Map<number, CategoryItem>();
|
||
const roots: CategoryItem[] = [];
|
||
|
||
// 创建节点映射
|
||
list.forEach(item => {
|
||
map.set(item.categoryId, {
|
||
...item,
|
||
children: [],
|
||
});
|
||
});
|
||
|
||
// 构建树形结构
|
||
list.forEach(item => {
|
||
const node = map.get(item.categoryId);
|
||
const parentId = item.parentId || 0;
|
||
|
||
if (parentId === 0 || !map.has(parentId)) {
|
||
// 根节点
|
||
roots.push(node!);
|
||
} else {
|
||
// 子节点
|
||
const parent = map.get(parentId);
|
||
if (parent && node) {
|
||
if (!parent.children) {
|
||
parent.children = [];
|
||
}
|
||
parent.children.push(node);
|
||
}
|
||
}
|
||
});
|
||
|
||
return roots;
|
||
};
|
||
|
||
const handleCategoryChange = (value: number | null) => {
|
||
if (value) {
|
||
formData.categoryId = value;
|
||
// 根据 categoryId 查找 categoryName
|
||
const findCategoryName = (list: CategoryItem[], id: number): string | null => {
|
||
for (const item of list) {
|
||
if (item.categoryId === id) {
|
||
return item.categoryName;
|
||
}
|
||
if (item.children) {
|
||
const found = findCategoryName(item.children, id);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const categoryName = findCategoryName(categoryTreeOptions.value, value);
|
||
if (categoryName) {
|
||
formData.category = categoryName;
|
||
}
|
||
} else {
|
||
formData.categoryId = 0;
|
||
formData.category = '';
|
||
}
|
||
};
|
||
|
||
const goBack = () => {
|
||
router.push("/apps/knowledge");
|
||
};
|
||
|
||
const handleSubmit = () => {
|
||
if (!formRef.value) return;
|
||
formRef.value.validate(async (valid: boolean) => {
|
||
if (!valid) return;
|
||
|
||
const currentId = id.value as string;
|
||
|
||
if (!formData.categoryId) {
|
||
ElMessage.warning("请选择分类");
|
||
return;
|
||
}
|
||
|
||
const submitData: any = {
|
||
title: formData.title || "",
|
||
categoryId: Number(formData.categoryId) || 0,
|
||
author: formData.author || "",
|
||
content: formData.content || "",
|
||
tags:
|
||
Array.isArray(formData.tags) && formData.tags.length > 0
|
||
? JSON.stringify(formData.tags)
|
||
: "[]",
|
||
status: 1,
|
||
share: formData.share,
|
||
};
|
||
|
||
if (isEdit.value && currentId !== "new") {
|
||
const knowledgeId = parseInt(currentId as string);
|
||
if (isNaN(knowledgeId) || knowledgeId <= 0) {
|
||
ElMessage.error("知识ID无效");
|
||
return;
|
||
}
|
||
try {
|
||
const res = await updateKnowledge(knowledgeId, submitData);
|
||
if (res.code === 0) {
|
||
ElMessage.success("保存成功");
|
||
goBack();
|
||
} else {
|
||
ElMessage.error(res.message || "保存失败");
|
||
}
|
||
} catch (e: any) {
|
||
console.error("保存失败:", e);
|
||
const errorMessage =
|
||
e.response?.data?.message || e.message || "保存失败";
|
||
ElMessage.error(errorMessage);
|
||
}
|
||
} else {
|
||
try {
|
||
const res = await createKnowledge(submitData);
|
||
if (res.code === 0) {
|
||
ElMessage.success("创建成功");
|
||
goBack();
|
||
} else {
|
||
ElMessage.error(res.message || "创建失败");
|
||
}
|
||
} catch (e: any) {
|
||
console.error("创建失败:", e);
|
||
const errorMessage =
|
||
e.response?.data?.message || e.message || "创建失败";
|
||
ElMessage.error(errorMessage);
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
onMounted(async () => {
|
||
const author = getLoginUser();
|
||
if (author && !isEdit.value) {
|
||
formData.author = author;
|
||
}
|
||
|
||
await loadCategoryAndTag();
|
||
|
||
if (isEdit.value) {
|
||
await fetchDetail();
|
||
}
|
||
// 新建时不需要设置默认分类,让用户自己选择
|
||
});
|
||
|
||
defineExpose({
|
||
formRef,
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.knowledge-edit {
|
||
padding: 24px;
|
||
min-height: 100%;
|
||
background-color: var(--el-bg-color-page);
|
||
}
|
||
|
||
.edit-header {
|
||
background: var(--el-bg-color);
|
||
border-radius: 12px;
|
||
padding: 20px 24px;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
|
||
.edit-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
}
|
||
|
||
.edit-meta {
|
||
margin-bottom: 20px;
|
||
|
||
.meta-card {
|
||
background: var(--el-bg-color);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
|
||
.meta-title {
|
||
margin: 0 0 16px;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
.meta-actions {
|
||
margin-top: 24px;
|
||
text-align: right;
|
||
}
|
||
}
|
||
}
|
||
|
||
.edit-body {
|
||
display: flex;
|
||
gap: 20px;
|
||
min-height: 600px;
|
||
}
|
||
|
||
.editor-panel,
|
||
.preview-panel {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.editor-card,
|
||
.preview-card {
|
||
background: var(--el-bg-color);
|
||
border-radius: 12px;
|
||
padding: 24px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.editor-title,
|
||
.preview-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
|
||
|
||
.editor-content {
|
||
flex: 1;
|
||
min-height: 400px;
|
||
}
|
||
}
|
||
|
||
.preview-card {
|
||
.markdown-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
color: var(--el-text-color-primary);
|
||
padding: 16px;
|
||
background: var(--el-fill-color-lighter);
|
||
border-radius: 8px;
|
||
|
||
:deep(h1), :deep(h2), :deep(h3) {
|
||
margin-top: 16px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
:deep(p), :deep(li) {
|
||
margin: 8px 0;
|
||
}
|
||
|
||
:deep(code) {
|
||
background-color: var(--el-fill-color-light);
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
:deep(pre) {
|
||
background-color: var(--el-fill-color-light);
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
overflow-x: auto;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 响应式布局 */
|
||
@media (max-width: 768px) {
|
||
.edit-body {
|
||
flex-direction: column;
|
||
height: auto;
|
||
}
|
||
|
||
.editor-panel,
|
||
.preview-panel {
|
||
width: 100%;
|
||
}
|
||
}
|
||
</style>
|