From e4628fee7b3a42eecb039e569b8030b4bedce4ea Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 14 Jul 2026 11:33:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E7=9F=A5=E8=AF=86=E5=BA=93?= =?UTF-8?q?=E3=80=81=E7=A7=9F=E6=88=B7=E7=AE=A1=E7=90=86API=EF=BC=8C?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=8A=A0=E5=85=A5=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=92=8C=E9=80=80=E5=87=BA=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/layout/AdminSidebar.tsx | 34 +++-- web/src/components/layout/AgentSidebar.tsx | 36 +++-- web/src/pages/admin/Tenants.tsx | 125 ++++++---------- web/src/pages/agent/Knowledge.tsx | 160 ++++++--------------- 4 files changed, 142 insertions(+), 213 deletions(-) diff --git a/web/src/components/layout/AdminSidebar.tsx b/web/src/components/layout/AdminSidebar.tsx index 1c80f41..c6fde20 100644 --- a/web/src/components/layout/AdminSidebar.tsx +++ b/web/src/components/layout/AdminSidebar.tsx @@ -1,11 +1,7 @@ import { useLocation, useNavigate } from 'react-router-dom' -import { Layout, Menu } from 'antd' -import { - DashboardOutlined, - TeamOutlined, - GiftOutlined, - ToolOutlined, -} from '@ant-design/icons' +import { Layout, Menu, Dropdown, Avatar } from 'antd' +import { DashboardOutlined, TeamOutlined, GiftOutlined, ToolOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons' +import { useAuth } from '@/stores/auth' const { Sider } = Layout @@ -19,11 +15,17 @@ const menuItems = [ const AdminSidebar = () => { const location = useLocation() const navigate = useNavigate() + const { user, logout } = useAuth() const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard' + const handleLogout = () => { + logout() + navigate('/login', { replace: true }) + } + return ( - +
客服云管理 @@ -33,8 +35,22 @@ const AdminSidebar = () => { selectedKeys={[selectedKey]} items={menuItems} onClick={({ key }) => navigate(key)} - className="border-e-0 mt-2" + className="border-e-0 mt-2 flex-1" /> +
+ , label: '退出登录', onClick: handleLogout }] }} + trigger={['click']} + > +
+ } className="!bg-blue-100 !text-blue-500" /> +
+
{user?.nickname || '管理员'}
+
在线
+
+
+
+
) } diff --git a/web/src/components/layout/AgentSidebar.tsx b/web/src/components/layout/AgentSidebar.tsx index 31ea694..2ac0f81 100644 --- a/web/src/components/layout/AgentSidebar.tsx +++ b/web/src/components/layout/AgentSidebar.tsx @@ -1,14 +1,10 @@ import { useLocation, useNavigate } from 'react-router-dom' -import { Layout, Menu } from 'antd' +import { Layout, Menu, Dropdown, Avatar } from 'antd' import { - AppstoreOutlined, - MessageOutlined, - HistoryOutlined, - TeamOutlined, - FileTextOutlined, - BarChartOutlined, - SettingOutlined, + AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined, + FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined, UserOutlined, } from '@ant-design/icons' +import { useAuth } from '@/stores/auth' const { Sider } = Layout @@ -24,11 +20,17 @@ const menuItems = [ const AgentSidebar = () => { const location = useLocation() const navigate = useNavigate() + const { user, logout } = useAuth() const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard' + const handleLogout = () => { + logout() + navigate('/login', { replace: true }) + } + return ( - +
客服云 @@ -38,8 +40,22 @@ const AgentSidebar = () => { selectedKeys={[selectedKey]} items={menuItems} onClick={({ key }) => navigate(key)} - className="border-e-0 mt-2" + className="border-e-0 mt-2 flex-1" /> +
+ , label: '退出登录', onClick: handleLogout }] }} + trigger={['click']} + > +
+ } className="!bg-blue-100 !text-blue-500" /> +
+
{user?.nickname || '客服'}
+
在线
+
+
+
+
) } diff --git a/web/src/pages/admin/Tenants.tsx b/web/src/pages/admin/Tenants.tsx index 9f0d836..71dbeb5 100644 --- a/web/src/pages/admin/Tenants.tsx +++ b/web/src/pages/admin/Tenants.tsx @@ -1,29 +1,7 @@ -import { useState } from 'react' -import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Switch, Space, message, Popconfirm, Descriptions } from 'antd' -import { PlusOutlined, SearchOutlined, EditOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons' - -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' }, -] +import { useState, useEffect } from 'react' +import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, Descriptions, Empty } from 'antd' +import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons' +import { getTenants, type Tenant } from '@/services/api' const statusConfig: Record = { normal: { color: 'green', text: '正常' }, @@ -33,42 +11,40 @@ const statusConfig: Record = { } const Tenants = () => { + const [tenants, setTenants] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) const [search, setSearch] = useState('') const [statusFilter, setStatusFilter] = useState() - const [planFilter, setPlanFilter] = useState() const [drawerOpen, setDrawerOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false) const [selectedTenant, setSelectedTenant] = useState(null) const [form] = Form.useForm() - const filtered = mockTenants.filter(t => { - if (search && !t.name.includes(search) && !t.contact.includes(search)) return false - if (statusFilter && t.status !== statusFilter) return false - if (planFilter && t.plan !== planFilter) return false - return true - }) + useEffect(() => { loadTenants() }, [page, search, statusFilter]) + + const loadTenants = async () => { + setLoading(true) + try { + const res = await getTenants({ search, status: statusFilter, page }) + setTenants(res.list) + setTotal(res.total) + } catch { setTenants([]) } finally { setLoading(false) } + } const columns = [ - { title: '公司名称', dataIndex: 'name', key: 'name', render: (text: string) => {text} }, - { title: '联系人', dataIndex: 'contact', key: 'contact' }, - { title: '手机号', dataIndex: 'phone', key: 'phone', render: (t: string) => {t} }, - { title: '套餐', dataIndex: 'plan', key: 'plan', render: (p: string) => {p} }, - { title: '坐席数', dataIndex: 'seats', key: 'seats', align: 'center' as const }, - { title: '开通日期', dataIndex: 'startDate', key: 'startDate', render: (t: string) => {t} }, - { title: '到期日期', dataIndex: 'expireDate', key: 'expireDate', render: (t: string) => {t} }, - { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {statusConfig[s].text} }, - { title: '操作', key: 'actions', width: 220, render: (_: unknown, record: Tenant) => ( + { title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => {t} }, + { title: '联系人', dataIndex: 'contact_name', key: 'contact_name' }, + { title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => {t} }, + { title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', align: 'center' as const }, + { title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => {t ? new Date(t).toLocaleDateString() : '-'} }, + { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => {statusConfig[s]?.text || s} }, + { title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => ( - - {record.status === 'normal' && ( - e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}> - - - )} - {(record.status === 'suspended' || record.status === 'expired') && ( - - )} + {record.status === 'normal' && } + {(record.status === 'suspended' || record.status === 'expired') && } )}, ] @@ -79,48 +55,35 @@ const Tenants = () => {

租户管理

-
- } placeholder="搜索公司名称或联系人" value={search} onChange={e => setSearch(e.target.value)} className="w-56" allowClear /> - ({ value: v, label: v }))} /> + } placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear /> + - - - - - + + + + + - - {/* 查看详情 */} setDetailOpen(false)} width={400}> {selectedTenant && ( {selectedTenant.name} - {selectedTenant.contact} - {selectedTenant.phone} - {selectedTenant.email} - {selectedTenant.plan} - {selectedTenant.seats} - {selectedTenant.startDate} - {selectedTenant.expireDate} - {statusConfig[selectedTenant.status].text} + {selectedTenant.contact_name} + {selectedTenant.contact_phone} + {selectedTenant.contact_email} + {selectedTenant.seat_count} + {selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '-'} )} diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx index 1c1020c..2273882 100644 --- a/web/src/pages/agent/Knowledge.tsx +++ b/web/src/pages/agent/Knowledge.tsx @@ -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('product') + const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([]) + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedCategory, setSelectedCategory] = useState('') const [search, setSearch] = useState('') const [modalOpen, setModalOpen] = useState(false) const [editingEntry, setEditingEntry] = useState(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) => {text} }, - { title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (status: string) => ( - {status === 'published' ? '已发布' : '草稿'} - )}, - { title: '使用频率', dataIndex: 'usageCount', key: 'usageCount', width: 200, render: (count: number) => ( -
- - {count}次 -
- )}, - { title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 120, render: (text: string) => {text} }, - { title: '操作', key: 'actions', width: 120, render: (_: unknown, record: KnowledgeEntry) => ( - - - e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}> - }, ] return (
- {/* 左侧分类树 */}
@@ -101,54 +59,30 @@ const Knowledge = () => { { if (keys.length > 0) setSelectedCategory(keys[0] as string) }} + selectedKeys={selectedCategory ? [selectedCategory] : []} + onSelect={keys => setSelectedCategory(keys.length > 0 ? (keys[0] as string) : '')} blockNode />
- - {/* 右侧内容 */}

知识库

- } placeholder="搜索标题或内容..." value={search} onChange={e => setSearch(e.target.value)} className="w-56" size="small" allowClear /> + } placeholder="搜索..." value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-48" size="small" allowClear />
- +
-
- }} - /> +
`共 ${t} 条`, onChange: p => setPage(p) }} + locale={{ emptyText: }} /> - - setModalOpen(false)} - onOk={() => form.submit()} - width={640} - > -
setModalOpen(false)} className="mt-4"> - - - - - - + setModalOpen(false)} onOk={() => form.submit()} width={640}> + setModalOpen(false)} className="mt-4"> + + +