fix(help):修复帮助文档问题
This commit is contained in:
1 parent
0a1acd8e95
commit
8d4e8744d5
9 files changed
+318
-65
No files matched your search
@@ -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:"状态"`
|
||||
|
||||
@@ -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 = "注册邮箱验证未启用"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user