feat: add rich text help article editor

This commit is contained in:
wushumin
2026-05-27 14:41:28 +08:00
parent 2ccbd0ffe4
commit 7d901fb435
17 changed files with 1311 additions and 114 deletions

View File

@@ -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;

View 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>

View 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>

View File

@@ -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>

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -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
View 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>;
}