实现知识库页 UI 并对齐效果图

分类侧栏含条目计数、列表支持排序筛选与详情抽屉;列表接口补充分类名与使用次数排序。
This commit is contained in:
yml2213
2026-07-15 13:14:03 +08:00
parent 32fbfb4fe1
commit 2cfdacacb3
3 changed files with 478 additions and 110 deletions
+73 -4
View File
@@ -42,13 +42,40 @@ func hasKnowledgeCapacity(tenantID uint) (bool, error) {
return count < int64(plan.KBLimit), nil return count < int64(plan.KBLimit), nil
} }
type CategoryListItem struct {
model.Category
EntryCount int64 `json:"entry_count"`
}
func (h *KnowledgeHandler) ListCategories(c *gin.Context) { func (h *KnowledgeHandler) ListCategories(c *gin.Context) {
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
var categories []model.Category 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) { func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
@@ -70,12 +97,18 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
middleware.JSON(c, category) middleware.JSON(c, category)
} }
type EntryListItem struct {
model.KnowledgeEntry
CategoryName string `json:"category_name"`
}
func (h *KnowledgeHandler) ListEntries(c *gin.Context) { func (h *KnowledgeHandler) ListEntries(c *gin.Context) {
tenantID := middleware.GetTenantID(c) tenantID := middleware.GetTenantID(c)
page, pageSize := middleware.GetPageParams(c) page, pageSize := middleware.GetPageParams(c)
categoryID := c.Query("category_id") categoryID := c.Query("category_id")
search := c.Query("search") search := c.Query("search")
status := c.Query("status") status := c.Query("status")
sortBy := c.Query("sort") // usage | updated(default)
var entries []model.KnowledgeEntry var entries []model.KnowledgeEntry
var total int64 var total int64
@@ -92,9 +125,45 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) {
} }
query.Model(&model.KnowledgeEntry{}).Count(&total) 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) { func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
+392 -104
View File
@@ -1,36 +1,72 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { import {
Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress, Input, Button, Modal, Form, Select, Empty, message, Popconfirm, Pagination, Spin, Drawer,
message, Popconfirm, Space,
} from 'antd' } from 'antd'
import { import {
SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined, DeleteOutlined, FolderAddOutlined, SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FolderAddOutlined,
CopyOutlined, FileTextOutlined, AppstoreOutlined, ThunderboltOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry, createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry,
getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry, getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry,
type KnowledgeEntry, type KnowledgeCategory, type KnowledgeEntry,
} from '@/services/api' } from '@/services/api'
import { useAuth } from '@/stores/auth' 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 Knowledge = () => {
const { user } = useAuth() const { user } = useAuth()
const canManage = user?.role === 'admin' || user?.role === 'supervisor' const canManage = user?.role === 'admin' || user?.role === 'supervisor'
const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([]) const [categories, setCategories] = useState<KnowledgeCategory[]>([])
const [totalEntries, setTotalEntries] = useState(0)
const [entries, setEntries] = useState<KnowledgeEntry[]>([]) const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('') const [selectedCategory, setSelectedCategory] = useState<string>('')
const [catSearch, setCatSearch] = useState('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [statusFilter, setStatusFilter] = useState<string>() const [statusFilter, setStatusFilter] = useState<string>()
const [sortBy, setSortBy] = useState<'usage' | 'updated' | 'title'>('usage')
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [categoryModalOpen, setCategoryModalOpen] = useState(false) const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null) const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [detailEntry, setDetailEntry] = useState<KnowledgeEntry | null>(null)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form] = Form.useForm() const [form] = Form.useForm()
const [categoryForm] = Form.useForm() const [categoryForm] = Form.useForm()
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
useEffect(() => { useEffect(() => {
loadCategories() loadCategories()
@@ -38,15 +74,18 @@ const Knowledge = () => {
useEffect(() => { useEffect(() => {
loadEntries() loadEntries()
}, [selectedCategory, search, page, statusFilter]) }, [selectedCategory, search, page, pageSize, statusFilter, sortBy])
const loadCategories = async () => { const loadCategories = async () => {
try { try {
const res = await getKnowledgeCategories() const res = await getKnowledgeCategories()
const list = Array.isArray(res.data) ? res.data : [] const data = res.data
setCategories(list.map(c => ({ key: String(c.id), title: c.name, id: c.id }))) 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 { } catch {
setCategories([]) setCategories([])
setTotalEntries(0)
} }
} }
@@ -57,18 +96,33 @@ const Knowledge = () => {
category_id: selectedCategory, category_id: selectedCategory,
search, search,
status: statusFilter, status: statusFilter,
sort: sortBy,
page, page,
pageSize: 10, pageSize,
}) })
setEntries(res.list) setEntries(res.list || [])
setTotal(res.total) setTotal(res.total || 0)
} catch { } catch {
setEntries([]) setEntries([])
setTotal(0)
} finally { } finally {
setLoading(false) 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 = () => { const openCreate = () => {
setEditingEntry(null) setEditingEntry(null)
form.resetFields() form.resetFields()
@@ -101,7 +155,7 @@ const Knowledge = () => {
message.success('条目已创建') message.success('条目已创建')
} }
setModalOpen(false) setModalOpen(false)
await loadEntries() await Promise.all([loadEntries(), loadCategories()])
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败') message.error(e instanceof Error ? e.message : '保存失败')
} finally { } finally {
@@ -113,12 +167,22 @@ const Knowledge = () => {
try { try {
await deleteKnowledgeEntry(id) await deleteKnowledgeEntry(id)
message.success('已删除') message.success('已删除')
await loadEntries() if (detailEntry?.id === id) setDetailEntry(null)
await Promise.all([loadEntries(), loadCategories()])
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '删除失败') 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 }) => { const handleCreateCategory = async (values: { name: string }) => {
try { try {
await createKnowledgeCategory({ name: values.name.trim() }) await createKnowledgeCategory({ name: values.name.trim() })
@@ -131,111 +195,333 @@ const Knowledge = () => {
} }
} }
const columns = [ const handleSearch = () => {
{ setPage(1)
title: '标题', dataIndex: 'title', key: 'title', setSearch(searchInput.trim())
render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span>, }
},
{ const catIcon = (name: string) => {
title: '状态', dataIndex: 'status', key: 'status', width: 90, if (name.includes('快捷')) return <ThunderboltOutlined className="text-sm" />
render: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag>, if (name.includes('政策') || name.includes('条款')) return <FileTextOutlined className="text-sm" />
}, return <AppstoreOutlined className="text-sm" />
{ }
title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 180,
render: (c: number) => (
<div className="flex items-center gap-2">
<Progress percent={Math.min(c / 2, 100)} size="small" showInfo={false} strokeColor="#2563eb" className="flex-1 max-w-28" />
<span className="text-xs text-neutral-400">{c}</span>
</div>
),
},
{
title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120,
render: (t: string) => <span className="text-xs text-neutral-400">{t ? new Date(t).toLocaleDateString() : '—'}</span>,
},
{
title: '操作', key: 'actions', width: 140,
render: (_: unknown, record: KnowledgeEntry) => canManage ? (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}></Button>
<Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
) : <span className="text-xs text-neutral-400"></span>,
},
]
return ( return (
<div className="h-full flex"> <div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4 flex flex-col"> {/* 左侧分类 280px */}
<div className="flex items-center justify-between mb-3"> <aside className="w-[280px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
<div className="flex items-center gap-2"> <div className="shrink-0 p-4 border-b border-neutral-200">
<FileTextOutlined className="text-blue-500" /> <div className="text-[15px] font-semibold text-neutral-900 mb-3"></div>
<span className="text-sm font-semibold text-neutral-700"></span> <div className="h-8 flex items-center gap-2 px-3 rounded-md bg-neutral-50 border border-neutral-200">
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
<input
type="text"
placeholder="搜索分类..."
value={catSearch}
onChange={e => setCatSearch(e.target.value)}
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-neutral-700 placeholder:text-neutral-400"
/>
</div> </div>
{canManage && ( </div>
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={() => setCategoryModalOpen(true)} />
<div className="flex-1 overflow-y-auto py-2">
<button
type="button"
onClick={() => { setSelectedCategory(''); setPage(1) }}
className={`w-full flex items-center gap-2 px-4 py-2 text-sm border-0 cursor-pointer text-left ${
!selectedCategory
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
: 'bg-transparent text-neutral-700 hover:bg-neutral-50'
}`}
>
<AppstoreOutlined className="shrink-0" />
<span className="flex-1 truncate"></span>
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
!selectedCategory ? 'bg-[#dbeafe] text-[#2563eb]' : 'text-neutral-400'
}`}>
{totalEntries}
</span>
</button>
{filteredCategories.map(cat => {
const active = selectedCategory === String(cat.id)
return (
<button
key={cat.id}
type="button"
onClick={() => { setSelectedCategory(String(cat.id)); setPage(1) }}
className={`w-full flex items-center gap-2 px-4 py-2 text-sm border-0 cursor-pointer text-left ${
active
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
: 'bg-transparent text-neutral-700 hover:bg-neutral-50'
}`}
>
<span className="shrink-0 text-neutral-400">{catIcon(cat.name)}</span>
<span className="flex-1 truncate">{cat.name}</span>
<span className={`text-xs ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
{cat.entry_count ?? 0}
</span>
</button>
)
})}
{filteredCategories.length === 0 && (
<div className="text-center text-xs text-neutral-400 py-8"></div>
)} )}
</div> </div>
<button
type="button"
className={`w-full text-left text-sm px-2 py-1.5 rounded mb-1 ${!selectedCategory ? 'bg-blue-50 text-blue-600' : 'text-neutral-600 hover:bg-neutral-50'}`}
onClick={() => { setSelectedCategory(''); setPage(1) }}
>
</button>
<Tree
treeData={categories as any}
selectedKeys={selectedCategory ? [selectedCategory] : []}
onSelect={keys => { setSelectedCategory(keys.length > 0 ? String(keys[0]) : ''); setPage(1) }}
blockNode
className="flex-1 overflow-auto"
/>
</div>
<div className="flex-1 flex flex-col p-6"> {canManage && (
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap"> <div className="shrink-0 p-3 border-t border-neutral-200">
<div className="flex items-center gap-3 flex-wrap"> <button
<h2 className="text-lg font-semibold text-neutral-800 m-0"></h2> type="button"
<Input onClick={() => setCategoryModalOpen(true)}
prefix={<SearchOutlined />} className="w-full h-8 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white text-xs text-neutral-600 hover:bg-neutral-50 cursor-pointer"
placeholder="搜索标题或内容" >
value={search} <FolderAddOutlined />
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="w-48" </button>
size="small" </div>
allowClear )}
</aside>
{/* 右侧列表 */}
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* 工具栏 */}
<div className="shrink-0 px-6 py-4 border-b border-neutral-200 bg-white flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-1 max-w-md h-8 px-3 rounded-md border border-neutral-200 bg-white">
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
<input
type="text"
placeholder="搜索知识条目..."
value={searchInput}
onChange={e => 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"
/> />
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 whitespace-nowrap">:</span>
<Select <Select
allowClear allowClear
size="small" placeholder="全部"
placeholder="状态"
className="w-28"
value={statusFilter} value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }} onChange={v => { setStatusFilter(v); setPage(1) }}
className="!w-[100px]"
size="small"
options={[ options={[
{ value: 'published', label: '已发布' }, { value: 'published', label: '已发布' },
{ value: 'draft', label: '草稿' }, { value: 'draft', label: '草稿' },
]} ]}
/> />
</div> </div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-neutral-500 whitespace-nowrap">:</span>
<Select
value={sortBy}
onChange={v => { setSortBy(v); setPage(1) }}
className="!w-[120px]"
size="small"
options={[
{ value: 'usage', label: '按使用次数' },
{ value: 'updated', label: '按更新时间' },
{ value: 'title', label: '按标题' },
]}
/>
</div>
<div className="flex-1" />
<span className="text-xs text-neutral-400 whitespace-nowrap"> {total} </span>
{canManage && ( {canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button> <Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm" onClick={openCreate}>
</Button>
)} )}
</div> </div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table {/* 表头 */}
dataSource={entries} <div className="shrink-0 flex items-center px-6 py-2 bg-neutral-100 border-b border-neutral-200 text-[11px] font-medium text-neutral-500">
columns={columns} <div className="w-[40%] pl-3"></div>
rowKey="id" <div className="w-[12%] text-center"></div>
size="middle" <div className="w-[10%] text-center"></div>
loading={loading} <div className="w-[14%] text-center">使</div>
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t}`, onChange: p => setPage(p) }} <div className="w-[12%] text-center"></div>
locale={{ emptyText: <Empty description="暂无知识条目" /> }} <div className="w-[12%] text-right pr-3"></div>
/>
</div> </div>
</div>
{/* 条目列表 */}
<div className="flex-1 overflow-y-auto bg-white">
{loading ? (
<div className="flex justify-center py-20"><Spin size="large" /></div>
) : entries.length === 0 ? (
<Empty className="py-20" description="暂无知识条目" />
) : (
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 (
<div
key={entry.id}
role="button"
tabIndex={0}
onClick={() => 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"
>
<div className="w-[40%] pl-3 min-w-0">
<div className="text-sm font-medium text-neutral-900 truncate">{entry.title}</div>
<div className="text-xs text-neutral-500 mt-0.5 truncate">
{previewText(entry.content)}
</div>
</div>
<div className="w-[12%] text-center">
<span
className="inline-flex px-2 py-0.5 rounded text-[11px] whitespace-nowrap"
style={{ backgroundColor: cc.bg, color: cc.color }}
>
{name}
</span>
</div>
<div className="w-[10%] text-center">
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] whitespace-nowrap"
style={{ backgroundColor: st.bg, color: st.color }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: st.dot }} />
{st.label}
</span>
</div>
<div className="w-[14%] flex flex-col items-center gap-1">
<span className="text-xs text-neutral-700 whitespace-nowrap">
{formatUsage(entry.usage_count || 0)}
</span>
<div className="w-20 h-1 rounded-full bg-neutral-200 overflow-hidden">
<div
className="h-full rounded-full bg-[#2563eb]"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
<div className="w-[12%] text-center">
<span className="text-xs text-neutral-500 whitespace-nowrap">
{entry.updated_at
? new Date(entry.updated_at).toLocaleDateString('zh-CN')
: '—'}
</span>
</div>
<div
className="w-[12%] flex items-center justify-end gap-0.5 pr-1"
onClick={e => e.stopPropagation()}
>
{canManage && (
<button
type="button"
title="编辑"
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
onClick={() => openEdit(entry)}
>
<EditOutlined className="text-xs" />
</button>
)}
<button
type="button"
title="复制内容"
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
onClick={() => handleCopy(entry)}
>
<CopyOutlined className="text-xs" />
</button>
{canManage && (
<Popconfirm title="确认删除该条目?" onConfirm={() => handleDelete(entry.id)}>
<button
type="button"
title="删除"
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:bg-red-50 hover:text-red-500 border-0 bg-transparent cursor-pointer"
>
<DeleteOutlined className="text-xs" />
</button>
</Popconfirm>
)}
</div>
</div>
)
})
)}
</div>
{total > 0 && (
<div className="shrink-0 px-6 py-3 border-t border-neutral-200 bg-white flex justify-end">
<Pagination
current={page}
total={total}
pageSize={pageSize}
showSizeChanger
pageSizeOptions={[10, 20, 50]}
size="small"
onChange={(p, ps) => {
setPage(p)
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
}
}}
/>
</div>
)}
</section>
{/* 详情抽屉 */}
<Drawer
open={!!detailEntry}
onClose={() => setDetailEntry(null)}
width={480}
title={detailEntry?.title || '知识详情'}
extra={
detailEntry ? (
<div className="flex gap-1">
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopy(detailEntry)}>
</Button>
{canManage && (
<Button size="small" icon={<EditOutlined />} onClick={() => { setDetailEntry(null); openEdit(detailEntry) }}>
</Button>
)}
</div>
) : null
}
>
{detailEntry && (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{(() => {
const st = statusStyle[detailEntry.status as keyof typeof statusStyle] || statusStyle.draft
const cc = catColor(detailEntry.category_id)
return (
<>
<span className="inline-flex px-2 py-0.5 rounded text-xs" style={{ background: cc.bg, color: cc.color }}>
{detailEntry.category_name || categoryName(detailEntry.category_id)}
</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs" style={{ background: st.bg, color: st.color }}>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: st.dot }} />
{st.label}
</span>
<span className="text-xs text-neutral-400">
使 {formatUsage(detailEntry.usage_count || 0)}
</span>
</>
)
})()}
</div>
<div className="text-sm text-neutral-800 whitespace-pre-wrap leading-relaxed border border-neutral-100 rounded-lg p-4 bg-neutral-50">
{detailEntry.content}
</div>
<div className="text-xs text-neutral-400">
{detailEntry.updated_at ? new Date(detailEntry.updated_at).toLocaleString('zh-CN') : '—'}
</div>
</div>
)}
</Drawer>
<Modal <Modal
title={editingEntry ? '编辑知识条目' : '新建知识条目'} title={editingEntry ? '编辑知识条目' : '新建知识条目'}
@@ -245,19 +531,20 @@ const Knowledge = () => {
confirmLoading={saving} confirmLoading={saving}
width={640} width={640}
destroyOnClose destroyOnClose
okText="保存"
> >
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-4"> <Form form={form} layout="vertical" onFinish={handleSave} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true }, { min: 2, max: 100 }]}> <Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { min: 2, max: 100 }]}>
<Input maxLength={100} /> <Input maxLength={100} placeholder="2-100 个字符" />
</Form.Item> </Form.Item>
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}> <Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select <Select
options={categories.map(c => ({ value: c.id, label: c.title }))} options={categories.map(c => ({ value: c.id, label: c.name }))}
placeholder={categories.length ? '选择分类' : '请先创建分类'} placeholder={categories.length ? '选择分类' : '请先创建分类'}
/> />
</Form.Item> </Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }, { max: 5000 }]}> <Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }, { max: 5000 }]}>
<Input.TextArea rows={6} maxLength={5000} showCount /> <Input.TextArea rows={8} maxLength={5000} showCount placeholder="标准答案或快捷话术" />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} /> <Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} />
@@ -271,9 +558,10 @@ const Knowledge = () => {
onCancel={() => setCategoryModalOpen(false)} onCancel={() => setCategoryModalOpen(false)}
onOk={() => categoryForm.submit()} onOk={() => categoryForm.submit()}
destroyOnClose destroyOnClose
okText="创建"
> >
<Form form={categoryForm} layout="vertical" onFinish={handleCreateCategory} className="mt-2"> <Form form={categoryForm} layout="vertical" onFinish={handleCreateCategory} className="mt-2">
<Form.Item name="name" label="分类名称" rules={[{ required: true }, { min: 2, max: 30 }]}> <Form.Item name="name" label="分类名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 30 }]}>
<Input maxLength={30} placeholder="如:售后服务" /> <Input maxLength={30} placeholder="如:售后服务" />
</Form.Item> </Form.Item>
</Form> </Form>
+13 -2
View File
@@ -39,11 +39,13 @@ export interface Customer {
export interface KnowledgeCategory { export interface KnowledgeCategory {
id: number; tenant_id: number; parent_id: number | null; name: string id: number; tenant_id: number; parent_id: number | null; name: string
entry_count?: number
} }
export interface KnowledgeEntry { export interface KnowledgeEntry {
id: number; title: string; content: string; status: string; usage_count: number id: number; title: string; content: string; status: string; usage_count: number
category_id: number; updated_at: string category_id: number; updated_at: string
category_name?: string
} }
export interface Channel { export interface Channel {
@@ -181,13 +183,22 @@ export const updateCustomer = (id: number, data: Partial<Customer>) => put<Custo
export const deleteCustomer = (id: number) => del(`/customers/${id}`) export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// Knowledge // Knowledge
export const getKnowledgeCategories = () => get<KnowledgeCategory[]>('/knowledge/categories') export const getKnowledgeCategories = () =>
get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories')
export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post<KnowledgeCategory>('/knowledge/categories', data) export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post<KnowledgeCategory>('/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() const search = new URLSearchParams()
if (params?.category_id) search.set('category_id', params.category_id) if (params?.category_id) search.set('category_id', params.category_id)
if (params?.search) search.set('search', params.search) if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status) 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?.page) search.set('page', String(params.page || 1))
if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10)) if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
return getList<KnowledgeEntry>(`/knowledge/entries?${search}`) return getList<KnowledgeEntry>(`/knowledge/entries?${search}`)