diff --git a/server/internal/handler/knowledge.go b/server/internal/handler/knowledge.go index 3eaf75d..32c0f42 100644 --- a/server/internal/handler/knowledge.go +++ b/server/internal/handler/knowledge.go @@ -42,13 +42,40 @@ func hasKnowledgeCapacity(tenantID uint) (bool, error) { return count < int64(plan.KBLimit), nil } +type CategoryListItem struct { + model.Category + EntryCount int64 `json:"entry_count"` +} + func (h *KnowledgeHandler) ListCategories(c *gin.Context) { tenantID := middleware.GetTenantID(c) var categories []model.Category - model.DB.Where("tenant_id = ?", tenantID).Find(&categories) + model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&categories) - middleware.JSON(c, categories) + type countRow struct { + CategoryID uint + Cnt int64 + } + var rows []countRow + model.DB.Model(&model.KnowledgeEntry{}). + Select("category_id, COUNT(*) as cnt"). + Where("tenant_id = ?", tenantID). + Group("category_id"). + Scan(&rows) + countMap := map[uint]int64{} + var total int64 + for _, r := range rows { + countMap[r.CategoryID] = r.Cnt + total += r.Cnt + } + + items := make([]CategoryListItem, 0, len(categories)) + for _, cat := range categories { + items = append(items, CategoryListItem{Category: cat, EntryCount: countMap[cat.ID]}) + } + // total 放在额外字段便于侧栏「全部知识库」 + middleware.JSON(c, gin.H{"list": items, "total_entries": total}) } func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { @@ -70,12 +97,18 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) { middleware.JSON(c, category) } +type EntryListItem struct { + model.KnowledgeEntry + CategoryName string `json:"category_name"` +} + func (h *KnowledgeHandler) ListEntries(c *gin.Context) { tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) categoryID := c.Query("category_id") search := c.Query("search") status := c.Query("status") + sortBy := c.Query("sort") // usage | updated(default) var entries []model.KnowledgeEntry var total int64 @@ -92,9 +125,45 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) { } query.Model(&model.KnowledgeEntry{}).Count(&total) - query.Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries) + order := "updated_at desc" + if sortBy == "usage" { + order = "usage_count desc, updated_at desc" + } else if sortBy == "title" { + order = "title asc" + } + query.Order(order).Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries) - middleware.JSONList(c, entries, total, page, pageSize) + catIDs := make([]uint, 0, len(entries)) + for _, e := range entries { + catIDs = append(catIDs, e.CategoryID) + } + catMap := map[uint]string{} + if len(catIDs) > 0 { + seen := map[uint]struct{}{} + unique := make([]uint, 0) + for _, id := range catIDs { + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + unique = append(unique, id) + } + var cats []model.Category + model.DB.Where("id IN ?", unique).Find(&cats) + for _, cat := range cats { + catMap[cat.ID] = cat.Name + } + } + + items := make([]EntryListItem, 0, len(entries)) + for _, e := range entries { + items = append(items, EntryListItem{ + KnowledgeEntry: e, + CategoryName: catMap[e.CategoryID], + }) + } + + middleware.JSONList(c, items, total, page, pageSize) } func (h *KnowledgeHandler) CreateEntry(c *gin.Context) { diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx index 06a4ab7..37d08b6 100644 --- a/web/src/pages/agent/Knowledge.tsx +++ b/web/src/pages/agent/Knowledge.tsx @@ -1,36 +1,72 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { - Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress, - message, Popconfirm, Space, + Input, Button, Modal, Form, Select, Empty, message, Popconfirm, Pagination, Spin, Drawer, } from 'antd' import { - SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined, DeleteOutlined, FolderAddOutlined, + SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FolderAddOutlined, + CopyOutlined, FileTextOutlined, AppstoreOutlined, ThunderboltOutlined, } from '@ant-design/icons' import { createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry, getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry, - type KnowledgeEntry, + type KnowledgeCategory, type KnowledgeEntry, } from '@/services/api' import { useAuth } from '@/stores/auth' +const statusStyle = { + published: { label: '已发布', bg: '#f0fdf4', color: '#16a34a', dot: '#16a34a' }, + draft: { label: '草稿', bg: '#f1f5f9', color: '#64748b', dot: '#94a3b8' }, +} + +const catColors = [ + { bg: '#eff6ff', color: '#2563eb' }, + { bg: '#fffbeb', color: '#d97706' }, + { bg: '#f0fdf4', color: '#16a34a' }, + { bg: '#ecfeff', color: '#0891b2' }, + { bg: '#f3e8ff', color: '#7c3aed' }, + { bg: '#fef2f2', color: '#dc2626' }, +] + +function catColor(id: number) { + return catColors[Math.abs(id) % catColors.length] +} + +function previewText(content: string, max = 60) { + const t = (content || '').replace(/\s+/g, ' ').trim() + if (t.length <= max) return t + return `${t.slice(0, max)}…` +} + +function formatUsage(n: number) { + if (n >= 10000) return `${(n / 10000).toFixed(1)}万` + if (n >= 1000) return n.toLocaleString('zh-CN') + return String(n) +} + const Knowledge = () => { const { user } = useAuth() const canManage = user?.role === 'admin' || user?.role === 'supervisor' - const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([]) + const [categories, setCategories] = useState([]) + const [totalEntries, setTotalEntries] = useState(0) const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [selectedCategory, setSelectedCategory] = useState('') + const [catSearch, setCatSearch] = useState('') const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') const [statusFilter, setStatusFilter] = useState() + const [sortBy, setSortBy] = useState<'usage' | 'updated' | 'title'>('usage') const [modalOpen, setModalOpen] = useState(false) const [categoryModalOpen, setCategoryModalOpen] = useState(false) const [editingEntry, setEditingEntry] = useState(null) + const [detailEntry, setDetailEntry] = useState(null) const [saving, setSaving] = useState(false) const [form] = Form.useForm() const [categoryForm] = Form.useForm() const [total, setTotal] = useState(0) const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) useEffect(() => { loadCategories() @@ -38,15 +74,18 @@ const Knowledge = () => { useEffect(() => { loadEntries() - }, [selectedCategory, search, page, statusFilter]) + }, [selectedCategory, search, page, pageSize, statusFilter, sortBy]) const loadCategories = async () => { try { const res = await getKnowledgeCategories() - const list = Array.isArray(res.data) ? res.data : [] - setCategories(list.map(c => ({ key: String(c.id), title: c.name, id: c.id }))) + const data = res.data + const list = Array.isArray(data?.list) ? data.list : [] + setCategories(list) + setTotalEntries(data?.total_entries ?? list.reduce((s, c) => s + (c.entry_count || 0), 0)) } catch { setCategories([]) + setTotalEntries(0) } } @@ -57,18 +96,33 @@ const Knowledge = () => { category_id: selectedCategory, search, status: statusFilter, + sort: sortBy, page, - pageSize: 10, + pageSize, }) - setEntries(res.list) - setTotal(res.total) + setEntries(res.list || []) + setTotal(res.total || 0) } catch { setEntries([]) + setTotal(0) } finally { setLoading(false) } } + const filteredCategories = useMemo(() => { + const q = catSearch.trim().toLowerCase() + if (!q) return categories + return categories.filter(c => c.name.toLowerCase().includes(q)) + }, [categories, catSearch]) + + const maxUsage = useMemo( + () => Math.max(1, ...entries.map(e => e.usage_count || 0), 10), + [entries], + ) + + const categoryName = (id: number) => categories.find(c => c.id === id)?.name || '未分类' + const openCreate = () => { setEditingEntry(null) form.resetFields() @@ -101,7 +155,7 @@ const Knowledge = () => { message.success('条目已创建') } setModalOpen(false) - await loadEntries() + await Promise.all([loadEntries(), loadCategories()]) } catch (e) { message.error(e instanceof Error ? e.message : '保存失败') } finally { @@ -113,12 +167,22 @@ const Knowledge = () => { try { await deleteKnowledgeEntry(id) message.success('已删除') - await loadEntries() + if (detailEntry?.id === id) setDetailEntry(null) + await Promise.all([loadEntries(), loadCategories()]) } catch (e) { message.error(e instanceof Error ? e.message : '删除失败') } } + const handleCopy = async (entry: KnowledgeEntry) => { + try { + await navigator.clipboard.writeText(entry.content) + message.success('内容已复制') + } catch { + message.error('复制失败') + } + } + const handleCreateCategory = async (values: { name: string }) => { try { await createKnowledgeCategory({ name: values.name.trim() }) @@ -131,111 +195,333 @@ const Knowledge = () => { } } - const columns = [ - { - title: '标题', dataIndex: 'title', key: 'title', - render: (t: string) => {t}, - }, - { - title: '状态', dataIndex: 'status', key: 'status', width: 90, - render: (s: string) => {s === 'published' ? '已发布' : '草稿'}, - }, - { - title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 180, - render: (c: number) => ( -
- - {c}次 -
- ), - }, - { - title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120, - render: (t: string) => {t ? new Date(t).toLocaleDateString() : '—'}, - }, - { - title: '操作', key: 'actions', width: 140, - render: (_: unknown, record: KnowledgeEntry) => canManage ? ( - - - handleDelete(record.id)}> - - - - ) : 只读, - }, - ] + const handleSearch = () => { + setPage(1) + setSearch(searchInput.trim()) + } + + const catIcon = (name: string) => { + if (name.includes('快捷')) return + if (name.includes('政策') || name.includes('条款')) return + return + } return ( -
-
-
-
- - 知识分类 +
+ {/* 左侧分类 280px */} +
-
-
-
-

知识库

- } - placeholder="搜索标题或内容" - value={search} - onChange={e => { setSearch(e.target.value); setPage(1) }} - className="w-48" - size="small" - allowClear + {canManage && ( +
+ +
+ )} + + + {/* 右侧列表 */} +
+ {/* 工具栏 */} +
+
+ + setSearchInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleSearch() }} + className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400" /> +
+
+ 状态: { setSortBy(v); setPage(1) }} + className="!w-[120px]" + size="small" + options={[ + { value: 'usage', label: '按使用次数' }, + { value: 'updated', label: '按更新时间' }, + { value: 'title', label: '按标题' }, + ]} + /> +
+
+ 共 {total} 条 {canManage && ( - + )}
-
- `共 ${t} 条`, onChange: p => setPage(p) }} - locale={{ emptyText: }} - /> + + {/* 表头 */} +
+
标题
+
分类
+
状态
+
使用频率
+
更新时间
+
操作
- + + {/* 条目列表 */} +
+ {loading ? ( +
+ ) : entries.length === 0 ? ( + + ) : ( + entries.map(entry => { + const st = statusStyle[entry.status as keyof typeof statusStyle] || statusStyle.draft + const cc = catColor(entry.category_id) + const name = entry.category_name || categoryName(entry.category_id) + const barPct = Math.min(100, Math.round(((entry.usage_count || 0) / maxUsage) * 100)) + return ( +
setDetailEntry(entry)} + onKeyDown={e => { if (e.key === 'Enter') setDetailEntry(entry) }} + className="flex items-center px-6 py-3 border-b border-neutral-100 hover:bg-neutral-50 cursor-pointer transition-colors" + > +
+
{entry.title}
+
+ {previewText(entry.content)} +
+
+
+ + {name} + +
+
+ + + {st.label} + +
+
+ + {formatUsage(entry.usage_count || 0)} 次 + +
+
+
+
+
+ + {entry.updated_at + ? new Date(entry.updated_at).toLocaleDateString('zh-CN') + : '—'} + +
+
e.stopPropagation()} + > + {canManage && ( + + )} + + {canManage && ( + handleDelete(entry.id)}> + + + )} +
+
+ ) + }) + )} +
+ + {total > 0 && ( +
+ { + setPage(p) + if (ps !== pageSize) { + setPageSize(ps) + setPage(1) + } + }} + /> +
+ )} + + + {/* 详情抽屉 */} + setDetailEntry(null)} + width={480} + title={detailEntry?.title || '知识详情'} + extra={ + detailEntry ? ( +
+ + {canManage && ( + + )} +
+ ) : null + } + > + {detailEntry && ( +
+
+ {(() => { + const st = statusStyle[detailEntry.status as keyof typeof statusStyle] || statusStyle.draft + const cc = catColor(detailEntry.category_id) + return ( + <> + + {detailEntry.category_name || categoryName(detailEntry.category_id)} + + + + {st.label} + + + 使用 {formatUsage(detailEntry.usage_count || 0)} 次 + + + ) + })()} +
+
+ {detailEntry.content} +
+
+ 更新于 {detailEntry.updated_at ? new Date(detailEntry.updated_at).toLocaleString('zh-CN') : '—'} +
+
+ )} +
{ confirmLoading={saving} width={640} destroyOnClose + okText="保存" >
- - + + @@ -271,9 +558,10 @@ const Knowledge = () => { onCancel={() => setCategoryModalOpen(false)} onOk={() => categoryForm.submit()} destroyOnClose + okText="创建" > - + diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 3be0404..3a543b8 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -39,11 +39,13 @@ export interface Customer { export interface KnowledgeCategory { id: number; tenant_id: number; parent_id: number | null; name: string + entry_count?: number } export interface KnowledgeEntry { id: number; title: string; content: string; status: string; usage_count: number category_id: number; updated_at: string + category_name?: string } export interface Channel { @@ -181,13 +183,22 @@ export const updateCustomer = (id: number, data: Partial) => put del(`/customers/${id}`) // Knowledge -export const getKnowledgeCategories = () => get('/knowledge/categories') +export const getKnowledgeCategories = () => + get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories') export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post('/knowledge/categories', data) -export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; status?: string; page?: number; pageSize?: number }) => { +export const getKnowledgeEntries = (params?: { + category_id?: string + search?: string + status?: string + sort?: 'usage' | 'updated' | 'title' + page?: number + pageSize?: number +}) => { const search = new URLSearchParams() if (params?.category_id) search.set('category_id', params.category_id) if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) + if (params?.sort) search.set('sort', params.sort) if (params?.page) search.set('page', String(params.page || 1)) if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10)) return getList(`/knowledge/entries?${search}`)