diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index f1592a1..1e812d3 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -2048d3c140545556010ceb3eea341153 \ No newline at end of file +2f1b47a26399ec7c847d08cf81f5dc77 \ No newline at end of file diff --git a/frontend/src/api/knowledgeCards.ts b/frontend/src/api/knowledgeCards.ts new file mode 100644 index 0000000..d136954 --- /dev/null +++ b/frontend/src/api/knowledgeCards.ts @@ -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('/knowledge/cards', params as Record) +} diff --git a/frontend/src/layouts/BasicLayout/index.tsx b/frontend/src/layouts/BasicLayout/index.tsx index 1ac0644..18afce2 100644 --- a/frontend/src/layouts/BasicLayout/index.tsx +++ b/frontend/src/layouts/BasicLayout/index.tsx @@ -1,13 +1,25 @@ import { Layout, Menu, Button, Popconfirm, Avatar, theme } from 'antd' import { MenuFoldOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons' 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 { useUserStore } from '@/store/user' import type { MenuProps } from 'antd' 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_COLLAPSED_WIDTH = 64 const HEADER_HEIGHT = 64 @@ -102,6 +114,7 @@ function BasicLayout() { r.children).map(r => r.path)} items={menuItems} onClick={handleMenuClick} style={{ borderInlineEnd: 'none', paddingTop: 8 }} @@ -143,7 +156,7 @@ function BasicLayout() { {/* 当前页面标题 */} - {routes.find(r => r.path === location.pathname)?.label ?? ''} + {flatRouteLabel(routes, location.pathname)} {/* 右侧操作区 */} diff --git a/frontend/src/mock/handlers.ts b/frontend/src/mock/handlers.ts index 901555b..2724e5c 100644 --- a/frontend/src/mock/handlers.ts +++ b/frontend/src/mock/handlers.ts @@ -1,6 +1,8 @@ import { userHandlers } from './handlers/user' +import { knowledgeCardsHandlers } from './handlers/knowledgeCards' /** 所有 mock handlers,按模块聚合 */ export const handlers = [ ...userHandlers, + ...knowledgeCardsHandlers, ] diff --git a/frontend/src/mock/handlers/knowledgeCards.ts b/frontend/src/mock/handlers/knowledgeCards.ts new file mode 100644 index 0000000..cb4f4b8 --- /dev/null +++ b/frontend/src/mock/handlers/knowledgeCards.ts @@ -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, + }) + }), +] diff --git a/frontend/src/pages/knowledgeBase/cards/index.tsx b/frontend/src/pages/knowledgeBase/cards/index.tsx new file mode 100644 index 0000000..94009b6 --- /dev/null +++ b/frontend/src/pages/knowledgeBase/cards/index.tsx @@ -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: , color: 'blue' }, + product: { label: '商品知识', icon: , color: 'orange' }, + chat: { label: '聊天知识', icon: , color: 'green' }, +} + +const auditConfig = { + pending: { label: '未审核', icon: , color: 'warning' as const }, + approved: { label: '已审核', icon: , 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 ( + onClick(card)} + style={{ + borderRadius: token.borderRadiusLG, + overflow: 'hidden', + width: CARD_WIDTH, + height: CARD_HEIGHT, + cursor: 'pointer', + flexShrink: 0, + }} + styles={{ body: { padding: 0, height: '100%' } }} + > + {/* 顶部色块装饰 */} +
+ +
+ {/* 标题:最多1行,超出省略 */} + + {card.title} + + + {/* 正文:固定行数,超出省略,不可展开 */} +
+ + {card.content} + +
+ + {/* 底部作者信息 */} + + + + {card.author[0]} + + + {card.author} · {card.updatedAt} + + + + + {/* 标签行 */} + + + {audit.label} + + + {type.label} + + +
+ + ) +} + +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 ( + + {contextHolder} + {/* 顶部色块 */} +
+ + {/* 左右两栏 */} +
+ {/* 左侧:主内容 */} +
+ {/* 标题区 */} + + {card.title} + + + {/* 状态标签 */} + + + {audit.label} + + + {type.label} + + + + + + {/* 元信息 */} + + + + + {card.author} + + + + + + {card.updatedAt} + + + + + {/* 正文完整内容 */} + + {card.content} + +
+ + {/* 右侧:属性面板 */} +
+ {/* 基础信息 */} +
+ + 基础信息 + + + {/* ID 可复制 */} +
+ + ID + + + + {card.id} + + + + + +
+ + {/* 多选标签 */} +
+ + 标签 + + {card.tags.length > 0 ? ( + + {card.tags.map(tag => ( + + {tag} + + ))} + + ) : ( + + 暂无标签 + + )} +
+
+ + + + {/* 扩展信息 */} +
+ + 扩展信息 + + + {/* 关联商品 */} +
+ + + + 关联商品 + + + {card.relatedProducts.length > 0 ? ( +
+ {card.relatedProducts.map(p => ( +
+ + {p.name} + +
+ ))} +
+ ) : ( + + 暂无关联商品 + + )} +
+ + {/* 关联聊天 */} +
+ + + + 关联聊天 + + + {card.relatedChats.length > 0 ? ( +
+ {card.relatedChats.map(c => ( +
+ + {c.name} + +
+ ))} +
+ ) : ( + + 暂无关联聊天 + + )} +
+
+
+
+ + ) +} + +function KnowledgeCards() { + const { token } = theme.useToken() + const [auditFilter, setAuditFilter] = useState('all') + const [typeFilter, setTypeFilter] = useState('all') + const [cards, setCards] = useState([]) + const [loading, setLoading] = useState(false) + const [selectedCard, setSelectedCard] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) + + useEffect(() => { + const fetchCards = async () => { + setLoading(true) + const params: Record = {} + if (auditFilter !== 'all') params.auditStatus = auditFilter + if (typeFilter !== 'all') params.type = typeFilter + + const res = await getKnowledgeCards( + params as Parameters[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 ( +
+ {/* 筛选栏 */} + + setAuditFilter(v as AuditFilter)} + options={[ + { label: '全部', value: 'all' }, + { label: '未审核', value: 'pending' }, + { label: '已审核', value: 'approved' }, + ]} + /> + setTypeFilter(v as TypeFilter)} + options={[ + { label: '全部', value: 'all' }, + { label: '全域知识', value: 'global' }, + { label: '商品知识', value: 'product' }, + { label: '聊天知识', value: 'chat' }, + ]} + /> + + + {/* 固定大小卡片网格 */} + + {cards.length > 0 ? ( +
+ {cards.map(card => ( + + ))} +
+ ) : ( + !loading && ( + + 暂无数据 + + ) + )} +
+ + {/* 详情抽屉 */} + +
+ ) +} + +export default KnowledgeCards diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 8163388..0cc30cf 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,17 +1,24 @@ +import type { ReactNode } from 'react' import { createBrowserRouter } from 'react-router-dom' import BasicLayout from '@/layouts/BasicLayout' import LoginPage from '@/pages/login' import NotFound from '@/pages/notFound' import { AuthGuard, GuestGuard } from './AuthGuard' -import { routes } from './routes' +import { routes, type RouteConfig } from './routes' -const layoutChildren = routes - .filter(r => r.path !== '*') - .map(({ path, element }) => ({ - path, - element, - ...(path === '/' ? { index: true, path: undefined } : {}), - })) +// 递归展开所有路由(包含子路由),扁平化注册到 layoutChildren +function flattenRoutes(list: RouteConfig[]): { path: string; element: ReactNode }[] { + return list.flatMap(r => { + if (r.path === '*') return [] + const self = r.path === '/' + ? [{ 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([ { diff --git a/frontend/src/router/routes.tsx b/frontend/src/router/routes.tsx index ba6d1ab..004406f 100644 --- a/frontend/src/router/routes.tsx +++ b/frontend/src/router/routes.tsx @@ -1,7 +1,8 @@ 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 NotFound from '@/pages/notFound' +import KnowledgeCards from '@/pages/knowledgeBase/cards' export interface RouteConfig { /** 路由路径 */ @@ -25,6 +26,20 @@ export const routes: RouteConfig[] = [ icon: , element: , }, + { + path: '/knowledge-base', + label: '知识库', + icon: , + element: <>, + children: [ + { + path: '/knowledge-base/cards', + label: '知识卡片', + icon: , + element: , + }, + ], + }, { path: '*', label: '页面不存在',