对接知识库、租户管理API,侧边栏加入用户信息和退出登录

This commit is contained in:
yml2213
2026-07-14 11:33:51 +08:00
parent 1f9915ff5a
commit e4628fee7b
4 changed files with 142 additions and 213 deletions
+25 -9
View File
@@ -1,11 +1,7 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Layout, Menu } from 'antd' import { Layout, Menu, Dropdown, Avatar } from 'antd'
import { import { DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons'
DashboardOutlined, import { useAuth } from '@/stores/auth'
TeamOutlined,
GiftOutlined,
ToolOutlined,
} from '@ant-design/icons'
const { Sider } = Layout const { Sider } = Layout
@@ -19,11 +15,17 @@ const menuItems = [
const AdminSidebar = () => { const AdminSidebar = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth()
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard' const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard'
const handleLogout = () => {
logout()
navigate('/login', { replace: true })
}
return ( return (
<Sider width={220} className="!bg-white border-r border-neutral-200"> <Sider width={220} className="!bg-white border-r border-neutral-200 flex flex-col">
<div className="h-14 flex items-center px-5 border-b border-neutral-100"> <div className="h-14 flex items-center px-5 border-b border-neutral-100">
<DashboardOutlined className="text-lg text-[#2563eb] mr-2.5" /> <DashboardOutlined className="text-lg text-[#2563eb] mr-2.5" />
<span className="text-base font-semibold text-neutral-900"></span> <span className="text-base font-semibold text-neutral-900"></span>
@@ -33,8 +35,22 @@ const AdminSidebar = () => {
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => navigate(key)}
className="border-e-0 mt-2" className="border-e-0 mt-2 flex-1"
/> />
<div className="border-t border-neutral-100 p-3">
<Dropdown
menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}
trigger={['click']}
>
<div className="flex items-center gap-2 cursor-pointer hover:bg-neutral-50 rounded p-1.5">
<Avatar size={28} icon={<UserOutlined />} className="!bg-blue-100 !text-blue-500" />
<div className="min-w-0">
<div className="text-xs font-medium text-neutral-700 truncate">{user?.nickname || '管理员'}</div>
<div className="text-xs text-neutral-400">线</div>
</div>
</div>
</Dropdown>
</div>
</Sider> </Sider>
) )
} }
+26 -10
View File
@@ -1,14 +1,10 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Layout, Menu } from 'antd' import { Layout, Menu, Dropdown, Avatar } from 'antd'
import { import {
AppstoreOutlined, AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
MessageOutlined, FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, UserOutlined,
HistoryOutlined,
TeamOutlined,
FileTextOutlined,
BarChartOutlined,
SettingOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { useAuth } from '@/stores/auth'
const { Sider } = Layout const { Sider } = Layout
@@ -24,11 +20,17 @@ const menuItems = [
const AgentSidebar = () => { const AgentSidebar = () => {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const { user, logout } = useAuth()
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
const handleLogout = () => {
logout()
navigate('/login', { replace: true })
}
return ( return (
<Sider width={220} className="!bg-white border-r border-neutral-200"> <Sider width={220} className="!bg-white border-r border-neutral-200 flex flex-col">
<div className="h-14 flex items-center px-5 border-b border-neutral-100"> <div className="h-14 flex items-center px-5 border-b border-neutral-100">
<MessageOutlined className="text-lg text-[#2563eb] mr-2.5" /> <MessageOutlined className="text-lg text-[#2563eb] mr-2.5" />
<span className="text-base font-semibold text-neutral-900"></span> <span className="text-base font-semibold text-neutral-900"></span>
@@ -38,8 +40,22 @@ const AgentSidebar = () => {
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
items={menuItems} items={menuItems}
onClick={({ key }) => navigate(key)} onClick={({ key }) => navigate(key)}
className="border-e-0 mt-2" className="border-e-0 mt-2 flex-1"
/> />
<div className="border-t border-neutral-100 p-3">
<Dropdown
menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}
trigger={['click']}
>
<div className="flex items-center gap-2 cursor-pointer hover:bg-neutral-50 rounded p-1.5">
<Avatar size={28} icon={<UserOutlined />} className="!bg-blue-100 !text-blue-500" />
<div className="min-w-0">
<div className="text-xs font-medium text-neutral-700 truncate">{user?.nickname || '客服'}</div>
<div className="text-xs text-neutral-400">线</div>
</div>
</div>
</Dropdown>
</div>
</Sider> </Sider>
) )
} }
+44 -81
View File
@@ -1,29 +1,7 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Switch, Space, message, Popconfirm, Descriptions } from 'antd' import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, Descriptions, Empty } from 'antd'
import { PlusOutlined, SearchOutlined, EditOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons' import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons'
import { getTenants, type Tenant } from '@/services/api'
interface Tenant {
id: string
name: string
contact: string
phone: string
email: string
plan: string
seats: number
startDate: string
expireDate: string
status: 'normal' | 'expiring' | 'suspended' | 'expired'
}
const mockTenants: Tenant[] = [
{ id: '1', name: '赵六科技', contact: '赵总', phone: '138****1111', email: 'zhao@tech.com', plan: '企业版', seats: 30, startDate: '2026-01-15', expireDate: '2027-01-15', status: 'normal' },
{ id: '2', name: '李四商贸', contact: '李经理', phone: '139****2222', email: 'li@trade.com', plan: '专业版', seats: 10, startDate: '2026-03-01', expireDate: '2026-09-01', status: 'normal' },
{ id: '3', name: '王五集团', contact: '王总', phone: '137****3333', email: 'wang@group.com', plan: '企业版', seats: 50, startDate: '2025-06-01', expireDate: '2026-07-20', status: 'expiring' },
{ id: '4', name: '孙八信息', contact: '孙经理', phone: '136****4444', email: 'sun@info.com', plan: '基础版', seats: 2, startDate: '2026-04-01', expireDate: '2027-04-01', status: 'suspended' },
{ id: '5', name: '周九科技', contact: '周总', phone: '135****5555', email: 'zhou@tech.com', plan: '专业版', seats: 8, startDate: '2025-01-01', expireDate: '2026-06-30', status: 'expired' },
{ id: '6', name: '吴十教育', contact: '吴校长', phone: '134****6666', email: 'wu@edu.com', plan: '基础版', seats: 3, startDate: '2026-07-01', expireDate: '2027-07-01', status: 'normal' },
{ id: '7', name: '郑十一医疗', contact: '郑主任', phone: '133****7777', email: 'zheng@med.com', plan: '企业版', seats: 20, startDate: '2026-05-15', expireDate: '2026-08-15', status: 'expiring' },
]
const statusConfig: Record<string, { color: string; text: string }> = { const statusConfig: Record<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' }, normal: { color: 'green', text: '正常' },
@@ -33,42 +11,40 @@ const statusConfig: Record<string, { color: string; text: string }> = {
} }
const Tenants = () => { const Tenants = () => {
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>() const [statusFilter, setStatusFilter] = useState<string>()
const [planFilter, setPlanFilter] = useState<string>()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false)
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null) const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const filtered = mockTenants.filter(t => { useEffect(() => { loadTenants() }, [page, search, statusFilter])
if (search && !t.name.includes(search) && !t.contact.includes(search)) return false
if (statusFilter && t.status !== statusFilter) return false const loadTenants = async () => {
if (planFilter && t.plan !== planFilter) return false setLoading(true)
return true try {
}) const res = await getTenants({ search, status: statusFilter, page })
setTenants(res.list)
setTotal(res.total)
} catch { setTenants([]) } finally { setLoading(false) }
}
const columns = [ const columns = [
{ title: '公司名称', dataIndex: 'name', key: 'name', render: (text: string) => <span className="text-sm font-medium text-neutral-800">{text}</span> }, { title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> },
{ title: '联系人', dataIndex: 'contact', key: 'contact' }, { title: '联系人', dataIndex: 'contact_name', key: 'contact_name' },
{ title: '手机号', dataIndex: 'phone', key: 'phone', render: (t: string) => <span className="text-neutral-500">{t}</span> }, { title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => <span className="text-neutral-500">{t}</span> },
{ title: '套餐', dataIndex: 'plan', key: 'plan', render: (p: string) => <Tag color={p === '企业版' ? 'purple' : p === '专业版' ? 'blue' : 'default'}>{p}</Tag> }, { title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', align: 'center' as const },
{ title: '坐席数', dataIndex: 'seats', key: 'seats', align: 'center' as const }, { title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleDateString() : '-'}</span> },
{ title: '开通日期', dataIndex: 'startDate', key: 'startDate', render: (t: string) => <span className="text-neutral-500 text-xs">{t}</span> }, { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag> },
{ title: '到期日期', dataIndex: 'expireDate', key: 'expireDate', render: (t: string) => <span className="text-neutral-500 text-xs">{t}</span> }, { title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => (
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => <Tag color={statusConfig[s].color}>{statusConfig[s].text}</Tag> },
{ title: '操作', key: 'actions', width: 220, render: (_: unknown, record: Tenant) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={(e) => { e.stopPropagation(); setSelectedTenant(record); setDetailOpen(true) }}></Button> <Button type="link" size="small" icon={<EyeOutlined />} onClick={(e) => { e.stopPropagation(); setSelectedTenant(record); setDetailOpen(true) }}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={(e) => e.stopPropagation()}></Button> {record.status === 'normal' && <Button type="link" size="small" danger icon={<PauseCircleOutlined />} onClick={(e) => { e.stopPropagation(); message.info('暂停') }}></Button>}
{record.status === 'normal' && ( {(record.status === 'suspended' || record.status === 'expired') && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={(e) => { e.stopPropagation(); message.info('恢复') }}></Button>}
<Popconfirm title="确定暂停该租户?" onConfirm={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}>
<Button type="link" size="small" danger icon={<PauseCircleOutlined />} onClick={(e) => e.stopPropagation()}></Button>
</Popconfirm>
)}
{(record.status === 'suspended' || record.status === 'expired') && (
<Button type="link" size="small" icon={<ReloadOutlined />} onClick={(e) => { e.stopPropagation(); message.success('已恢复') }}></Button>
)}
</Space> </Space>
)}, )},
] ]
@@ -79,48 +55,35 @@ const Tenants = () => {
<h2 className="text-lg font-semibold text-neutral-800"></h2> <h2 className="text-lg font-semibold text-neutral-800"></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setDrawerOpen(true) }}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setDrawerOpen(true) }}></Button>
</div> </div>
<div className="flex gap-3 mb-3 flex-wrap"> <div className="flex gap-3 mb-3 flex-wrap">
<Input prefix={<SearchOutlined />} placeholder="搜索公司名称或联系人" value={search} onChange={e => setSearch(e.target.value)} className="w-56" allowClear /> <Input prefix={<SearchOutlined />} placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear />
<Select placeholder="状态筛选" value={statusFilter} onChange={setStatusFilter} allowClear className="w-28" options={Object.entries(statusConfig).map(([k, v]) => ({ value: k, label: v.text }))} /> <Select placeholder="状态筛选" value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1) }} allowClear className="w-28" options={Object.entries(statusConfig).map(([k, v]) => ({ value: k, label: v.text }))} />
<Select placeholder="套餐筛选" value={planFilter} onChange={setPlanFilter} allowClear className="w-28" options={['基础版', '专业版', '企业版'].map(v => ({ value: v, label: v }))} />
</div> </div>
<div className="bg-white rounded-lg border border-neutral-200 overflow-hidden"> <div className="bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table dataSource={filtered} columns={columns} rowKey="id" size="middle" pagination={{ pageSize: 10, showTotal: t => `${t} 个租户` }} /> <Table dataSource={tenants} 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> </div>
{/* 开通新账号 */}
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}> <Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
<Form form={form} layout="vertical" onFinish={_ => { setDrawerOpen(false); message.success('租户开通成功') }}> <Form form={form} layout="vertical" onFinish={() => { setDrawerOpen(false); message.success('开通成功') }}>
<Form.Item name="company" label="公司名称" rules={[{ required: true }]}><Input placeholder="2-100字符" maxLength={100} /></Form.Item> <Form.Item name="company" label="公司名称" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contact" label="联系人" rules={[{ required: true }]}><Input placeholder="2-30字符" maxLength={30} /></Form.Item> <Form.Item name="contact" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true, pattern: /^\d{11}$/, message: '请输入11位手机号' }]}><Input placeholder="11位手机号" maxLength={11} /></Form.Item> <Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="email" label="邮箱" rules={[{ required: true, type: 'email' }]}><Input placeholder="接收登录信息" /></Form.Item> <Form.Item name="email" label="邮箱"><Input /></Form.Item>
<Form.Item name="plan" label="套餐" rules={[{ required: true }]}> <Form.Item name="seats" label="坐席数量"><InputNumber min={1} className="w-full" /></Form.Item>
<Select options={[{ value: 'basic', label: '基础版 ¥299/月' }, { value: 'pro', label: '专业版 ¥699/月' }, { value: 'enterprise', label: '企业版 ¥1999/月' }]} /> <Form.Item name="duration" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item>
</Form.Item>
<Form.Item name="seats" label="坐席数量" rules={[{ required: true }]}><InputNumber min={1} max={100} className="w-full" /></Form.Item>
<Form.Item name="duration" label="开通时长(月)" rules={[{ required: true }]}><InputNumber min={1} max={36} className="w-full" /></Form.Item>
<Form.Item name="autoRenew" label="自动续费" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="note" label="备注"><Input.TextArea rows={2} maxLength={500} /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit" block></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit" block></Button></Form.Item>
</Form> </Form>
</Drawer> </Drawer>
{/* 查看详情 */}
<Drawer title="租户详情" open={detailOpen} onClose={() => setDetailOpen(false)} width={400}> <Drawer title="租户详情" open={detailOpen} onClose={() => setDetailOpen(false)} width={400}>
{selectedTenant && ( {selectedTenant && (
<Descriptions column={1} size="small" colon={false} className="mt-4"> <Descriptions column={1} size="small" colon={false} className="mt-4">
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item> <Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
<Descriptions.Item label="联系人">{selectedTenant.contact}</Descriptions.Item> <Descriptions.Item label="联系人">{selectedTenant.contact_name}</Descriptions.Item>
<Descriptions.Item label="手机号">{selectedTenant.phone}</Descriptions.Item> <Descriptions.Item label="手机号">{selectedTenant.contact_phone}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedTenant.email}</Descriptions.Item> <Descriptions.Item label="邮箱">{selectedTenant.contact_email}</Descriptions.Item>
<Descriptions.Item label="套餐"><Tag color={selectedTenant.plan === '企业版' ? 'purple' : selectedTenant.plan === '专业版' ? 'blue' : 'default'}>{selectedTenant.plan}</Tag></Descriptions.Item> <Descriptions.Item label="坐席数">{selectedTenant.seat_count}</Descriptions.Item>
<Descriptions.Item label="坐席数">{selectedTenant.seats}</Descriptions.Item> <Descriptions.Item label="到期日期">{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '-'}</Descriptions.Item>
<Descriptions.Item label="开通日期">{selectedTenant.startDate}</Descriptions.Item>
<Descriptions.Item label="到期日期">{selectedTenant.expireDate}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={statusConfig[selectedTenant.status].color}>{statusConfig[selectedTenant.status].text}</Tag></Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Drawer> </Drawer>
+47 -113
View File
@@ -1,98 +1,56 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Space, Empty, Progress, Popconfirm } from 'antd' import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons' import { SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons'
import { getKnowledgeCategories, getKnowledgeEntries, type KnowledgeEntry } from '@/services/api'
interface Category {
key: string
title: string
children?: Category[]
}
interface KnowledgeEntry {
id: string
title: string
category: string
content: string
status: 'published' | 'draft'
usageCount: number
updatedAt: string
}
const categories: Category[] = [
{ key: 'product', title: '产品常见问题', children: [
{ key: 'product-feature', title: '功能介绍' },
{ key: 'product-compare', title: '版本对比' },
]},
{ key: 'after-sale', title: '售后服务', children: [
{ key: 'refund', title: '退换货政策' },
{ key: 'warranty', title: '保修说明' },
]},
{ key: 'support', title: '技术支持' },
{ key: 'policy', title: '政策条款' },
{ key: 'quick-reply', title: '快捷回复模板' },
]
const mockEntries: KnowledgeEntry[] = [
{ id: '1', title: '如何修改登录密码', category: 'product', content: '登录后在右上角头像→个人设置→修改密码中输入旧密码和新密码即可完成修改。', status: 'published', usageCount: 156, updatedAt: '2026-07-14' },
{ id: '2', title: '支持哪些支付方式', category: 'product-feature', content: '目前支持微信支付、支付宝、银行转账三种方式。企业客户支持对公转账。', status: 'published', usageCount: 98, updatedAt: '2026-07-13' },
{ id: '3', title: '免费版和专业版区别', category: 'product-compare', content: '免费版提供基础的客服功能,专业版支持更多渠道接入、高级报表和API接口。', status: 'published', usageCount: 72, updatedAt: '2026-07-12' },
{ id: '4', title: '退货流程说明', category: 'refund', content: '在订单页面点击申请退货→填写退货原因→等待审核→寄回商品→退款到账,全程约3-5个工作日。', status: 'published', usageCount: 45, updatedAt: '2026-07-11' },
{ id: '5', title: 'API 接口文档', category: 'support', content: '开发者文档请访问 docs.example.com/api,提供 REST API 和 Webhook 两种接入方式。', status: 'draft', usageCount: 0, updatedAt: '2026-07-10' },
{ id: '6', title: '欢迎语模板', category: 'quick-reply', content: '您好!欢迎来到客服云,请问有什么可以帮您的?', status: 'published', usageCount: 230, updatedAt: '2026-07-09' },
{ id: '7', title: '结束语模板', category: 'quick-reply', content: '感谢您的咨询,如有其他问题随时联系我们,祝您生活愉快!', status: 'published', usageCount: 189, updatedAt: '2026-07-09' },
]
const Knowledge = () => { const Knowledge = () => {
const [selectedCategory, setSelectedCategory] = useState<string>('product') const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([])
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string>('')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null) const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [form] = Form.useForm() const [form] = Form.useForm()
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const filtered = mockEntries.filter(e => { useEffect(() => {
if (selectedCategory && e.category !== selectedCategory) return false loadCategories()
if (search && !e.title.includes(search) && !e.content.includes(search)) return false }, [])
return true
})
const openNew = () => { useEffect(() => {
setEditingEntry(null) loadEntries()
form.resetFields() }, [selectedCategory, search, page])
form.setFieldsValue({ category: selectedCategory, status: 'draft' })
setModalOpen(true) const loadCategories = async () => {
try {
const res = await getKnowledgeCategories()
setCategories((res.data as any[]).map((c: any) => ({ key: String(c.id), title: c.name, id: c.id })))
} catch { /* fallback */ }
} }
const openEdit = (entry: KnowledgeEntry) => { const loadEntries = async () => {
setEditingEntry(entry) setLoading(true)
form.setFieldsValue(entry) try {
setModalOpen(true) const res = await getKnowledgeEntries({ category_id: selectedCategory, search, page })
setEntries(res.list)
setTotal(res.total)
} catch { setEntries([]) } finally { setLoading(false) }
} }
const columns = [ const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', render: (text: string) => <span className="text-sm font-medium text-neutral-800">{text}</span> }, { 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: 80, render: (status: string) => ( { title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === 'published' ? '已发布' : '草稿'}</Tag> },
<Tag color={status === 'published' ? 'green' : 'default'}>{status === 'published' ? '已发布' : '草稿'}</Tag> { title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 200, 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-32" /><span className="text-xs text-neutral-400">{c}</span></div>
{ title: '使用频率', dataIndex: 'usageCount', key: 'usageCount', width: 200, render: (count: number) => (
<div className="flex items-center gap-2">
<Progress percent={Math.min(count / 2, 100)} size="small" showInfo={false} strokeColor="#2563eb" className="flex-1 max-w-32" />
<span className="text-xs text-neutral-400">{count}</span>
</div>
)},
{ title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 120, render: (text: string) => <span className="text-xs text-neutral-400">{text}</span> },
{ title: '操作', key: 'actions', width: 120, render: (_: unknown, record: KnowledgeEntry) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); openEdit(record) }}></Button>
<Popconfirm title="确定删除?" onConfirm={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</Space>
)}, )},
{ 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: 100, render: () => <Button type="link" size="small" icon={<EditOutlined />}></Button> },
] ]
return ( return (
<div className="h-full flex"> <div className="h-full flex">
{/* 左侧分类树 */}
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4"> <div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<FileTextOutlined className="text-blue-500" /> <FileTextOutlined className="text-blue-500" />
@@ -101,54 +59,30 @@ const Knowledge = () => {
<Tree <Tree
treeData={categories as any} treeData={categories as any}
defaultExpandAll defaultExpandAll
selectedKeys={[selectedCategory]} selectedKeys={selectedCategory ? [selectedCategory] : []}
onSelect={keys => { if (keys.length > 0) setSelectedCategory(keys[0] as string) }} onSelect={keys => setSelectedCategory(keys.length > 0 ? (keys[0] as string) : '')}
blockNode blockNode
/> />
</div> </div>
{/* 右侧内容 */}
<div className="flex-1 flex flex-col p-6"> <div className="flex-1 flex flex-col p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-neutral-800"></h2> <h2 className="text-lg font-semibold text-neutral-800"></h2>
<Input prefix={<SearchOutlined />} placeholder="搜索标题或内容..." value={search} onChange={e => setSearch(e.target.value)} className="w-56" size="small" allowClear /> <Input prefix={<SearchOutlined />} placeholder="搜索..." value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-48" size="small" allowClear />
</div> </div>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew}></Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingEntry(null); form.resetFields(); setModalOpen(true) }}></Button>
</div> </div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden"> <div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table <Table dataSource={entries} columns={columns} rowKey="id" size="middle" loading={loading}
dataSource={filtered} pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t}`, onChange: p => setPage(p) }}
columns={columns} locale={{ emptyText: <Empty description="暂无知识条目" /> }} />
rowKey="id"
size="middle"
pagination={{ pageSize: 10 }}
locale={{ emptyText: <Empty description="暂无知识条目" /> }}
/>
</div> </div>
</div> </div>
<Modal title={editingEntry ? '编辑' : '新建'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} width={640}>
<Modal <Form form={form} layout="vertical" onFinish={() => setModalOpen(false)} className="mt-4">
title={editingEntry ? '编辑知识条目' : '新建知识条目'} <Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={100} /></Form.Item>
open={modalOpen} <Form.Item name="content" label="内容" rules={[{ required: true }]}><Input.TextArea rows={5} maxLength={5000} showCount /></Form.Item>
onCancel={() => setModalOpen(false)} <Form.Item name="status" label="状态"><Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} /></Form.Item>
onOk={() => form.submit()}
width={640}
>
<Form form={form} layout="vertical" onFinish={_values => setModalOpen(false)} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="条目标题,2-100字符" maxLength={100} />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Select options={categories.map(c => ({ value: c.key, label: c.title }))} />
</Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={6} placeholder="条目内容,支持纯文本" maxLength={5000} showCount />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>