fix(help):修复帮助文档问题

This commit is contained in:
qian committed 2026-09-02 23:56:49 +08:00
1 parent 0a1acd8e95
commit 8d4e8744d5
9 files changed
+318 -65

No files matched your search

+4 -4
View File
@@ -11,7 +11,7 @@ type HelpCategoryCreateReq struct {
g.Meta `path:"/help-categories" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助分类"`
ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"`
Name string `json:"name" v:"required|length:1,100" dc:"分类名称"`
Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"`
Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
Description string `json:"description" dc:"分类描述"`
SortOrder int `json:"sort_order" d:"0" dc:"排序序号"`
Icon string `json:"icon" dc:"图标名称"`
@@ -27,7 +27,7 @@ type HelpCategoryUpdateReq struct {
Id int64 `json:"id" in:"path" v:"required|min:1"`
ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"`
Name string `json:"name" v:"required|length:1,100" dc:"分类名称"`
Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"`
Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
Description string `json:"description" dc:"分类描述"`
SortOrder int `json:"sort_order" d:"0" dc:"排序序号"`
Icon string `json:"icon" dc:"图标名称"`
@@ -77,7 +77,7 @@ type HelpArticleCreateReq struct {
g.Meta `path:"/help-articles" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助文章"`
CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"`
Title string `json:"title" v:"required|length:1,200" dc:"文章标题"`
Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"`
Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
Content string `json:"content" v:"required" dc:"文章内容(Markdown)"`
Summary string `json:"summary" dc:"文章摘要"`
Status string `json:"status" d:"draft" v:"in:draft,published" dc:"状态"`
@@ -94,7 +94,7 @@ type HelpArticleUpdateReq struct {
Id int64 `json:"id" in:"path" v:"required|min:1"`
CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"`
Title string `json:"title" v:"required|length:1,200" dc:"文章标题"`
Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"`
Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
Content string `json:"content" dc:"文章内容(Markdown),不传则不更新"`
Summary string `json:"summary" dc:"文章摘要"`
Status string `json:"status" v:"in:draft,published" dc:"状态"`
+2
View File
@@ -182,6 +182,7 @@ const (
CodeHelpCategorySlugExists = 10074
CodeHelpArticleNotFound = 10075
CodeHelpArticleSlugExists = 10076
CodeHelpRateLimitExceeded = 10099
// Registration errors
CodeRegistrationDisabled = 10077
@@ -337,6 +338,7 @@ const (
MsgHelpCategorySlugExists = "分类标识已存在"
MsgHelpArticleNotFound = "帮助文章不存在"
MsgHelpArticleSlugExists = "文章标识已存在"
MsgHelpRateLimitExceeded = "访问过于频繁,请稍后再试"
// Registration errors
MsgRegistrationDisabled = "注册功能已关闭"
MsgEmailVerificationDisabled = "注册邮箱验证未启用"
+59 -5
View File
@@ -28,6 +28,13 @@ func (s *sAdmin) CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCre
return nil, common.NewBusinessError(consts.CodeHelpCategorySlugExists, consts.MsgHelpCategorySlugExists)
}
// 父分类校验:必须存在、必须是顶级分类(仅支持两级)
if req.ParentId > 0 {
if err = s.validateHelpCategoryParent(ctx, 0, req.ParentId); err != nil {
return nil, err
}
}
isVisible := true
if req.IsVisible != nil {
isVisible = *req.IsVisible
@@ -77,6 +84,13 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd
return nil, common.NewBusinessError(consts.CodeBadRequest, "不能将分类设置为自己的子分类")
}
// 父分类校验:必须存在、必须是顶级分类(仅支持两级),且祖先链中不能出现自己(防环导致分类互相锁定无法删除)
if req.ParentId > 0 {
if err = s.validateHelpCategoryParent(ctx, req.Id, req.ParentId); err != nil {
return nil, err
}
}
updateData := do.SptCategories{
ParentId: req.ParentId,
Name: req.Name,
@@ -100,6 +114,45 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd
return &v1.HelpCategoryUpdateRes{}, nil
}
// validateHelpCategoryParent 校验父分类:必须存在、必须是顶级分类(仅支持两级结构),
// 且父分类的祖先链中不能出现 selfId(否则会形成 A→B→A 的环,环上分类因互为父子被删除校验拦截,永远无法删除)
func (s *sAdmin) validateHelpCategoryParent(ctx context.Context, selfId, parentId int64) error {
var parent *struct {
Id int64 `json:"id"`
ParentId int64 `json:"parent_id"`
}
err := dao.SptCategories.Ctx(ctx).Where("id", parentId).Scan(&parent)
if err = common.IgnoreScanNoRows(err); err != nil {
return err
}
if parent == nil {
return common.NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound)
}
if parent.ParentId != 0 {
return common.NewBusinessError(consts.CodeBadRequest, "仅支持两级分类,父分类必须是顶级分类")
}
// 沿祖先链向上检查是否会出现自己(数据异常时用深度上限兜底)
current := parent.ParentId
for i := 0; i < 50 && current != 0; i++ {
if current == selfId {
return common.NewBusinessError(consts.CodeBadRequest, "不能将分类挂到自身或其子分类下")
}
var ancestor *struct {
ParentId int64 `json:"parent_id"`
}
err = dao.SptCategories.Ctx(ctx).Where("id", current).Scan(&ancestor)
if err = common.IgnoreScanNoRows(err); err != nil {
return err
}
if ancestor == nil {
return nil
}
current = ancestor.ParentId
}
return nil
}
// DeleteHelpCategory 删除帮助分类
func (s *sAdmin) DeleteHelpCategory(ctx context.Context, req *v1.HelpCategoryDeleteReq) (*v1.HelpCategoryDeleteRes, error) {
var cat *struct {
@@ -223,9 +276,10 @@ func (s *sAdmin) CreateHelpArticle(ctx context.Context, req *v1.HelpArticleCreat
// UpdateHelpArticle 更新帮助文章
func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdateReq) (*v1.HelpArticleUpdateRes, error) {
var article *struct {
Id int64 `json:"id"`
CategoryId int64 `json:"category_id"`
Status string `json:"status"`
Id int64 `json:"id"`
CategoryId int64 `json:"category_id"`
Status string `json:"status"`
PublishedAt *gtime.Time `json:"published_at"`
}
err := dao.SptArticles.Ctx(ctx).Where("id", req.Id).Scan(&article)
if err = common.IgnoreScanNoRows(err); err != nil {
@@ -269,8 +323,8 @@ func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdat
data.Content = req.Content
}
// 从草稿发布时设置发布时间
if req.Status == "published" && article.Status != "published" {
// 从草稿发布时设置发布时间(仅首次发布固定;下架再上架不重置,保持原始发布时间与排序位置)
if req.Status == "published" && article.Status != "published" && article.PublishedAt == nil {
data.PublishedAt = gtime.Now()
}
+61 -14
View File
@@ -2,8 +2,13 @@ package common
import (
"context"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
)
@@ -65,10 +70,12 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error
countMap[cr.CategoryId] = cr.ArticleCount
}
// 构建所有节点
nodeMap := make(map[int64]*HelpPublicCategoryItem)
// 构建所有节点(rows 已按 sort_order 排序,同时维护有序切片保证树节点顺序稳定,
// 不能遍历 map 组装——Go map 遍历顺序随机,会导致分类顺序每次请求都变)
nodeMap := make(map[int64]*HelpPublicCategoryItem, len(rows))
orderedNodes := make([]*HelpPublicCategoryItem, 0, len(rows))
for _, r := range rows {
nodeMap[r.Id] = &HelpPublicCategoryItem{
node := &HelpPublicCategoryItem{
Id: r.Id,
ParentId: r.ParentId,
Name: r.Name,
@@ -78,11 +85,13 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error
ArticleCount: countMap[r.Id],
Children: make([]*HelpPublicCategoryItem, 0),
}
nodeMap[r.Id] = node
orderedNodes = append(orderedNodes, node)
}
// 组装树结构
// 组装树结构(按 sort_order 顺序遍历,roots 与 children 均保持稳定排序)
var roots []*HelpPublicCategoryItem
for _, node := range nodeMap {
for _, node := range orderedNodes {
if node.ParentId == 0 {
roots = append(roots, node)
} else if parent, ok := nodeMap[node.ParentId]; ok {
@@ -120,18 +129,19 @@ func ListPublicArticles(ctx context.Context, categorySlug string, page, pageSize
return nil, 0, 0, 0, err
}
if cat == nil {
return nil, 0, 0, 0, NewBusinessError(10073, "帮助分类不存在")
return nil, 0, 0, 0, NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound)
}
var total int
rows := make([]*HelpPublicArticleItem, 0)
// 排序与管理后台列表保持一致:sort_order 优先,其次发布时间倒序
err = dao.SptArticles.Ctx(ctx).
Where("category_id", cat.Id).
Where("status", "published").
Where("published_at IS NOT NULL").
Where("published_at <=", gtime.Now()).
OrderDesc("published_at").
OrderAsc("sort_order").
OrderDesc("published_at").
Page(page, pageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
@@ -177,16 +187,18 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai
return nil, err
}
if row.Id == 0 {
return nil, NewBusinessError(10075, "帮助文章不存在")
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
}
if row.Status != "published" || row.PublishedAt == nil || row.PublishedAt.Timestamp() > gtime.Now().Timestamp() {
return nil, NewBusinessError(10075, "帮助文章不存在")
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
}
// 增加浏览计数
dao.SptArticles.Ctx(ctx).
// 增加浏览计数(失败仅记日志,不影响详情返回)
if _, err = dao.SptArticles.Ctx(ctx).
Where("id", row.Id).
Increment("view_count", 1)
Increment("view_count", 1); err != nil {
g.Log().Warningf(ctx, "帮助文章浏览计数失败 id=%d: %v", row.Id, err)
}
return &HelpPublicArticleDetail{
Id: row.Id,
@@ -201,18 +213,23 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai
}, nil
}
// SearchPublicArticles 全文搜索已发布文章
// SearchPublicArticles 搜索已发布文章
// 用 ILIKE 模糊匹配而非 to_tsvector 全文检索:PostgreSQL 'simple' 分词配置不做中文分词,
// 连续中文会被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。
// 帮助文章量级通常为百级,ILIKE 顺序扫描性能足够。
func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) ([]*HelpPublicArticleItem, int, int, int, error) {
page, pageSize = NormalizePagination(page, pageSize)
likePat := "%" + escapeLikePattern(query) + "%"
var total int
rows := make([]*HelpPublicArticleItem, 0)
err := dao.SptArticles.Ctx(ctx).
Where("status", "published").
Where("published_at IS NOT NULL").
Where("published_at <=", gtime.Now()).
Where("to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(content,'')) @@ plainto_tsquery('simple', ?)", query).
Where("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')", likePat, likePat).
OrderDesc("published_at").
OrderAsc("sort_order").
Page(page, pageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
@@ -221,3 +238,33 @@ func SearchPublicArticles(ctx context.Context, query string, page, pageSize int)
return rows, total, page, pageSize, nil
}
// escapeLikePattern 转义 ILIKE 模式中的通配符(配合 ESCAPE '\' 使用),防止用户输入 % _ \ 干扰匹配
func escapeLikePattern(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
// CheckHelpCenterRateLimit 帮助中心公开接口 IP 限流(公开无认证端点,防刷浏览量/搜索滥用)
// 默认每 IP 每分钟 60 次,可通过配置 help_center_rate_limit_per_minute 调整;Redis 不可用时放行(帮助中心为公开只读内容)
func CheckHelpCenterRateLimit(ctx context.Context, ipAddress string) error {
if ipAddress == "" {
return nil
}
limit := Config().GetInt(ctx, "help_center_rate_limit_per_minute")
if limit <= 0 {
limit = 60
}
key := fmt.Sprintf("help:center:rl:%s:%d", ipAddress, time.Now().Unix()/60)
count, err := incrementWithExpire(ctx, key, 120)
if err != nil {
g.Log().Warningf(ctx, "帮助中心限流检查失败: %v", err)
return nil
}
if count > int64(limit) {
return NewBusinessError(consts.CodeHelpRateLimitExceeded, consts.MsgHelpRateLimitExceeded)
}
return nil
}
+21
View File
@@ -3,14 +3,26 @@ package tenant
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/logic/common"
)
// helpRateLimit 帮助中心公开接口统一入口限流(非 HTTP 上下文跳过)
func helpRateLimit(ctx context.Context) error {
if r := g.RequestFromCtx(ctx); r != nil {
return common.CheckHelpCenterRateLimit(ctx, r.GetClientIp())
}
return nil
}
// ListHelpPublicCategories 公开帮助分类列表(树结构)
func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublicCategoryListReq) (*v1.HelpPublicCategoryListRes, error) {
if err := helpRateLimit(ctx); err != nil {
return nil, err
}
items, err := common.ListPublicCategories(ctx)
if err != nil {
return nil, err
@@ -22,6 +34,9 @@ func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublic
// ListHelpPublicArticles 分类下的文章列表
func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublicArticleListReq) (*v1.HelpPublicArticleListRes, error) {
if err := helpRateLimit(ctx); err != nil {
return nil, err
}
items, total, page, pageSize, err := common.ListPublicArticles(ctx, req.CategorySlug, req.Page, req.PageSize)
if err != nil {
return nil, err
@@ -50,6 +65,9 @@ func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublic
// GetHelpPublicArticle 文章详情
func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicArticleGetReq) (*v1.HelpPublicArticleGetRes, error) {
if err := helpRateLimit(ctx); err != nil {
return nil, err
}
detail, err := common.GetPublicArticle(ctx, req.Slug)
if err != nil {
return nil, err
@@ -75,6 +93,9 @@ func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicAr
// SearchHelpPublicArticles 搜索文章
func (s *sTenant) SearchHelpPublicArticles(ctx context.Context, req *v1.HelpPublicSearchReq) (*v1.HelpPublicSearchRes, error) {
if err := helpRateLimit(ctx); err != nil {
return nil, err
}
items, total, page, pageSize, err := common.SearchPublicArticles(ctx, req.Query, req.Page, req.PageSize)
if err != nil {
return nil, err
@@ -0,0 +1,10 @@
-- +goose Up
-- 帮助中心文章搜索从 to_tsvector 全文检索改为 ILIKE 模糊匹配:
-- PostgreSQL 'simple' 分词配置不做中文分词,连续中文被切成单个长 token,
-- 中文关键词几乎无法命中(帮助文档以中文内容为主)。该 GIN 索引不再被查询使用,删除以释放空间。
DROP INDEX IF EXISTS idx_spt_articles_search;
-- +goose Down
-- 恢复全文检索索引(仅回滚迁移时使用,此时搜索已退回 to_tsvector 方案)
CREATE INDEX idx_spt_articles_search ON spt_articles USING gin (to_tsvector('simple'::regconfig, (((COALESCE(title, ''::character varying))::text || ' '::text) || COALESCE(content, ''::text))));
@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref, reactive, h, onMounted } from 'vue'
import { ref, reactive, computed, h, onMounted } from 'vue'
import { Tag, Button, Space, Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import TableStats from '@/components/TableStats.vue'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import request from '@/utils/request'
import { hasPermission } from '@/utils/permission'
const canEdit = hasPermission('support:edit')
const loading = ref(false)
const articles = ref<any[]>([])
@@ -40,6 +43,7 @@ const columns: TableColumnData[] = [
{
title: '操作', dataIndex: 'actions', width: 200, fixed: 'right',
render({ record }) {
if (!canEdit) return '-'
return h(Space, { size: 'small' }, () => [
h(Button, { size: 'small', type: 'text', onClick: () => openEdit(record) }, () => '编辑'),
record.status === 'draft'
@@ -51,15 +55,29 @@ const columns: TableColumnData[] = [
},
]
// 分类下拉选项:顶级在前,子分类带前缀缩进(两级结构)
const categoryOptions = computed(() => {
const tops = categories.value.filter((c: any) => c.parent_id === 0)
const opts: { value: number; label: string }[] = tops.map((t: any) => ({ value: t.id, label: t.name }))
for (const c of categories.value) {
if (c.parent_id !== 0 && tops.some((t: any) => t.id === c.parent_id)) {
opts.push({ value: c.id, label: `└ ${c.name}` })
}
}
return opts
})
async function fetchCategories() {
try {
const res = await request.get('/admin/help-categories', { params: { page: 1, page_size: 100, parent_id: -1 } })
const res = await request.get('/admin/help-categories', { params: { page: 1, page_size: 500, parent_id: -1 } })
categories.value = res.data?.data?.list || []
} catch {} finally {
// 无论有无分类都加载列表:无分类时应展示空态而非不加载
if (categories.value.length > 0 && !categoryFilter.value) {
categoryFilter.value = categories.value[0].id
fetchList()
}
} catch {}
fetchList()
}
}
async function fetchList() {
@@ -129,7 +147,19 @@ async function handleSubmit() {
async function publishArticle(row: any) {
Modal.confirm({ title: '确认发布', content: `确定发布文章「${row.title}」?`, okText: '发布', cancelText: '取消', onOk: async () => {
try { await request.put(`/admin/help-articles/${row.id}`, { ...row, status: 'published' }); Message.success('发布成功'); fetchList() } catch {}
try {
// 显式传更新所需字段:不展开整行(列表行不含 content,且避免把无关字段一并回传)
await request.put(`/admin/help-articles/${row.id}`, {
category_id: row.category_id,
title: row.title,
slug: row.slug,
summary: row.summary || '',
status: 'published',
sort_order: row.sort_order || 0,
keywords: row.keywords || [],
})
Message.success('发布成功'); fetchList()
} catch {}
}})
}
@@ -146,7 +176,7 @@ onMounted(() => { fetchCategories() })
<div class="page-table">
<PageHeader title="帮助文章" description="管理帮助中心的文章内容">
<template #actions>
<AButton type="primary" @click="openCreate">创建文章</AButton>
<AButton v-if="canEdit" type="primary" @click="openCreate">创建文章</AButton>
</template>
</PageHeader>
@@ -155,7 +185,7 @@ onMounted(() => { fetchCategories() })
<div class="flex items-center justify-between w-full">
<span>文章列表</span>
<Space>
<ASelect v-model="categoryFilter" :options="categories.map((c: any) => ({ value: c.id, label: c.name }))" style="width: 140px" allow-clear placeholder="按分类筛选" @change="() => { pagination.current = 1; fetchList() }" />
<ASelect v-model="categoryFilter" :options="categoryOptions" style="width: 140px" allow-clear placeholder="按分类筛选" @change="() => { pagination.current = 1; fetchList() }" />
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear placeholder="状态筛选" @change="() => { pagination.current = 1; fetchList() }" />
</Space>
</div>
@@ -174,7 +204,7 @@ onMounted(() => { fetchCategories() })
<AFormItem label="Slug" required><AInput v-model="form.slug" placeholder="url-friendly-identifier" /></AFormItem>
<div class="flex gap-4">
<AFormItem label="分类" required class="flex-1 min-w-[160px]">
<ASelect v-model="form.category_id" :options="categories.map((c: any) => ({ value: c.id, label: c.name }))" placeholder="选择分类" allow-clear />
<ASelect v-model="form.category_id" :options="categoryOptions" placeholder="选择分类" allow-clear />
</AFormItem>
<AFormItem label="状态" class="w-40">
<ASelect v-model="form.status" :options="[{ label: '草稿', value: 'draft' }, { label: '已发布', value: 'published' }]" />
@@ -1,21 +1,46 @@
<script setup lang="ts">
import { ref, reactive, h, onMounted } from 'vue'
import { ref, reactive, computed, h, onMounted } from 'vue'
import { Tag, Button, Space, Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import PageHeader from '@/components/PageHeader.vue'
import TableStats from '@/components/TableStats.vue'
import ResponsiveTable from '@/components/ResponsiveTable.vue'
import request from '@/utils/request'
import { hasPermission } from '@/utils/permission'
const canEdit = hasPermission('support:edit')
const loading = ref(false)
const categories = ref<any[]>([])
// 全量分类(不分页),用于父分类选择与父分类名称展示
const allCategories = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50] })
// 图标选项与租户端帮助中心 categoryIconList 保持一致,避免配出无效图标名
const iconOptions = [
{ value: 'bookOpen', label: '手册(bookOpen)' },
{ value: 'document', label: '文档(document)' },
{ value: 'cog', label: '设置(cog)' },
{ value: 'shield', label: '安全(shield)' },
{ value: 'key', label: '密钥(key)' },
{ value: 'creditCard', label: '计费(creditCard)' },
{ value: 'bell', label: '通知(bell)' },
{ value: 'chat', label: '对话(chat)' },
{ value: 'bolt', label: '闪电(bolt)' },
{ value: 'building', label: '组织(building)' },
]
function parentName(id: number) {
if (!id) return '-'
return allCategories.value.find((c: any) => c.id === id)?.name || `#${id}`
}
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true, render({ record }) { return record.parent_id ? `└ ${record.name}` : record.name } },
{ title: '父分类', dataIndex: 'parent_id', width: 110, render({ record }) { return parentName(record.parent_id) }},
{ title: 'Slug', dataIndex: 'slug', width: 140, ellipsis: true },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{ title: '描述', dataIndex: 'description', width: 180, ellipsis: true },
{ title: '排序', dataIndex: 'sort_order', width: 70 },
{ title: '文章数', dataIndex: 'article_count', width: 80 },
{ title: '可见', dataIndex: 'is_visible', width: 70, render({ record }) {
@@ -25,6 +50,7 @@ const columns: TableColumnData[] = [
{
title: '操作', dataIndex: 'actions', width: 160, fixed: 'right',
render({ record }) {
if (!canEdit) return '-'
return h(Space, { size: 'small' }, () => [
h(Button, { size: 'small', type: 'text', onClick: () => openEdit(record) }, () => '编辑'),
h(Button, { size: 'small', type: 'text', status: 'danger', onClick: () => deleteCategory(record) }, () => '删除'),
@@ -43,11 +69,25 @@ async function fetchList() {
} catch {} finally { loading.value = false }
}
// 拉全量分类(分类总量小),供父分类选择器与父分类名称展示使用
async function fetchAllCategories() {
try {
const res = await request.get('/admin/help-categories', { params: { page: 1, page_size: 500, parent_id: -1 } })
allCategories.value = res.data?.data?.list || []
} catch {}
}
// 父分类选项:仅顶级分类可作父级(两级结构),编辑时排除自身
const parentOptions = computed(() => {
const tops = allCategories.value.filter((c: any) => c.parent_id === 0 && c.id !== editingId.value)
return [{ value: 0, label: '顶级分类' }, ...tops.map((c: any) => ({ value: c.id, label: c.name }))]
})
// Create / Edit
const showDrawer = ref(false)
const editingId = ref<number | null>(null)
const formLoading = ref(false)
const form = reactive({ parent_id: 0, name: '', slug: '', description: '', sort_order: 0, icon: '', is_visible: true as boolean })
const form = reactive({ parent_id: 0, name: '', slug: '', description: '', sort_order: 0, icon: '' as string | undefined, is_visible: true as boolean })
function resetForm() { Object.assign(form, { parent_id: 0, name: '', slug: '', description: '', sort_order: 0, icon: '', is_visible: true }) }
@@ -64,30 +104,31 @@ async function handleSubmit() {
if (!form.slug.trim()) { Message.warning('请输入 Slug'); return }
formLoading.value = true
try {
if (editingId.value) { await request.put(`/admin/help-categories/${editingId.value}`, form); Message.success('更新成功') }
else { await request.post('/admin/help-categories', form); Message.success('创建成功') }
showDrawer.value = false; fetchList()
const payload = { ...form, icon: form.icon || '' }
if (editingId.value) { await request.put(`/admin/help-categories/${editingId.value}`, payload); Message.success('更新成功') }
else { await request.post('/admin/help-categories', payload); Message.success('创建成功') }
showDrawer.value = false; fetchList(); fetchAllCategories()
} catch {} finally { formLoading.value = false }
}
async function deleteCategory(row: any) {
Modal.confirm({ title: '确认删除', content: `确定删除分类「${row.name}」?`, okText: '删除', cancelText: '取消', onOk: async () => {
try { await request.delete(`/admin/help-categories/${row.id}`); Message.success('删除成功'); fetchList() } catch {}
try { await request.delete(`/admin/help-categories/${row.id}`); Message.success('删除成功'); fetchList(); fetchAllCategories() } catch {}
}})
}
onMounted(fetchList)
onMounted(() => { fetchList(); fetchAllCategories() })
</script>
<template>
<div class="page-table">
<PageHeader title="帮助分类" description="管理帮助中心的文章分类">
<PageHeader title="帮助分类" description="管理帮助中心的文章分类(支持两级)">
<template #actions>
<AButton type="primary" @click="openCreate">创建分类</AButton>
<AButton v-if="canEdit" type="primary" @click="openCreate">创建分类</AButton>
</template>
</PageHeader>
<ACard :bordered="false">
<ResponsiveTable :columns="columns" :data="categories" :loading="loading" row-key="id" :scroll="{ x: 1100 }" card-title-key="name" card-subtitle-key="slug" card-badge-key="is_visible" :card-fields="['description', 'sort_order', 'article_count', 'created_at']" />
<ResponsiveTable :columns="columns" :data="categories" :loading="loading" row-key="id" :scroll="{ x: 1200 }" card-title-key="name" card-subtitle-key="slug" card-badge-key="is_visible" :card-fields="['parent_id', 'description', 'sort_order', 'article_count', 'created_at']" />
<div class="table-footer">
<TableStats :total="pagination.total" />
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
@@ -97,10 +138,13 @@ onMounted(fetchList)
<ADrawer v-model:visible="showDrawer" :title="editingId ? '编辑分类' : '创建分类'" :width="480" :mask-closable="false" :footer="true" unmount-on-close>
<AForm :model="form" :auto-label-width="true" layout="vertical">
<AFormItem label="名称" required><AInput v-model="form.name" placeholder="分类名称" /></AFormItem>
<AFormItem label="Slug" required><AInput v-model="form.slug" placeholder="url-friendly-identifier" /></AFormItem>
<AFormItem label="父分类">
<ASelect v-model="form.parent_id" :options="parentOptions" placeholder="选择父分类(默认顶级)" />
</AFormItem>
<AFormItem label="Slug" required><AInput v-model="form.slug" placeholder="url-friendly-identifier(小写字母、数字、中划线)" /></AFormItem>
<AFormItem label="描述"><ATextarea v-model="form.description" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="分类描述" /></AFormItem>
<AFormItem label="排序"><AInputNumber v-model="form.sort_order" :min="0" placeholder="0" class="w-full" /></AFormItem>
<AFormItem label="图标"><AInput v-model="form.icon" placeholder="图标名称(可选)" /></AFormItem>
<AFormItem label="图标"><ASelect v-model="form.icon" :options="iconOptions" allow-clear placeholder="选择图标(可选)" /></AFormItem>
<AFormItem label="对外可见"><ASwitch v-model="form.is_visible" /></AFormItem>
</AForm>
<template #footer>
+65 -20
View File
@@ -75,6 +75,12 @@ function catIcon(idx: number): string {
return categoryIconList[idx % categoryIconList.length]
}
// 解析分类图标:配置的图标名不在支持列表时(历史数据/手工录入),回退到按索引的默认图标
function resolveIcon(name: string | undefined, idx: number): string {
if (name && categoryIconList.includes(name)) return name
return catIcon(idx)
}
const categoryIndexMap = computed(() => {
const map = new Map<string, number>()
categories.value.forEach((cat, idx) => {
@@ -135,7 +141,7 @@ async function loadList() {
})
raw = res.data?.data
} else if (activeCategorySlug.value) {
const res: any = await request.get(`/tenant/help/categories/${activeCategorySlug.value}/articles`, {
const res: any = await request.get(`/tenant/help/categories/${encodeURIComponent(activeCategorySlug.value)}/articles`, {
params: { page: listPage.value, page_size: pageSize.value },
})
raw = res.data?.data
@@ -163,7 +169,7 @@ async function fetchArticleDetail(slug: string) {
articleLoading.value = true
activeArticleSlug.value = slug
try {
const res: any = await request.get(`/tenant/help/articles/${slug}`)
const res: any = await request.get(`/tenant/help/articles/${encodeURIComponent(slug)}`)
articleDetail.value = res.data?.data || null
} catch {
articleDetail.value = null
@@ -200,6 +206,12 @@ function goBackToLanding() {
isSearchMode.value = false
}
// 清空搜索词并退出搜索模式,回到分类落地页(否则清空输入框后仍停留在旧搜索结果)
function clearSearch() {
searchQuery.value = ''
if (isSearchMode.value) goBackToLanding()
}
onMounted(fetchCategories)
</script>
@@ -223,7 +235,7 @@ onMounted(fetchCategories)
<button
v-if="searchQuery"
class="text-gray-400 hover:text-gray-600 transition-colors"
@click="searchQuery = ''"
@click="clearSearch"
>
<Icon name="x" size="xs" />
</button>
@@ -232,7 +244,7 @@ onMounted(fetchCategories)
</div>
</div>
<!-- Mobile: horizontal category pills -->
<!-- Mobile: horizontal category pills(顶级 + 子分类平铺,子分类用描边样式区分层级) -->
<div v-if="categories.length" class="lg:hidden flex gap-2 overflow-x-auto scrollbar-hide -mx-1 px-1">
<button
:class="[
@@ -245,20 +257,37 @@ onMounted(fetchCategories)
>
全部
</button>
<button
v-for="(cat, idx) in categories"
:key="cat.id"
:class="[
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition-colors flex items-center gap-1.5',
activeCategorySlug === cat.slug && !isSearchMode
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
@click="fetchArticles(cat.slug)"
>
<span class="w-1.5 h-1.5 rounded-full" :class="catColor(idx).dot" />
{{ cat.name }}
</button>
<template v-for="(cat, idx) in categories" :key="cat.id">
<button
:class="[
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition-colors flex items-center gap-1.5',
activeCategorySlug === cat.slug && !isSearchMode
? 'bg-primary-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
]"
@click="fetchArticles(cat.slug)"
>
<span class="w-1.5 h-1.5 rounded-full" :class="catColor(idx).dot" />
{{ cat.name }}
</button>
<button
v-for="child in cat.children || []"
:key="child.id"
:class="[
'flex-shrink-0 px-2.5 py-1.5 rounded-full text-xs transition-colors flex items-center gap-1 border',
activeCategorySlug === child.slug && !isSearchMode
? 'bg-primary-500 text-white border-primary-500'
: 'bg-white border-gray-200 text-gray-500 hover:border-primary-300 hover:text-primary-600'
]"
@click="fetchArticles(child.slug)"
>
<span
class="w-1 h-1 rounded-full"
:class="activeCategorySlug === child.slug && !isSearchMode ? 'bg-white/80' : 'bg-gray-300'"
/>
{{ child.name }}
</button>
</template>
</div>
<!-- Main Layout -->
@@ -367,7 +396,12 @@ onMounted(fetchCategories)
<!-- Landing: Category Cards -->
<template v-else-if="isLanding">
<div v-if="categories.length > 0" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 分类加载中先显示骨架,避免闪现"暂无帮助文档"空态 -->
<div v-if="loading" class="card p-12 text-center">
<div class="spinner mx-auto mb-3" />
<p class="text-sm text-gray-500">加载中...</p>
</div>
<div v-else-if="categories.length > 0" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
v-for="(cat, idx) in categories"
:key="cat.id"
@@ -378,7 +412,7 @@ onMounted(fetchCategories)
class="h-10 w-10 rounded-xl flex items-center justify-center flex-shrink-0"
:class="catColor(idx).bg"
>
<Icon :name="cat.icon || catIcon(idx)" size="md" :class="catColor(idx).text" />
<Icon :name="resolveIcon(cat.icon, idx)" size="md" :class="catColor(idx).text" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">
@@ -389,6 +423,17 @@ onMounted(fetchCategories)
<Icon name="document" size="xs" />
{{ cat.article_count }} 篇文章
</div>
<!-- 子分类入口(桌面端落地卡片此前也只有顶级可点) -->
<div v-if="cat.children?.length" class="flex flex-wrap gap-1.5 mt-2.5">
<button
v-for="child in cat.children"
:key="child.id"
class="px-2 py-0.5 rounded-full text-xs text-gray-500 bg-gray-50 border border-gray-200 hover:border-primary-300 hover:text-primary-600 transition-colors"
@click.stop="fetchArticles(child.slug)"
>
{{ child.name }} · {{ child.article_count }}
</button>
</div>
</div>
</div>
</div>