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

@@ -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;");
}