对接知识库、租户管理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
+47 -113
View File
@@ -1,98 +1,56 @@
import { useState } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Space, Empty, Progress, Popconfirm } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'
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' },
]
import { useState, useEffect } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons'
import { getKnowledgeCategories, getKnowledgeEntries, type KnowledgeEntry } from '@/services/api'
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 [modalOpen, setModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
const [form] = Form.useForm()
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const filtered = mockEntries.filter(e => {
if (selectedCategory && e.category !== selectedCategory) return false
if (search && !e.title.includes(search) && !e.content.includes(search)) return false
return true
})
useEffect(() => {
loadCategories()
}, [])
const openNew = () => {
setEditingEntry(null)
form.resetFields()
form.setFieldsValue({ category: selectedCategory, status: 'draft' })
setModalOpen(true)
useEffect(() => {
loadEntries()
}, [selectedCategory, search, page])
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) => {
setEditingEntry(entry)
form.setFieldsValue(entry)
setModalOpen(true)
const loadEntries = async () => {
setLoading(true)
try {
const res = await getKnowledgeEntries({ category_id: selectedCategory, search, page })
setEntries(res.list)
setTotal(res.total)
} catch { setEntries([]) } finally { setLoading(false) }
}
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', render: (text: string) => <span className="text-sm font-medium text-neutral-800">{text}</span> },
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (status: string) => (
<Tag color={status === 'published' ? 'green' : 'default'}>{status === 'published' ? '已发布' : '草稿'}</Tag>
)},
{ 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: '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: (s: string) => <Tag color={s === 'published' ? 'green' : 'default'}>{s === '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: '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 (
<div className="h-full flex">
{/* 左侧分类树 */}
<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">
<FileTextOutlined className="text-blue-500" />
@@ -101,54 +59,30 @@ const Knowledge = () => {
<Tree
treeData={categories as any}
defaultExpandAll
selectedKeys={[selectedCategory]}
onSelect={keys => { if (keys.length > 0) setSelectedCategory(keys[0] as string) }}
selectedKeys={selectedCategory ? [selectedCategory] : []}
onSelect={keys => setSelectedCategory(keys.length > 0 ? (keys[0] as string) : '')}
blockNode
/>
</div>
{/* 右侧内容 */}
<div className="flex-1 flex flex-col p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<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>
<Button type="primary" icon={<PlusOutlined />} onClick={openNew}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditingEntry(null); form.resetFields(); setModalOpen(true) }}></Button>
</div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table
dataSource={filtered}
columns={columns}
rowKey="id"
size="middle"
pagination={{ pageSize: 10 }}
locale={{ emptyText: <Empty description="暂无知识条目" /> }}
/>
<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>
</div>
<Modal
title={editingEntry ? '编辑知识条目' : '新建知识条目'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
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>
<Modal title={editingEntry ? '编辑' : '新建'} open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} width={640}>
<Form form={form} layout="vertical" onFinish={() => setModalOpen(false)} className="mt-4">
<Form.Item name="title" label="标题" rules={[{ required: true }]}><Input maxLength={100} /></Form.Item>
<Form.Item name="content" label="内容" rules={[{ required: true }]}><Input.TextArea rows={5} maxLength={5000} showCount /></Form.Item>
<Form.Item name="status" label="状态"><Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} /></Form.Item>
</Form>
</Modal>
</div>