对接知识库、租户管理API,侧边栏加入用户信息和退出登录
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user