feat: 新增知识卡片页面及路由支持
- 添加知识卡片 API 接口与 Mock 数据 - 实现知识卡片列表展示与筛选功能 - 实现知识卡片详情抽屉 - 支持路由嵌套配置与菜单展开
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2048d3c140545556010ceb3eea341153
|
2f1b47a26399ec7c847d08cf81f5dc77
|
||||||
38
frontend/src/api/knowledgeCards.ts
Normal file
38
frontend/src/api/knowledgeCards.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { get } from '@/utils/request'
|
||||||
|
|
||||||
|
export type KnowledgeCardType = 'global' | 'product' | 'chat'
|
||||||
|
export type KnowledgeCardAuditStatus = 'pending' | 'approved'
|
||||||
|
|
||||||
|
export interface RelatedProduct {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedChat {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeCard {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
type: KnowledgeCardType
|
||||||
|
auditStatus: KnowledgeCardAuditStatus
|
||||||
|
author: string
|
||||||
|
updatedAt: string
|
||||||
|
coverColor: string
|
||||||
|
tags: string[]
|
||||||
|
relatedProducts: RelatedProduct[]
|
||||||
|
relatedChats: RelatedChat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetKnowledgeCardsParams {
|
||||||
|
type?: KnowledgeCardType
|
||||||
|
auditStatus?: KnowledgeCardAuditStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取知识卡片列表 */
|
||||||
|
export function getKnowledgeCards(params?: GetKnowledgeCardsParams) {
|
||||||
|
return get<KnowledgeCard[]>('/knowledge/cards', params as Record<string, string>)
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Layout, Menu, Button, Popconfirm, Avatar, theme } from 'antd'
|
import { Layout, Menu, Button, Popconfirm, Avatar, theme } from 'antd'
|
||||||
import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
|
import { useNavigate, useLocation, Outlet } from 'react-router-dom'
|
||||||
import { routes } from '@/router/routes'
|
import { routes, type RouteConfig } from '@/router/routes'
|
||||||
import { useAppStore } from '@/store/app'
|
import { useAppStore } from '@/store/app'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
|
|
||||||
const { Sider, Header, Content } = Layout
|
const { Sider, Header, Content } = Layout
|
||||||
|
|
||||||
|
// 递归扁平化所有路由,用于查找当前路径对应的标题
|
||||||
|
function flatRouteLabel(list: RouteConfig[], pathname: string): string {
|
||||||
|
for (const r of list) {
|
||||||
|
if (r.path === pathname) return r.label
|
||||||
|
if (r.children) {
|
||||||
|
const found = flatRouteLabel(r.children, pathname)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const SIDER_WIDTH = 220
|
const SIDER_WIDTH = 220
|
||||||
const SIDER_COLLAPSED_WIDTH = 64
|
const SIDER_COLLAPSED_WIDTH = 64
|
||||||
const HEADER_HEIGHT = 64
|
const HEADER_HEIGHT = 64
|
||||||
@@ -102,6 +114,7 @@ function BasicLayout() {
|
|||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
|
defaultOpenKeys={routes.filter(r => r.children).map(r => r.path)}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
style={{ borderInlineEnd: 'none', paddingTop: 8 }}
|
style={{ borderInlineEnd: 'none', paddingTop: 8 }}
|
||||||
@@ -143,7 +156,7 @@ function BasicLayout() {
|
|||||||
|
|
||||||
{/* 当前页面标题 */}
|
{/* 当前页面标题 */}
|
||||||
<span style={{ fontSize: 16, fontWeight: 600, color: token.colorText }}>
|
<span style={{ fontSize: 16, fontWeight: 600, color: token.colorText }}>
|
||||||
{routes.find(r => r.path === location.pathname)?.label ?? ''}
|
{flatRouteLabel(routes, location.pathname)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 右侧操作区 */}
|
{/* 右侧操作区 */}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { userHandlers } from './handlers/user'
|
import { userHandlers } from './handlers/user'
|
||||||
|
import { knowledgeCardsHandlers } from './handlers/knowledgeCards'
|
||||||
|
|
||||||
/** 所有 mock handlers,按模块聚合 */
|
/** 所有 mock handlers,按模块聚合 */
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
...userHandlers,
|
...userHandlers,
|
||||||
|
...knowledgeCardsHandlers,
|
||||||
]
|
]
|
||||||
|
|||||||
158
frontend/src/mock/handlers/knowledgeCards.ts
Normal file
158
frontend/src/mock/handlers/knowledgeCards.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { http, HttpResponse, delay } from 'msw'
|
||||||
|
import type { KnowledgeCard } from '@/api/knowledgeCards'
|
||||||
|
|
||||||
|
const mockCards: KnowledgeCard[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '如何处理客户退款申请',
|
||||||
|
content:
|
||||||
|
'当客户提出退款申请时,首先需要核实订单信息,确认是否在退款政策范围内。若符合条件,按照标准流程发起退款,并在24小时内完成审核。对于特殊情况,需上报主管审批。',
|
||||||
|
type: 'global',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
author: '张三',
|
||||||
|
updatedAt: '2025-05-20',
|
||||||
|
coverColor: '#e6f4ff',
|
||||||
|
tags: ['退款', '售后', '客服流程'],
|
||||||
|
relatedProducts: [{ id: 101, name: '夏季连衣裙' }, { id: 102, name: '运动鞋' }],
|
||||||
|
relatedChats: [{ id: 201, name: '退款标准话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '新品发布常见问题解答',
|
||||||
|
content:
|
||||||
|
'本文档汇总了新品发布期间客户最常咨询的问题,包括发货时间、库存查询、预售规则等,客服可直接引用回复客户。',
|
||||||
|
type: 'product',
|
||||||
|
auditStatus: 'pending',
|
||||||
|
author: '李四',
|
||||||
|
updatedAt: '2025-05-22',
|
||||||
|
coverColor: '#fff7e6',
|
||||||
|
tags: ['新品', 'FAQ', '预售'],
|
||||||
|
relatedProducts: [{ id: 103, name: '2025夏季新款T恤' }],
|
||||||
|
relatedChats: [{ id: 202, name: '新品咨询话术' }, { id: 203, name: '预售说明' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '节假日活动话术模板',
|
||||||
|
content:
|
||||||
|
'双十一、春节、618等大促节点的标准欢迎语、活动介绍话术,以及常见异议处理方式。使用时请根据当前活动情况替换活动名称和折扣信息。',
|
||||||
|
type: 'chat',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
author: '王五',
|
||||||
|
updatedAt: '2025-05-18',
|
||||||
|
coverColor: '#f6ffed',
|
||||||
|
tags: ['大促', '话术', '双十一', '618'],
|
||||||
|
relatedProducts: [],
|
||||||
|
relatedChats: [{ id: 204, name: '双十一欢迎语' }, { id: 205, name: '618活动说明' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '商品质量问题处理规范',
|
||||||
|
content:
|
||||||
|
'针对客户反馈商品质量问题的完整处理流程:收集问题描述与凭证、判断责任归属、协商解决方案(补寄/退款/换货)、记录归档。',
|
||||||
|
type: 'product',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
author: '赵六',
|
||||||
|
updatedAt: '2025-05-15',
|
||||||
|
coverColor: '#fff0f6',
|
||||||
|
tags: ['质量问题', '换货', '售后'],
|
||||||
|
relatedProducts: [{ id: 104, name: '皮质背包' }, { id: 105, name: '陶瓷餐具套装' }],
|
||||||
|
relatedChats: [{ id: 206, name: '质量投诉处理话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '客户服务礼貌用语规范',
|
||||||
|
content:
|
||||||
|
'统一客服沟通风格,包括:开场白、道歉用语、感谢用语、告别语等,禁止使用的不当用语列表,以及如何在情绪激动的客户面前保持专业态度。',
|
||||||
|
type: 'chat',
|
||||||
|
auditStatus: 'pending',
|
||||||
|
author: '孙七',
|
||||||
|
updatedAt: '2025-05-23',
|
||||||
|
coverColor: '#e6f4ff',
|
||||||
|
tags: ['礼貌用语', '规范', '沟通技巧'],
|
||||||
|
relatedProducts: [],
|
||||||
|
relatedChats: [{ id: 207, name: '通用开场白' }, { id: 208, name: '道歉话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '快递异常处理指南',
|
||||||
|
content:
|
||||||
|
'物流延误、快递丢失、破损到货等场景的处理方式。包括如何查询快递信息、与快递公司协商、赔偿标准,以及如何安抚客户情绪。',
|
||||||
|
type: 'global',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
author: '周八',
|
||||||
|
updatedAt: '2025-05-10',
|
||||||
|
coverColor: '#f9f0ff',
|
||||||
|
tags: ['物流', '快递异常', '赔偿'],
|
||||||
|
relatedProducts: [],
|
||||||
|
relatedChats: [{ id: 209, name: '物流异常安抚话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: '会员积分兑换规则说明',
|
||||||
|
content:
|
||||||
|
'平台会员积分的获取方式(消费、签到、邀请好友)、兑换比例、有效期及使用限制。常见积分问题的排查和处理方式。',
|
||||||
|
type: 'global',
|
||||||
|
auditStatus: 'pending',
|
||||||
|
author: '吴九',
|
||||||
|
updatedAt: '2025-05-21',
|
||||||
|
coverColor: '#fff7e6',
|
||||||
|
tags: ['会员', '积分', '兑换'],
|
||||||
|
relatedProducts: [],
|
||||||
|
relatedChats: [{ id: 210, name: '积分查询话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: '爆款商品卖点话术',
|
||||||
|
content:
|
||||||
|
'本季度热销TOP10商品的核心卖点提炼、差异化优势描述和场景化推荐话术,帮助客服快速向客户介绍商品价值并促成购买决策。',
|
||||||
|
type: 'product',
|
||||||
|
auditStatus: 'approved',
|
||||||
|
author: '郑十',
|
||||||
|
updatedAt: '2025-05-17',
|
||||||
|
coverColor: '#f6ffed',
|
||||||
|
tags: ['爆款', '卖点', '推荐话术'],
|
||||||
|
relatedProducts: [
|
||||||
|
{ id: 106, name: '无线蓝牙耳机' },
|
||||||
|
{ id: 107, name: '智能手表' },
|
||||||
|
{ id: 108, name: '颈部按摩仪' },
|
||||||
|
],
|
||||||
|
relatedChats: [{ id: 211, name: '爆款推荐话术' }, { id: 212, name: '商品对比话术' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: '投诉升级处理流程',
|
||||||
|
content:
|
||||||
|
'当客户投诉进入升级状态(如威胁投诉平台、媒体曝光)时的处置预案:评估风险等级、上报节点、授权范围,以及事后复盘和改进记录要求。',
|
||||||
|
type: 'global',
|
||||||
|
auditStatus: 'pending',
|
||||||
|
author: '张三',
|
||||||
|
updatedAt: '2025-05-24',
|
||||||
|
coverColor: '#fff0f6',
|
||||||
|
tags: ['投诉', '升级处理', '风险'],
|
||||||
|
relatedProducts: [],
|
||||||
|
relatedChats: [{ id: 213, name: '升级投诉安抚话术' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const knowledgeCardsHandlers = [
|
||||||
|
http.get('/knowledge/cards', async ({ request }) => {
|
||||||
|
await delay(300)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const type = url.searchParams.get('type')
|
||||||
|
const auditStatus = url.searchParams.get('auditStatus')
|
||||||
|
|
||||||
|
const filtered = mockCards.filter(card => {
|
||||||
|
const typeMatch = !type || card.type === type
|
||||||
|
const auditMatch = !auditStatus || card.auditStatus === auditStatus
|
||||||
|
return typeMatch && auditMatch
|
||||||
|
})
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
code: '0',
|
||||||
|
msg: 'ok',
|
||||||
|
time: Date.now(),
|
||||||
|
ok: true,
|
||||||
|
data: filtered,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
]
|
||||||
563
frontend/src/pages/knowledgeBase/cards/index.tsx
Normal file
563
frontend/src/pages/knowledgeBase/cards/index.tsx
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
Segmented,
|
||||||
|
Flex,
|
||||||
|
Avatar,
|
||||||
|
theme,
|
||||||
|
Drawer,
|
||||||
|
Divider,
|
||||||
|
Spin,
|
||||||
|
Tooltip,
|
||||||
|
message,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
ShoppingOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
ShoppingCartOutlined,
|
||||||
|
CommentOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
getKnowledgeCards,
|
||||||
|
type KnowledgeCard,
|
||||||
|
type KnowledgeCardType,
|
||||||
|
type KnowledgeCardAuditStatus,
|
||||||
|
} from '@/api/knowledgeCards'
|
||||||
|
|
||||||
|
const { Text, Paragraph, Title } = Typography
|
||||||
|
|
||||||
|
type AuditFilter = 'all' | KnowledgeCardAuditStatus
|
||||||
|
type TypeFilter = 'all' | KnowledgeCardType
|
||||||
|
|
||||||
|
const typeConfig = {
|
||||||
|
global: { label: '全域知识', icon: <GlobalOutlined />, color: 'blue' },
|
||||||
|
product: { label: '商品知识', icon: <ShoppingOutlined />, color: 'orange' },
|
||||||
|
chat: { label: '聊天知识', icon: <MessageOutlined />, color: 'green' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditConfig = {
|
||||||
|
pending: { label: '未审核', icon: <ClockCircleOutlined />, color: 'warning' as const },
|
||||||
|
approved: { label: '已审核', icon: <CheckCircleOutlined />, color: 'success' as const },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片固定尺寸
|
||||||
|
const CARD_WIDTH = 280
|
||||||
|
const CARD_HEIGHT = 220
|
||||||
|
|
||||||
|
function KnowledgeCardItem({
|
||||||
|
card,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
card: KnowledgeCard
|
||||||
|
onClick: (card: KnowledgeCard) => void
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
const type = typeConfig[card.type]
|
||||||
|
const audit = auditConfig[card.auditStatus]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
onClick={() => onClick(card)}
|
||||||
|
style={{
|
||||||
|
borderRadius: token.borderRadiusLG,
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: CARD_WIDTH,
|
||||||
|
height: CARD_HEIGHT,
|
||||||
|
cursor: 'pointer',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
styles={{ body: { padding: 0, height: '100%' } }}
|
||||||
|
>
|
||||||
|
{/* 顶部色块装饰 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
background:
|
||||||
|
card.type === 'global'
|
||||||
|
? token.colorPrimary
|
||||||
|
: card.type === 'product'
|
||||||
|
? token.colorWarning
|
||||||
|
: token.colorSuccess,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: token.paddingMD,
|
||||||
|
height: `calc(100% - 6px)`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题:最多1行,超出省略 */}
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
ellipsis
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeLG,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: token.marginXS,
|
||||||
|
color: token.colorText,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 正文:固定行数,超出省略,不可展开 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: token.marginSM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paragraph
|
||||||
|
type="secondary"
|
||||||
|
ellipsis={{ rows: 3, expandable: false }}
|
||||||
|
style={{
|
||||||
|
marginBottom: 0,
|
||||||
|
fontSize: token.fontSize,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.content}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部作者信息 */}
|
||||||
|
<Flex align="center" justify="space-between" style={{ flexShrink: 0, marginBottom: 6 }}>
|
||||||
|
<Space size={4}>
|
||||||
|
<Avatar
|
||||||
|
size={20}
|
||||||
|
style={{
|
||||||
|
backgroundColor: token.colorPrimary,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.author[0]}
|
||||||
|
</Avatar>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{card.author} · {card.updatedAt}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 标签行 */}
|
||||||
|
<Flex gap={6} style={{ flexShrink: 0 }} wrap>
|
||||||
|
<Tag
|
||||||
|
icon={audit.icon}
|
||||||
|
color={audit.color}
|
||||||
|
style={{ margin: 0, borderRadius: token.borderRadiusSM }}
|
||||||
|
>
|
||||||
|
{audit.label}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
icon={type.icon}
|
||||||
|
color={type.color}
|
||||||
|
style={{ margin: 0, borderRadius: token.borderRadiusSM }}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeCardDetail({
|
||||||
|
card,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
card: KnowledgeCard | null
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
const [messageApi, contextHolder] = message.useMessage()
|
||||||
|
|
||||||
|
if (!card) return null
|
||||||
|
|
||||||
|
const type = typeConfig[card.type]
|
||||||
|
const audit = auditConfig[card.auditStatus]
|
||||||
|
const topBarColor =
|
||||||
|
card.type === 'global'
|
||||||
|
? token.colorPrimary
|
||||||
|
: card.type === 'product'
|
||||||
|
? token.colorWarning
|
||||||
|
: token.colorSuccess
|
||||||
|
|
||||||
|
const handleCopyId = () => {
|
||||||
|
navigator.clipboard.writeText(String(card.id)).then(() => {
|
||||||
|
messageApi.success('ID 已复制')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarBg = token.colorFillQuaternary
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={null}
|
||||||
|
placement="right"
|
||||||
|
width={760}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
styles={{
|
||||||
|
header: { display: 'none' },
|
||||||
|
body: { padding: 0, display: 'flex', flexDirection: 'column' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
{/* 顶部色块 */}
|
||||||
|
<div style={{ height: 6, background: topBarColor, flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{/* 左右两栏 */}
|
||||||
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
|
{/* 左侧:主内容 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: token.paddingLG,
|
||||||
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题区 */}
|
||||||
|
<Title level={4} style={{ marginTop: 0, marginBottom: token.marginSM }}>
|
||||||
|
{card.title}
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* 状态标签 */}
|
||||||
|
<Flex gap={8} style={{ marginBottom: token.marginMD }}>
|
||||||
|
<Tag
|
||||||
|
icon={audit.icon}
|
||||||
|
color={audit.color}
|
||||||
|
style={{ borderRadius: token.borderRadiusSM }}
|
||||||
|
>
|
||||||
|
{audit.label}
|
||||||
|
</Tag>
|
||||||
|
<Tag
|
||||||
|
icon={type.icon}
|
||||||
|
color={type.color}
|
||||||
|
style={{ borderRadius: token.borderRadiusSM }}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Tag>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Divider style={{ marginTop: 0, marginBottom: token.marginMD }} />
|
||||||
|
|
||||||
|
{/* 元信息 */}
|
||||||
|
<Flex gap={token.marginLG} style={{ marginBottom: token.marginLG }}>
|
||||||
|
<Space size={6}>
|
||||||
|
<UserOutlined style={{ color: token.colorTextSecondary }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{card.author}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
<Space size={6}>
|
||||||
|
<CalendarOutlined style={{ color: token.colorTextSecondary }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
{card.updatedAt}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 正文完整内容 */}
|
||||||
|
<Paragraph
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSize,
|
||||||
|
lineHeight: 1.8,
|
||||||
|
color: token.colorText,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{card.content}
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:属性面板 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'auto',
|
||||||
|
background: sidebarBg,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 基础信息 */}
|
||||||
|
<div style={{ padding: `${token.paddingMD}px ${token.paddingMD}px ${token.paddingSM}px` }}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: token.marginSM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
基础信息
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* ID 可复制 */}
|
||||||
|
<div style={{ marginBottom: token.marginSM }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM, display: 'block', marginBottom: 4 }}>
|
||||||
|
ID
|
||||||
|
</Text>
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
gap={6}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
padding: `4px ${token.paddingSM}px`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={handleCopyId}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: token.fontSizeSM, flex: 1, fontFamily: 'monospace' }}>
|
||||||
|
{card.id}
|
||||||
|
</Text>
|
||||||
|
<Tooltip title="复制 ID">
|
||||||
|
<CopyOutlined style={{ fontSize: 12, color: token.colorTextSecondary }} />
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 多选标签 */}
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM, display: 'block', marginBottom: 4 }}>
|
||||||
|
标签
|
||||||
|
</Text>
|
||||||
|
{card.tags.length > 0 ? (
|
||||||
|
<Flex gap={4} wrap>
|
||||||
|
{card.tags.map(tag => (
|
||||||
|
<Tag
|
||||||
|
key={tag}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
暂无标签
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: `${token.marginXS}px 0` }} />
|
||||||
|
|
||||||
|
{/* 扩展信息 */}
|
||||||
|
<div style={{ padding: `${token.paddingSM}px ${token.paddingMD}px ${token.paddingMD}px` }}>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{
|
||||||
|
fontSize: token.fontSizeSM,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: token.marginSM,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
扩展信息
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 关联商品 */}
|
||||||
|
<div style={{ marginBottom: token.marginMD }}>
|
||||||
|
<Space size={4} style={{ marginBottom: 6 }}>
|
||||||
|
<ShoppingCartOutlined style={{ fontSize: token.fontSizeSM, color: token.colorTextSecondary }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
关联商品
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{card.relatedProducts.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{card.relatedProducts.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
padding: `4px ${token.paddingSM}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: token.fontSizeSM }} ellipsis>
|
||||||
|
{p.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
暂无关联商品
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关联聊天 */}
|
||||||
|
<div>
|
||||||
|
<Space size={4} style={{ marginBottom: 6 }}>
|
||||||
|
<CommentOutlined style={{ fontSize: token.fontSizeSM, color: token.colorTextSecondary }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
关联聊天
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
{card.relatedChats.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{card.relatedChats.map(c => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
style={{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
padding: `4px ${token.paddingSM}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: token.fontSizeSM }} ellipsis>
|
||||||
|
{c.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: token.fontSizeSM }}>
|
||||||
|
暂无关联聊天
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KnowledgeCards() {
|
||||||
|
const { token } = theme.useToken()
|
||||||
|
const [auditFilter, setAuditFilter] = useState<AuditFilter>('all')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all')
|
||||||
|
const [cards, setCards] = useState<KnowledgeCard[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selectedCard, setSelectedCard] = useState<KnowledgeCard | null>(null)
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCards = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (auditFilter !== 'all') params.auditStatus = auditFilter
|
||||||
|
if (typeFilter !== 'all') params.type = typeFilter
|
||||||
|
|
||||||
|
const res = await getKnowledgeCards(
|
||||||
|
params as Parameters<typeof getKnowledgeCards>[0],
|
||||||
|
)
|
||||||
|
if (res.ok && res.data) {
|
||||||
|
setCards(res.data)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCards()
|
||||||
|
}, [auditFilter, typeFilter])
|
||||||
|
|
||||||
|
const handleCardClick = (card: KnowledgeCard) => {
|
||||||
|
setSelectedCard(card)
|
||||||
|
setDrawerOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrawerClose = () => {
|
||||||
|
setDrawerOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: token.paddingLG }}>
|
||||||
|
{/* 筛选栏 */}
|
||||||
|
<Flex gap={token.marginMD} wrap style={{ marginBottom: token.marginLG }}>
|
||||||
|
<Segmented
|
||||||
|
value={auditFilter}
|
||||||
|
onChange={v => setAuditFilter(v as AuditFilter)}
|
||||||
|
options={[
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '未审核', value: 'pending' },
|
||||||
|
{ label: '已审核', value: 'approved' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Segmented
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={v => setTypeFilter(v as TypeFilter)}
|
||||||
|
options={[
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '全域知识', value: 'global' },
|
||||||
|
{ label: '商品知识', value: 'product' },
|
||||||
|
{ label: '聊天知识', value: 'chat' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 固定大小卡片网格 */}
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{cards.length > 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: token.marginMD,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cards.map(card => (
|
||||||
|
<KnowledgeCardItem key={card.id} card={card} onClick={handleCardClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
!loading && (
|
||||||
|
<Flex
|
||||||
|
justify="center"
|
||||||
|
align="center"
|
||||||
|
style={{ height: 300, color: token.colorTextQuaternary, fontSize: token.fontSizeLG }}
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
{/* 详情抽屉 */}
|
||||||
|
<KnowledgeCardDetail
|
||||||
|
card={selectedCard}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={handleDrawerClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KnowledgeCards
|
||||||
@@ -1,17 +1,24 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
import { createBrowserRouter } from 'react-router-dom'
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
import BasicLayout from '@/layouts/BasicLayout'
|
import BasicLayout from '@/layouts/BasicLayout'
|
||||||
import LoginPage from '@/pages/login'
|
import LoginPage from '@/pages/login'
|
||||||
import NotFound from '@/pages/notFound'
|
import NotFound from '@/pages/notFound'
|
||||||
import { AuthGuard, GuestGuard } from './AuthGuard'
|
import { AuthGuard, GuestGuard } from './AuthGuard'
|
||||||
import { routes } from './routes'
|
import { routes, type RouteConfig } from './routes'
|
||||||
|
|
||||||
const layoutChildren = routes
|
// 递归展开所有路由(包含子路由),扁平化注册到 layoutChildren
|
||||||
.filter(r => r.path !== '*')
|
function flattenRoutes(list: RouteConfig[]): { path: string; element: ReactNode }[] {
|
||||||
.map(({ path, element }) => ({
|
return list.flatMap(r => {
|
||||||
path,
|
if (r.path === '*') return []
|
||||||
element,
|
const self = r.path === '/'
|
||||||
...(path === '/' ? { index: true, path: undefined } : {}),
|
? [{ path: r.path, element: r.element, index: true }]
|
||||||
}))
|
: [{ path: r.path, element: r.element }]
|
||||||
|
const children = r.children ? flattenRoutes(r.children) : []
|
||||||
|
return [...self, ...children] as { path: string; element: ReactNode }[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutChildren = flattenRoutes(routes)
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { HomeOutlined } from '@ant-design/icons'
|
import { HomeOutlined, BookOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||||
import Home from '@/pages/home'
|
import Home from '@/pages/home'
|
||||||
import NotFound from '@/pages/notFound'
|
import NotFound from '@/pages/notFound'
|
||||||
|
import KnowledgeCards from '@/pages/knowledgeBase/cards'
|
||||||
|
|
||||||
export interface RouteConfig {
|
export interface RouteConfig {
|
||||||
/** 路由路径 */
|
/** 路由路径 */
|
||||||
@@ -25,6 +26,20 @@ export const routes: RouteConfig[] = [
|
|||||||
icon: <HomeOutlined />,
|
icon: <HomeOutlined />,
|
||||||
element: <Home />,
|
element: <Home />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/knowledge-base',
|
||||||
|
label: '知识库',
|
||||||
|
icon: <BookOutlined />,
|
||||||
|
element: <></>,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/knowledge-base/cards',
|
||||||
|
label: '知识卡片',
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
element: <KnowledgeCards />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
label: '页面不存在',
|
label: '页面不存在',
|
||||||
|
|||||||
Reference in New Issue
Block a user