feat: add rich text help article editor
This commit is contained in:
@@ -1509,6 +1509,7 @@ export interface AdminHelpArticleItem {
|
||||
title: string;
|
||||
summary: string;
|
||||
keywords: string[];
|
||||
content_html: string;
|
||||
content_blocks: string[];
|
||||
is_recommended: boolean;
|
||||
is_enabled: boolean;
|
||||
@@ -1522,6 +1523,7 @@ export interface AdminHelpArticlePayload {
|
||||
title: string;
|
||||
summary: string;
|
||||
keywords: string[];
|
||||
content_html: string;
|
||||
content_blocks: string[];
|
||||
is_recommended: boolean;
|
||||
is_enabled: boolean;
|
||||
|
||||
220
admin-web/src/components/RichTextEditor.vue
Normal file
220
admin-web/src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<script setup lang="ts">
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor/editor";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string;
|
||||
disabled?: boolean;
|
||||
minHeight?: number;
|
||||
uploadImage?: (file: File) => Promise<string>;
|
||||
}>(), {
|
||||
minHeight: 560,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
const mode = "default";
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const valueHtml = ref(props.modelValue || "");
|
||||
|
||||
const editorHeight = computed(() => `${props.minHeight}px`);
|
||||
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
toolbarKeys: [
|
||||
"header2",
|
||||
"header3",
|
||||
"header4",
|
||||
"|",
|
||||
"bold",
|
||||
"italic",
|
||||
"underline",
|
||||
"through",
|
||||
"blockquote",
|
||||
"|",
|
||||
"bulletedList",
|
||||
"numberedList",
|
||||
"|",
|
||||
"insertLink",
|
||||
{
|
||||
key: "group-image",
|
||||
title: "图片",
|
||||
menuKeys: ["insertImage", "uploadImage"],
|
||||
},
|
||||
"|",
|
||||
"clearStyle",
|
||||
"undo",
|
||||
"redo",
|
||||
],
|
||||
};
|
||||
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: "请输入文章正文,可使用标题、列表、引用、链接和图片。",
|
||||
scroll: true,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
maxFileSize: 5 * 1024 * 1024,
|
||||
allowedFileTypes: ["image/*"],
|
||||
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
ElMessage.error("仅支持上传图片文件");
|
||||
return;
|
||||
}
|
||||
if (!props.uploadImage) {
|
||||
ElMessage.error("图片上传接口未配置");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await props.uploadImage(file);
|
||||
insertFn(url, file.name);
|
||||
ElMessage.success("图片已插入");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error("图片上传失败");
|
||||
}
|
||||
},
|
||||
},
|
||||
insertLink: {
|
||||
checkLink: (_text: string, url: string) => {
|
||||
if (!url.trim()) {
|
||||
return "链接地址不能为空";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value === valueHtml.value) {
|
||||
return;
|
||||
}
|
||||
valueHtml.value = value || "";
|
||||
},
|
||||
);
|
||||
|
||||
watch(valueHtml, (value) => {
|
||||
emit("update:modelValue", value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
if (disabled) {
|
||||
editor.disable();
|
||||
return;
|
||||
}
|
||||
editor.enable();
|
||||
},
|
||||
);
|
||||
|
||||
function handleCreated(editor: IDomEditor) {
|
||||
editorRef.value = editor;
|
||||
if (props.disabled) {
|
||||
editor.disable();
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editorRef.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rich-text-editor" :class="{ 'rich-text-editor--disabled': disabled }">
|
||||
<Toolbar class="rich-text-editor__toolbar" :editor="editorRef" :default-config="toolbarConfig" :mode="mode" />
|
||||
<Editor
|
||||
v-model="valueHtml"
|
||||
class="rich-text-editor__body"
|
||||
:style="{ height: editorHeight }"
|
||||
:default-config="editorConfig"
|
||||
:mode="mode"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 36px rgba(34, 28, 18, 0.06);
|
||||
--w-e-toolbar-bg-color: #fff;
|
||||
--w-e-toolbar-color: var(--admin-text-main);
|
||||
--w-e-toolbar-active-bg-color: rgba(200, 164, 93, 0.14);
|
||||
--w-e-toolbar-active-color: var(--admin-text-main);
|
||||
--w-e-toolbar-border-color: var(--admin-border);
|
||||
--w-e-textarea-bg-color: #fff;
|
||||
--w-e-textarea-color: var(--admin-text-main);
|
||||
--w-e-textarea-selected-border-color: rgba(200, 164, 93, 0.55);
|
||||
--w-e-textarea-slight-bg-color: #fbf7ef;
|
||||
--w-e-textarea-slight-color: #9aa0aa;
|
||||
--w-e-modal-button-bg-color: #fff;
|
||||
--w-e-modal-button-border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.rich-text-editor--disabled {
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
.rich-text-editor__toolbar {
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
background: linear-gradient(180deg, #fff 0%, #fbfbfc 100%);
|
||||
}
|
||||
|
||||
:deep(.w-e-toolbar) {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
:deep(.w-e-bar-item button) {
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.w-e-bar-item button:hover) {
|
||||
background: rgba(200, 164, 93, 0.14);
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-placeholder) {
|
||||
color: #9aa0aa;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:deep(.w-e-scroll) {
|
||||
padding: 24px 30px;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor]) {
|
||||
color: var(--admin-text-main);
|
||||
font-size: 16px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] h2),
|
||||
:deep(.w-e-text-container [data-slate-editor] h3),
|
||||
:deep(.w-e-text-container [data-slate-editor] h4) {
|
||||
color: var(--admin-text);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
:deep(.w-e-text-container [data-slate-editor] img) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
202
admin-web/src/pages/content/article-edit.vue
Normal file
202
admin-web/src/pages/content/article-edit.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { adminApi, type AdminHelpArticleItem } from "../../api/admin";
|
||||
import RichTextEditor from "../../components/RichTextEditor.vue";
|
||||
import { articleCategoryOptions, contentBlocksFromHtml, parseLines, resetArticleForm, type ArticleFormState } from "./shared";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const articleForm = reactive<ArticleFormState>({
|
||||
category: "service",
|
||||
title: "",
|
||||
summary: "",
|
||||
keywordsText: "",
|
||||
contentHtml: "",
|
||||
is_recommended: false,
|
||||
is_enabled: true,
|
||||
sort_order: 0,
|
||||
});
|
||||
|
||||
const articleId = computed(() => Number(route.params.id || 0));
|
||||
const isEditMode = computed(() => articleId.value > 0);
|
||||
const pageTitle = computed(() => (isEditMode.value ? "编辑帮助文章" : "新增帮助文章"));
|
||||
|
||||
function backToList() {
|
||||
router.push({ name: "content-articles" });
|
||||
}
|
||||
|
||||
async function fetchArticle() {
|
||||
if (!isEditMode.value) {
|
||||
resetArticleForm(articleForm);
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await adminApi.getHelpArticles();
|
||||
const article = response.data.list.find((item: AdminHelpArticleItem) => item.id === articleId.value);
|
||||
if (!article) {
|
||||
ElMessage.error("帮助文章不存在");
|
||||
backToList();
|
||||
return;
|
||||
}
|
||||
resetArticleForm(articleForm, article);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error("帮助文章加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadArticleImage(file: File) {
|
||||
const response = await adminApi.uploadContentImage(file);
|
||||
return response.data.file_url;
|
||||
}
|
||||
|
||||
async function submitArticle() {
|
||||
const contentBlocks = contentBlocksFromHtml(articleForm.contentHtml);
|
||||
if (!articleForm.title.trim()) {
|
||||
ElMessage.error("请填写文章标题");
|
||||
return;
|
||||
}
|
||||
if (!articleForm.summary.trim()) {
|
||||
ElMessage.error("请填写文章摘要");
|
||||
return;
|
||||
}
|
||||
if (!contentBlocks.length) {
|
||||
ElMessage.error("请填写文章正文");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await adminApi.saveHelpArticle({
|
||||
id: articleForm.id,
|
||||
category: articleForm.category,
|
||||
title: articleForm.title.trim(),
|
||||
summary: articleForm.summary.trim(),
|
||||
keywords: parseLines(articleForm.keywordsText),
|
||||
content_html: articleForm.contentHtml,
|
||||
content_blocks: contentBlocks,
|
||||
is_recommended: articleForm.is_recommended,
|
||||
is_enabled: articleForm.is_enabled,
|
||||
sort_order: articleForm.sort_order,
|
||||
});
|
||||
ElMessage.success(articleForm.id ? "帮助文章已更新" : "帮助文章已创建");
|
||||
backToList();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error("帮助文章保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchArticle);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="article-edit-page" v-loading="loading">
|
||||
<el-card class="panel-card article-edit-header" shadow="never">
|
||||
<div>
|
||||
<div class="article-edit-header__title">{{ pageTitle }}</div>
|
||||
<div class="article-edit-header__desc">独立编辑页提供更大的正文区域,适合维护长文章和图文说明。</div>
|
||||
</div>
|
||||
<div class="article-edit-header__actions">
|
||||
<el-button @click="backToList">返回列表</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitArticle">保存文章</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="panel-card" shadow="never">
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="18">
|
||||
<el-col :span="6">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="articleForm.category" style="width: 100%">
|
||||
<el-option v-for="item in articleCategoryOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="articleForm.sort_order" :min="0" :step="10" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="推荐">
|
||||
<el-switch v-model="articleForm.is_recommended" inline-prompt active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="articleForm.is_enabled" inline-prompt active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="articleForm.title" maxlength="120" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="articleForm.summary" type="textarea" :rows="3" maxlength="300" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="articleForm.keywordsText" type="textarea" :rows="3" placeholder="每行一个关键词" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="正文内容">
|
||||
<RichTextEditor v-model="articleForm.contentHtml" :disabled="saving" :upload-image="uploadArticleImage" :min-height="640" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-edit-page {
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
.article-edit-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.article-edit-header :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.article-edit-header__title {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.article-edit-header__desc {
|
||||
margin-top: 6px;
|
||||
color: var(--admin-text-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.article-edit-header__actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useRouter } from "vue-router";
|
||||
import { adminApi, type AdminHelpArticleItem } from "../../api/admin";
|
||||
import { articleCategoryOptions, parseLines, resetArticleForm, type ArticleFormState } from "./shared";
|
||||
|
||||
const loading = ref(false);
|
||||
const articleSaving = ref(false);
|
||||
const articleDialogVisible = ref(false);
|
||||
const articles = ref<AdminHelpArticleItem[]>([]);
|
||||
|
||||
const articleForm = reactive<ArticleFormState>({
|
||||
category: "service",
|
||||
title: "",
|
||||
summary: "",
|
||||
keywordsText: "",
|
||||
contentBlocksText: "",
|
||||
is_recommended: false,
|
||||
is_enabled: true,
|
||||
sort_order: 0,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const articleStats = computed(() => ({
|
||||
total: articles.value.length,
|
||||
@@ -40,38 +28,11 @@ async function fetchArticles() {
|
||||
}
|
||||
|
||||
function openCreateArticle() {
|
||||
resetArticleForm(articleForm);
|
||||
articleDialogVisible.value = true;
|
||||
router.push({ name: "content-article-create" });
|
||||
}
|
||||
|
||||
function openEditArticle(row: AdminHelpArticleItem) {
|
||||
resetArticleForm(articleForm, row);
|
||||
articleDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitArticle() {
|
||||
articleSaving.value = true;
|
||||
try {
|
||||
await adminApi.saveHelpArticle({
|
||||
id: articleForm.id,
|
||||
category: articleForm.category,
|
||||
title: articleForm.title.trim(),
|
||||
summary: articleForm.summary.trim(),
|
||||
keywords: parseLines(articleForm.keywordsText),
|
||||
content_blocks: parseLines(articleForm.contentBlocksText),
|
||||
is_recommended: articleForm.is_recommended,
|
||||
is_enabled: articleForm.is_enabled,
|
||||
sort_order: articleForm.sort_order,
|
||||
});
|
||||
ElMessage.success(articleForm.id ? "帮助文章已更新" : "帮助文章已创建");
|
||||
articleDialogVisible.value = false;
|
||||
await fetchArticles();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ElMessage.error("帮助文章保存失败");
|
||||
} finally {
|
||||
articleSaving.value = false;
|
||||
}
|
||||
router.push({ name: "content-article-edit", params: { id: row.id } });
|
||||
}
|
||||
|
||||
async function deleteArticle(row: AdminHelpArticleItem) {
|
||||
@@ -133,58 +94,4 @@ onMounted(fetchArticles);
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="articleDialogVisible" :title="articleForm.id ? '编辑帮助文章' : '新增帮助文章'" width="760px">
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="articleForm.category" style="width: 100%">
|
||||
<el-option v-for="item in articleCategoryOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="articleForm.sort_order" :min="0" :step="10" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="推荐">
|
||||
<el-switch v-model="articleForm.is_recommended" inline-prompt active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="articleForm.is_enabled" inline-prompt active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="articleForm.title" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="articleForm.summary" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="articleForm.keywordsText" type="textarea" :rows="3" placeholder="每行一个关键词" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="正文内容">
|
||||
<el-input v-model="articleForm.contentBlocksText" type="textarea" :rows="8" placeholder="每行一段正文内容" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="articleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="articleSaving" @click="submitArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@ export type ArticleFormState = {
|
||||
title: string;
|
||||
summary: string;
|
||||
keywordsText: string;
|
||||
contentBlocksText: string;
|
||||
contentHtml: string;
|
||||
is_recommended: boolean;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
@@ -143,7 +143,7 @@ export function resetArticleForm(target: ArticleFormState, row?: AdminHelpArticl
|
||||
target.title = row?.title || "";
|
||||
target.summary = row?.summary || "";
|
||||
target.keywordsText = row?.keywords?.join("\n") || "";
|
||||
target.contentBlocksText = row?.content_blocks?.join("\n") || "";
|
||||
target.contentHtml = row ? articleContentHtml(row) : "";
|
||||
target.is_recommended = row?.is_recommended || false;
|
||||
target.is_enabled = row ? row.is_enabled : true;
|
||||
target.sort_order = row?.sort_order || 0;
|
||||
@@ -155,3 +155,42 @@ export function parseLines(value: string) {
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function contentBlocksFromHtml(value: string) {
|
||||
if (!value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(value, "text/html");
|
||||
const blocks = Array.from(doc.body.querySelectorAll("h2, h3, h4, p, li, blockquote"))
|
||||
.map((item) => (item.textContent || "").replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (blocks.length) {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
const bodyText = (doc.body.textContent || "").replace(/\s+/g, " ").trim();
|
||||
return bodyText ? [bodyText] : [];
|
||||
}
|
||||
|
||||
function articleContentHtml(row: AdminHelpArticleItem) {
|
||||
if (row.content_html?.trim()) {
|
||||
return row.content_html;
|
||||
}
|
||||
return contentBlocksToHtml(row.content_blocks || []);
|
||||
}
|
||||
|
||||
function contentBlocksToHtml(blocks: string[]) {
|
||||
return blocks.map((item) => `<p>${escapeHtml(item)}</p>`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
@@ -202,6 +202,30 @@ const adminChildren = [
|
||||
contentTab: "articles",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "articles/create",
|
||||
name: "content-article-create",
|
||||
component: () => import("../pages/content/article-edit.vue"),
|
||||
meta: {
|
||||
title: "内容中心",
|
||||
desc: "新增帮助文章:编辑分类、摘要、关键词和富文本正文。",
|
||||
permission: "system.manage",
|
||||
menuIndex: "content",
|
||||
contentTab: "articles",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "articles/:id/edit",
|
||||
name: "content-article-edit",
|
||||
component: () => import("../pages/content/article-edit.vue"),
|
||||
meta: {
|
||||
title: "内容中心",
|
||||
desc: "编辑帮助文章:维护富文本正文、推荐状态和排序。",
|
||||
permission: "system.manage",
|
||||
menuIndex: "content",
|
||||
contentTab: "articles",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
6
admin-web/src/types/wangeditor.d.ts
vendored
Normal file
6
admin-web/src/types/wangeditor.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module "@wangeditor/editor-for-vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
|
||||
export const Editor: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export const Toolbar: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user