feat: add rich text help article editor
This commit is contained in:
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, "'");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user