实现知识库页 UI 并对齐效果图
分类侧栏含条目计数、列表支持排序筛选与详情抽屉;列表接口补充分类名与使用次数排序。
This commit is contained in:
+392
-104
@@ -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
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user