实现知识库页 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
}
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) {
+392 -104
View File
@@ -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<KnowledgeCategory[]>([])
const [totalEntries, setTotalEntries] = useState(0)
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [catSearch, setCatSearch] = useState('')
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [sortBy, setSortBy] = useState<'usage' | 'updated' | 'title'>('usage')
const [modalOpen, setModalOpen] = useState(false)
const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [detailEntry, setDetailEntry] = useState<KnowledgeEntry | null>(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) => <span className="text-sm font-medium text-neutral-800">{t}</span>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag>,
},
{
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>,
},
]
const handleSearch = () => {
setPage(1)
setSearch(searchInput.trim())
}
const catIcon = (name: string) => {
if (name.includes('快捷')) return <ThunderboltOutlined className="text-sm" />
if (name.includes('政策') || name.includes('条款')) return <FileTextOutlined className="text-sm" />
return <AppstoreOutlined className="text-sm" />
}
return (
<div className="h-full flex">
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FileTextOutlined className="text-blue-500" />
<span className="text-sm font-semibold text-neutral-700"></span>
<div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
{/* 左侧分类 280px */}
<aside className="w-[280px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
<div className="shrink-0 p-4 border-b border-neutral-200">
<div className="text-[15px] font-semibold text-neutral-900 mb-3"></div>
<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>
{canManage && (
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={() => setCategoryModalOpen(true)} />
</div>
<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>
<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">
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<h2 className="text-lg font-semibold text-neutral-800 m-0"></h2>
<Input
prefix={<SearchOutlined />}
placeholder="搜索标题或内容"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="w-48"
size="small"
allowClear
{canManage && (
<div className="shrink-0 p-3 border-t border-neutral-200">
<button
type="button"
onClick={() => setCategoryModalOpen(true)}
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"
>
<FolderAddOutlined />
</button>
</div>
)}
</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
allowClear
size="small"
placeholder="状态"
className="w-28"
placeholder="全部"
value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }}
className="!w-[100px]"
size="small"
options={[
{ value: 'published', label: '已发布' },
{ value: 'draft', label: '草稿' },
]}
/>
</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 && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm" onClick={openCreate}>
</Button>
)}
</div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table
dataSource={entries}
columns={columns}
rowKey="id"
size="middle"
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t}`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无知识条目" /> }}
/>
{/* 表头 */}
<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">
<div className="w-[40%] pl-3"></div>
<div className="w-[12%] text-center"></div>
<div className="w-[10%] text-center"></div>
<div className="w-[14%] text-center">使</div>
<div className="w-[12%] text-center"></div>
<div className="w-[12%] text-right pr-3"></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
title={editingEntry ? '编辑知识条目' : '新建知识条目'}
@@ -245,19 +531,20 @@ const Knowledge = () => {
confirmLoading={saving}
width={640}
destroyOnClose
okText="保存"
>
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true }, { min: 2, max: 100 }]}>
<Input maxLength={100} />
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { min: 2, max: 100 }]}>
<Input maxLength={100} placeholder="2-100 个字符" />
</Form.Item>
<Form.Item name="category_id" label="分类" rules={[{ required: true, message: '请选择分类' }]}>
<Select
options={categories.map(c => ({ value: c.id, label: c.title }))}
options={categories.map(c => ({ value: c.id, label: c.name }))}
placeholder={categories.length ? '选择分类' : '请先创建分类'}
/>
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }, { max: 5000 }]}>
<Input.TextArea rows={6} maxLength={5000} showCount />
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }, { max: 5000 }]}>
<Input.TextArea rows={8} maxLength={5000} showCount placeholder="标准答案或快捷话术" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} />
@@ -271,9 +558,10 @@ const Knowledge = () => {
onCancel={() => setCategoryModalOpen(false)}
onOk={() => categoryForm.submit()}
destroyOnClose
okText="创建"
>
<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="如:售后服务" />
</Form.Item>
</Form>
+13 -2
View File
@@ -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<Customer>) => put<Custo
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// 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 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<KnowledgeEntry>(`/knowledge/entries?${search}`)