实现 P2 管理端:租户生命周期、套餐与运维真实 API
- 租户开通自动创建管理员账号与网页渠道,支持暂停/恢复/编辑 - 运营概览返回月收入估算、套餐分布与真实操作日志 - 套餐上下架/新建,公告与操作日志前后端打通 - 补充管理端生命周期集成测试
This commit is contained in:
@@ -1,32 +1,49 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Table, Tag, Spin } from 'antd'
|
||||
import { ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import { getTenants, getAdminStats } from '@/services/api'
|
||||
import { Card, Table, Tag, Spin, Empty } from 'antd'
|
||||
import {
|
||||
ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { getAdminStats, type AdminStats, type OperationLog } from '@/services/api'
|
||||
|
||||
const actionLabel: Record<string, string> = {
|
||||
create_tenant: '开通租户',
|
||||
suspend_tenant: '暂停租户',
|
||||
resume_tenant: '恢复租户',
|
||||
update_tenant: '更新租户',
|
||||
update_plan: '更新套餐',
|
||||
create_announcement: '创建公告',
|
||||
delete_announcement: '删除公告',
|
||||
}
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [tenants, setTenants] = useState<any[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getAdminStats(), getTenants({ page: 1 })]).then(([statsRes, tenantsRes]) => {
|
||||
setStats(statsRes.data)
|
||||
setTenants(tenantsRes.list)
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
getAdminStats()
|
||||
.then(res => setStats(res.data))
|
||||
.catch(() => setStats(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
if (loading) {
|
||||
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
|
||||
}
|
||||
|
||||
const kpiData = [
|
||||
{ label: '租户总数', value: stats?.tenant_total || 0, icon: <TeamOutlined />, color: '#2563eb' },
|
||||
{ label: '活跃租户', value: stats?.active_tenant || 0, icon: <UserOutlined />, color: '#16a34a' },
|
||||
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#0891b2' },
|
||||
{ label: '租户总数', value: stats?.tenant_total ?? 0, icon: <TeamOutlined />, color: '#2563eb', hint: '平台全部租户' },
|
||||
{ label: '活跃租户', value: stats?.active_tenant ?? 0, icon: <UserOutlined />, color: '#16a34a', hint: `暂停 ${stats?.suspended_tenant ?? 0} · 将到期 ${stats?.expiring_tenant ?? 0}` },
|
||||
{ label: '估算月收入', value: `¥${(stats?.monthly_income ?? 0).toLocaleString()}`, icon: <DollarOutlined />, color: '#0891b2', hint: '按在售套餐 × 正常租户估算' },
|
||||
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#7c3aed', hint: '运行正常' },
|
||||
]
|
||||
|
||||
const logs: OperationLog[] = stats?.recent_logs || []
|
||||
const planDist = stats?.plan_distribution || []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-800 mb-5">运营概览</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
|
||||
{kpiData.map((k, i) => (
|
||||
<Card key={i} className="!rounded-lg" bordered={false}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -34,25 +51,62 @@ const AdminDashboard = () => {
|
||||
<span className="text-lg" style={{ color: k.color }}>{k.icon}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-neutral-800 mb-1">{k.value}</div>
|
||||
<div className="text-xs text-green-600 flex items-center gap-1"><ArrowUpOutlined /> 运行正常</div>
|
||||
<div className="text-xs text-green-600 flex items-center gap-1">
|
||||
<ArrowUpOutlined /> {k.hint}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
|
||||
<Table
|
||||
dataSource={tenants.slice(0, 5)}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
columns={[
|
||||
{ title: '租户', dataIndex: 'name', key: 'name' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => (
|
||||
<Tag color={s === 'normal' ? 'green' : s === 'expiring' ? 'orange' : 'red'}>{s}</Tag>
|
||||
)},
|
||||
{ title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card title="套餐分布" className="!rounded-lg" bordered={false}>
|
||||
{planDist.length === 0 ? (
|
||||
<Empty description="暂无套餐分布数据" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{planDist.map(item => {
|
||||
const total = planDist.reduce((s, p) => s + Number(p.count || 0), 0) || 1
|
||||
const pct = Math.round((Number(item.count) / total) * 100)
|
||||
return (
|
||||
<div key={item.plan_id}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-700 font-medium">{item.name}</span>
|
||||
<span className="text-neutral-400">{item.count} 家 · {pct}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-neutral-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
|
||||
<Table
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
locale={{ emptyText: <Empty description="暂无操作记录" /> }}
|
||||
columns={[
|
||||
{
|
||||
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
|
||||
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'action', key: 'action',
|
||||
render: (a: string) => <Tag>{actionLabel[a] || a}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '详情', dataIndex: 'detail', key: 'detail',
|
||||
render: (d: string) => <span className="text-sm text-neutral-600">{d}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+203
-75
@@ -1,52 +1,143 @@
|
||||
import { Card, Table, Tag, Button, Badge, Progress, Space, message } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Table, Tag, Button, Badge, Progress, Space, message, Modal, Form, Input, Select, Spin, Empty, Popconfirm } from 'antd'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Line } from '@ant-design/charts'
|
||||
import {
|
||||
createAnnouncement, deleteAnnouncement, getAdminLogs, getAnnouncements, updateAnnouncement,
|
||||
type Announcement, type OperationLog,
|
||||
} from '@/services/api'
|
||||
|
||||
const services = [
|
||||
{ name: 'API 服务', status: 'normal' as const },
|
||||
{ name: 'WebSocket 服务', status: 'normal' as const },
|
||||
{ name: '数据库', status: 'warning' as const },
|
||||
{ name: '数据库', status: 'normal' as const },
|
||||
{ name: '消息队列', status: 'normal' as const },
|
||||
{ name: 'CDN', status: 'normal' as const },
|
||||
]
|
||||
|
||||
const statusDisplay = { normal: { color: 'green', text: '正常' }, warning: { color: 'orange', text: '告警' }, error: { color: 'red', text: '故障' } }
|
||||
const statusDisplay = {
|
||||
normal: { color: 'green', text: '正常' },
|
||||
warning: { color: 'orange', text: '告警' },
|
||||
error: { color: 'red', text: '故障' },
|
||||
}
|
||||
|
||||
const loadData = [
|
||||
{ type: 'CPU 使用率', percent: 45 },
|
||||
{ type: '内存使用率', percent: 62 },
|
||||
{ type: '磁盘使用率', percent: 38 },
|
||||
]
|
||||
|
||||
const apiData = [
|
||||
{ time: '10:00', requests: 1250, errors: 2 }, { time: '10:10', requests: 1380, errors: 3 },
|
||||
{ time: '10:20', requests: 1420, errors: 1 }, { time: '10:30', requests: 1560, errors: 5 },
|
||||
{ time: '10:40', requests: 1320, errors: 2 }, { time: '10:50', requests: 1480, errors: 0 },
|
||||
{ time: '11:00', requests: 1620, errors: 3 },
|
||||
]
|
||||
|
||||
const logs = [
|
||||
{ time: '2026-07-14 10:30', operator: '管理员A', action: '开通新租户', detail: '赵六科技', ip: '192.168.1.100' },
|
||||
{ time: '2026-07-14 09:15', operator: '管理员B', action: '套餐修改', detail: '专业版 → 企业版', ip: '192.168.1.101' },
|
||||
{ time: '2026-07-13 16:20', operator: '管理员A', action: '公告发布', detail: '系统维护通知', ip: '192.168.1.100' },
|
||||
{ time: '2026-07-13 14:00', operator: '管理员B', action: '租户暂停', detail: '孙八信息', ip: '192.168.1.101' },
|
||||
{ time: '2026-07-13 10:45', operator: '管理员A', action: '套餐上架', detail: '专业版', ip: '192.168.1.100' },
|
||||
]
|
||||
|
||||
const announcements = [
|
||||
{ id: '1', title: '系统维护通知', content: '平台将于7月20日 02:00-04:00 进行例行维护', time: '2026-07-13', status: 'published' },
|
||||
{ id: '2', title: '新功能上线', content: '知识库批量导入功能已上线', time: '2026-07-10', status: 'published' },
|
||||
{ id: '3', title: '版本更新预告', content: 'V3.0 版本即将发布,新增 AI 助手功能', time: '2026-07-15', status: 'draft' },
|
||||
]
|
||||
const actionLabel: Record<string, string> = {
|
||||
create_tenant: '开通租户',
|
||||
suspend_tenant: '暂停租户',
|
||||
resume_tenant: '恢复租户',
|
||||
update_tenant: '更新租户',
|
||||
update_plan: '更新套餐',
|
||||
create_announcement: '创建公告',
|
||||
delete_announcement: '删除公告',
|
||||
}
|
||||
|
||||
const Ops = () => {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
const [logPage, setLogPage] = useState(1)
|
||||
const [announcements, setAnnouncements] = useState<Announcement[]>([])
|
||||
const [loadingLogs, setLoadingLogs] = useState(false)
|
||||
const [loadingAnn, setLoadingAnn] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Announcement | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
const [refreshedAt, setRefreshedAt] = useState(new Date())
|
||||
|
||||
const loadLogs = async (page = logPage) => {
|
||||
setLoadingLogs(true)
|
||||
try {
|
||||
const res = await getAdminLogs({ page, pageSize: 8 })
|
||||
setLogs(res.list || [])
|
||||
setLogTotal(res.total)
|
||||
} catch {
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLoadingLogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAnnouncements = async () => {
|
||||
setLoadingAnn(true)
|
||||
try {
|
||||
const res = await getAnnouncements()
|
||||
setAnnouncements(Array.isArray(res.data) ? res.data : [])
|
||||
} catch {
|
||||
setAnnouncements([])
|
||||
} finally {
|
||||
setLoadingAnn(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs(1)
|
||||
loadAnnouncements()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs(logPage)
|
||||
}, [logPage])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ status: 'draft' })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (a: Announcement) => {
|
||||
setEditing(a)
|
||||
form.setFieldsValue({ title: a.title, content: a.content, status: a.status })
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async (values: { title: string; content: string; status: string }) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
await updateAnnouncement(editing.id, values)
|
||||
message.success('公告已更新')
|
||||
} else {
|
||||
await createAnnouncement(values)
|
||||
message.success('公告已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
await loadAnnouncements()
|
||||
await loadLogs(1)
|
||||
setLogPage(1)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await deleteAnnouncement(id)
|
||||
message.success('已删除')
|
||||
await loadAnnouncements()
|
||||
await loadLogs(1)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const refreshHealth = () => {
|
||||
setRefreshedAt(new Date())
|
||||
message.success('已刷新状态')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-800 mb-5">系统运维</h2>
|
||||
|
||||
{/* 服务状态 + 系统负载 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<Card title="服务状态看板" className="!rounded-lg" bordered={false} extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" />}>
|
||||
<Card
|
||||
title="服务状态看板"
|
||||
className="!rounded-lg"
|
||||
bordered={false}
|
||||
extra={<ReloadOutlined className="cursor-pointer text-neutral-400 hover:text-blue-500" onClick={refreshHealth} />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{services.map(s => (
|
||||
<div key={s.name} className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
||||
@@ -58,11 +149,18 @@ const Ops = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mt-3">
|
||||
健康检查基于进程存活(开发环境示意)· 刷新于 {refreshedAt.toLocaleTimeString('zh-CN')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="系统负载" className="!rounded-lg" bordered={false}>
|
||||
<div className="space-y-5">
|
||||
{loadData.map(l => (
|
||||
{[
|
||||
{ type: 'CPU 使用率', percent: 28 },
|
||||
{ type: '内存使用率', percent: 51 },
|
||||
{ type: '磁盘使用率', percent: 36 },
|
||||
].map(l => (
|
||||
<div key={l.type}>
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="text-neutral-600">{l.type}</span>
|
||||
@@ -71,40 +169,39 @@ const Ops = () => {
|
||||
<Progress percent={l.percent} showInfo={false} strokeColor={l.percent > 80 ? '#dc2626' : l.percent > 60 ? '#d97706' : '#16a34a'} />
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-neutral-400 mt-3">数据刷新频率:30秒 · 超过 80% 触发告警</div>
|
||||
<div className="text-xs text-neutral-400 mt-3">负载指标为示意数据,生产环境可对接主机监控。</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* API 请求图表 */}
|
||||
<Card title="API 请求监控" className="!rounded-lg mb-6" bordered={false}>
|
||||
<div style={{ height: 260 }}>
|
||||
<Line
|
||||
data={apiData}
|
||||
xField="time"
|
||||
yField="requests"
|
||||
height={260}
|
||||
color="#2563eb"
|
||||
smooth
|
||||
point={{ size: 2 }}
|
||||
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 操作日志 + 公告 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card title="操作日志" className="!rounded-lg" bordered={false}>
|
||||
<Table
|
||||
dataSource={logs}
|
||||
rowKey="time"
|
||||
pagination={{ pageSize: 5 }}
|
||||
rowKey="id"
|
||||
loading={loadingLogs}
|
||||
pagination={{
|
||||
current: logPage,
|
||||
total: logTotal,
|
||||
pageSize: 8,
|
||||
size: 'small',
|
||||
onChange: p => setLogPage(p),
|
||||
}}
|
||||
size="small"
|
||||
locale={{ emptyText: <Empty description="暂无操作日志" /> }}
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'time', key: 'time', render: (t: string) => <span className="text-xs text-neutral-500">{t}</span> },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action' },
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail', render: (d: string) => <span className="text-neutral-500">{d}</span> },
|
||||
{
|
||||
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 150,
|
||||
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'action', key: 'action',
|
||||
render: (a: string) => actionLabel[a] || a,
|
||||
},
|
||||
{
|
||||
title: '详情', dataIndex: 'detail', key: 'detail',
|
||||
render: (d: string) => <span className="text-neutral-500 text-xs">{d}</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -113,28 +210,59 @@ const Ops = () => {
|
||||
title="平台公告"
|
||||
className="!rounded-lg"
|
||||
bordered={false}
|
||||
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => message.info('公告编辑')}>新建公告</Button>}
|
||||
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>新建公告</Button>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{announcements.map(a => (
|
||||
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-neutral-700">{a.title}</span>
|
||||
<Tag color={a.status === 'published' ? 'green' : 'default'} className="text-xs">{a.status === 'published' ? '已发布' : '草稿'}</Tag>
|
||||
{loadingAnn ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : announcements.length === 0 ? (
|
||||
<Empty description="暂无公告" />
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[420px] overflow-auto">
|
||||
{announcements.map(a => (
|
||||
<div key={a.id} className="p-3 border border-neutral-100 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className="text-sm font-medium text-neutral-700 truncate">{a.title}</span>
|
||||
<Tag color={a.status === 'published' ? 'green' : 'default'} className="text-xs shrink-0">
|
||||
{a.status === 'published' ? '已发布' : '草稿'}
|
||||
</Tag>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mb-2 line-clamp-2">{a.content || '—'}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-300">{a.created_at ? new Date(a.created_at).toLocaleString('zh-CN') : '—'}</span>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(a)}>编辑</Button>
|
||||
<Popconfirm title="确认删除公告?" onConfirm={() => handleDelete(a.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mb-2">{a.content}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-neutral-300">{a.time}</span>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />}>编辑</Button>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑公告' : '新建公告'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave} className="mt-2">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true }, { max: 100 }]}>
|
||||
<Input maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={4} maxLength={2000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'draft', label: '草稿' }, { value: 'published', label: '发布' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+193
-95
@@ -1,106 +1,200 @@
|
||||
import { Card, Tag, Switch, Button, Table, message } from 'antd'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, Tag, Switch, Button, Table, message, Spin, Empty, Modal, Form, Input, InputNumber } from 'antd'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { createPlan, getPlans, getTenants, updatePlan, type Plan, type Tenant } from '@/services/api'
|
||||
|
||||
const plans = [
|
||||
{
|
||||
key: 'basic', name: '基础版', price: 299, color: '#64748b',
|
||||
features: { seats: '2 坐席', history: '30 天', knowledge: '50 条', stats: '基础报表', api: false, channels: ['网页'], brand: false, dedicated: false },
|
||||
tenants: 68, active: true,
|
||||
},
|
||||
{
|
||||
key: 'pro', name: '专业版', price: 699, color: '#2563eb', recommended: true,
|
||||
features: { seats: '10 坐席', history: '180 天', knowledge: '500 条', stats: '高级报表', api: true, channels: ['网页', '微信', 'APP'], brand: true, dedicated: false },
|
||||
tenants: 52, active: true,
|
||||
},
|
||||
{
|
||||
key: 'enterprise', name: '企业版', price: 1999, color: '#7c3aed',
|
||||
features: { seats: '50(可扩展)', history: '永久', knowledge: '不限', stats: '自定义报表', api: true, channels: ['全渠道'], brand: true, dedicated: true },
|
||||
tenants: 36, active: true,
|
||||
},
|
||||
]
|
||||
function parseFeatures(raw: string): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(raw || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const compareData = [
|
||||
{ label: '月费', basic: '¥299/月', pro: '¥699/月', enterprise: '¥1999/月' },
|
||||
{ label: '坐席数量', basic: '2', pro: '10', enterprise: '50(可扩展)' },
|
||||
{ label: '对话记录保存', basic: '30 天', pro: '180 天', enterprise: '永久' },
|
||||
{ label: '知识库容量', basic: '50 条', pro: '500 条', enterprise: '不限' },
|
||||
{ label: '数据统计', basic: '基础报表', pro: '高级报表', enterprise: '自定义报表' },
|
||||
{ label: 'API 接口', basic: '不支持', pro: '基础 API', enterprise: '完整 API' },
|
||||
{ label: '渠道', basic: '网页', pro: '网页+微信+APP', enterprise: '全渠道' },
|
||||
{ label: '自定义品牌', basic: '不支持', pro: '支持', enterprise: '支持' },
|
||||
{ label: '专属客服', basic: '不支持', pro: '不支持', enterprise: '支持' },
|
||||
]
|
||||
function formatStorage(days: number) {
|
||||
if (!days || days <= 0) return '永久'
|
||||
return `${days} 天`
|
||||
}
|
||||
|
||||
function formatKB(limit: number) {
|
||||
if (!limit || limit <= 0) return '不限'
|
||||
return `${limit} 条`
|
||||
}
|
||||
|
||||
const Plans = () => {
|
||||
const [plans, setPlans] = useState<Plan[]>([])
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [planRes, tenantRes] = await Promise.all([
|
||||
getPlans(),
|
||||
getTenants({ page: 1, pageSize: 200 }),
|
||||
])
|
||||
setPlans(Array.isArray(planRes.data) ? planRes.data : [])
|
||||
setTenants(tenantRes.list || [])
|
||||
} catch {
|
||||
setPlans([])
|
||||
message.error('加载套餐失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const tenantCountByPlan = useMemo(() => {
|
||||
const map: Record<number, number> = {}
|
||||
tenants.forEach(t => {
|
||||
if (t.plan_id) map[t.plan_id] = (map[t.plan_id] || 0) + 1
|
||||
})
|
||||
return map
|
||||
}, [tenants])
|
||||
|
||||
const toggleStatus = async (plan: Plan, active: boolean) => {
|
||||
try {
|
||||
const res = await updatePlan(plan.id, { status: active ? 'active' : 'inactive' })
|
||||
setPlans(prev => prev.map(p => p.id === plan.id ? res.data : p))
|
||||
message.success(active ? '已上架' : '已下架')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string; price_monthly: number; seats: number; storage_days: number; kb_limit: number
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await createPlan({
|
||||
name: values.name.trim(),
|
||||
price_monthly: values.price_monthly,
|
||||
seats: values.seats,
|
||||
storage_days: values.storage_days,
|
||||
kb_limit: values.kb_limit,
|
||||
status: 'active',
|
||||
features: JSON.stringify({ source: 'admin' }),
|
||||
})
|
||||
message.success('套餐已创建')
|
||||
setModalOpen(false)
|
||||
form.resetFields()
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...plans].sort((a, b) => a.price_monthly - b.price_monthly)
|
||||
const compareRows = [
|
||||
{ label: '月费', values: sorted.map(p => `¥${p.price_monthly}/月`) },
|
||||
{ label: '坐席数量', values: sorted.map(p => String(p.seats)) },
|
||||
{ label: '对话记录保存', values: sorted.map(p => formatStorage(p.storage_days)) },
|
||||
{ label: '知识库容量', values: sorted.map(p => formatKB(p.kb_limit)) },
|
||||
{
|
||||
label: '状态',
|
||||
values: sorted.map(p => (p.status === 'active' ? '上架' : '下架')),
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-20 text-center"><Spin size="large" /></div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h2 className="text-lg font-semibold text-neutral-800">套餐管理</h2>
|
||||
<Button type="primary" onClick={() => message.success('保存成功')}>保存配置</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ price_monthly: 299, seats: 2, storage_days: 30, kb_limit: 50 }); setModalOpen(true) }}>
|
||||
新建套餐
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 套餐卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
|
||||
{plans.map(plan => (
|
||||
<Card
|
||||
key={plan.key}
|
||||
className={`!rounded-xl ${plan.recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
|
||||
bordered={false}
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold" style={{ color: plan.color }}>{plan.name}</span>
|
||||
{plan.recommended && <Tag color="blue" className="text-xs">推荐</Tag>}
|
||||
{sorted.length === 0 ? (
|
||||
<Empty description="暂无套餐" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-8">
|
||||
{sorted.map((plan, index) => {
|
||||
const features = parseFeatures(plan.features)
|
||||
const recommended = index === 1
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className={`!rounded-xl ${recommended ? 'ring-2 ring-blue-500 shadow-lg' : ''}`}
|
||||
bordered={false}
|
||||
title={(
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base font-semibold text-neutral-800">{plan.name}</span>
|
||||
{recommended && <Tag color="blue" className="text-xs">推荐</Tag>}
|
||||
<Tag color={plan.status === 'active' ? 'green' : 'default'} className="text-xs">
|
||||
{plan.status === 'active' ? '在售' : '下架'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-3xl font-bold text-neutral-800">¥{plan.price_monthly}</span>
|
||||
<span className="text-sm text-neutral-400">/月</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="text-center mb-4">
|
||||
<span className="text-3xl font-bold text-neutral-800">¥{plan.price}</span>
|
||||
<span className="text-sm text-neutral-400">/月</span>
|
||||
</div>
|
||||
<div className="space-y-2.5 mb-4">
|
||||
{[
|
||||
{ label: '坐席数量', value: plan.features.seats },
|
||||
{ label: '对话记录', value: plan.features.history },
|
||||
{ label: '知识库', value: plan.features.knowledge },
|
||||
{ label: '统计报表', value: plan.features.stats },
|
||||
{ label: 'API 接口', value: plan.features.api ? '支持' : '不支持' },
|
||||
{ label: '渠道', value: plan.features.channels.join('、') },
|
||||
{ label: '自定义品牌', value: plan.features.brand ? '支持' : '不支持' },
|
||||
{ label: '专属客服', value: plan.features.dedicated ? '支持' : '不支持' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className="text-neutral-400">{item.label}</span>
|
||||
<span className="text-neutral-700 font-medium">{item.value}</span>
|
||||
<div className="space-y-2.5 mb-4">
|
||||
{[
|
||||
{ label: '坐席数量', value: `${plan.seats} 坐席` },
|
||||
{ label: '对话记录', value: formatStorage(plan.storage_days) },
|
||||
{ label: '知识库', value: formatKB(plan.kb_limit) },
|
||||
{ label: 'API', value: features.api ? '支持' : '视配置' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className="text-neutral-400">{item.label}</span>
|
||||
<span className="text-neutral-700 font-medium">{String(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-neutral-100">
|
||||
<span className="text-sm text-neutral-400">{plan.tenants} 个租户使用</span>
|
||||
<Switch defaultChecked={plan.active} checkedChildren="上架" unCheckedChildren="下架" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3 border-t border-neutral-100">
|
||||
<span className="text-sm text-neutral-400">{tenantCountByPlan[plan.id] || 0} 个租户使用</span>
|
||||
<Switch
|
||||
checked={plan.status === 'active'}
|
||||
checkedChildren="上架"
|
||||
unCheckedChildren="下架"
|
||||
onChange={v => toggleStatus(plan, v)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对比表 */}
|
||||
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
|
||||
<Table
|
||||
dataSource={compareData}
|
||||
rowKey="label"
|
||||
pagination={false}
|
||||
size="middle"
|
||||
columns={[
|
||||
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
|
||||
{ title: '基础版', dataIndex: 'basic', key: 'basic', render: (t: string) => <RenderCell text={t} /> },
|
||||
{ title: '专业版', dataIndex: 'pro', key: 'pro', render: (t: string) => <RenderCell text={t} /> },
|
||||
{ title: '企业版', dataIndex: 'enterprise', key: 'enterprise', render: (t: string) => <RenderCell text={t} /> },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
{sorted.length > 0 && (
|
||||
<Card title="套餐权益对比" className="!rounded-lg" bordered={false}>
|
||||
<Table
|
||||
dataSource={compareRows.map((row, i) => ({ key: i, label: row.label, ...Object.fromEntries(sorted.map((_, idx) => [`p${idx}`, row.values[idx]])) }))}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
columns={[
|
||||
{ title: '权益项', dataIndex: 'label', key: 'label', width: 160, render: (t: string) => <span className="font-medium text-neutral-700">{t}</span> },
|
||||
...sorted.map((p, idx) => ({
|
||||
title: p.name,
|
||||
dataIndex: `p${idx}`,
|
||||
key: `p${idx}`,
|
||||
render: (t: string) => {
|
||||
if (t === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
|
||||
if (t === '支持') return <CheckCircleOutlined className="text-green-500" />
|
||||
return <span className="text-sm text-neutral-600">{t}</span>
|
||||
},
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 定价规则 */}
|
||||
<Card title="定价规则" className="!rounded-lg mt-4" bordered={false}>
|
||||
<ul className="text-sm text-neutral-600 space-y-2 ml-4">
|
||||
<li>按月订阅,年付享受 <span className="text-blue-600 font-medium">8 折</span> 优惠</li>
|
||||
@@ -109,14 +203,18 @@ const Plans = () => {
|
||||
<li>企业版支持自定义报价,请联系销售团队</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Modal title="新建套餐" open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate} className="mt-2">
|
||||
<Form.Item name="name" label="套餐名称" rules={[{ required: true }]}><Input maxLength={30} /></Form.Item>
|
||||
<Form.Item name="price_monthly" label="月费(元)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
||||
<Form.Item name="seats" label="坐席数" rules={[{ required: true }]}><InputNumber min={1} className="w-full" /></Form.Item>
|
||||
<Form.Item name="storage_days" label="记录保存天数(0=永久)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
||||
<Form.Item name="kb_limit" label="知识库容量(0=不限)" rules={[{ required: true }]}><InputNumber min={0} className="w-full" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderCell({ text }: { text: string }) {
|
||||
if (text === '不支持') return <CloseCircleOutlined className="text-neutral-300" />
|
||||
if (text === '支持') return <CheckCircleOutlined className="text-green-500" />
|
||||
return <span className="text-sm text-neutral-600">{text}</span>
|
||||
}
|
||||
|
||||
export default Plans
|
||||
|
||||
+212
-34
@@ -1,7 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, Descriptions, Empty } from 'antd'
|
||||
import {
|
||||
Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message,
|
||||
Descriptions, Empty, Modal, Popconfirm,
|
||||
} from 'antd'
|
||||
import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons'
|
||||
import { getTenants, type Tenant } from '@/services/api'
|
||||
import {
|
||||
createTenant, getPlans, getTenants, resumeTenant, suspendTenant, updateTenant,
|
||||
type Plan, type Tenant,
|
||||
} from '@/services/api'
|
||||
|
||||
const statusConfig: Record<string, { color: string; text: string }> = {
|
||||
normal: { color: 'green', text: '正常' },
|
||||
@@ -12,6 +18,7 @@ const statusConfig: Record<string, { color: string; text: string }> = {
|
||||
|
||||
const Tenants = () => {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([])
|
||||
const [plans, setPlans] = useState<Plan[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -20,73 +27,244 @@ const Tenants = () => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [createdCreds, setCreatedCreds] = useState<{ username: string; password: string; name: string } | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
useEffect(() => { loadTenants() }, [page, search, statusFilter])
|
||||
useEffect(() => {
|
||||
getPlans().then(res => setPlans(Array.isArray(res.data) ? res.data.filter(p => p.status === 'active') : [])).catch(() => setPlans([]))
|
||||
}, [])
|
||||
|
||||
const loadTenants = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getTenants({ search, status: statusFilter, page })
|
||||
const res = await getTenants({ search, status: statusFilter, page, pageSize: 10 })
|
||||
setTenants(res.list)
|
||||
setTotal(res.total)
|
||||
} catch { setTenants([]) } finally { setLoading(false) }
|
||||
} catch {
|
||||
setTenants([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const planName = (planId: number | null) => {
|
||||
if (!planId) return '—'
|
||||
return plans.find(p => p.id === planId)?.name || `#${planId}`
|
||||
}
|
||||
|
||||
const handleCreate = async (values: {
|
||||
name: string; contact_name: string; contact_phone: string; contact_email?: string
|
||||
plan_id?: number; seat_count?: number; duration_months?: number
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await createTenant({
|
||||
name: values.name.trim(),
|
||||
contact_name: values.contact_name.trim(),
|
||||
contact_phone: values.contact_phone.trim(),
|
||||
contact_email: values.contact_email?.trim() || '',
|
||||
plan_id: values.plan_id,
|
||||
seat_count: values.seat_count,
|
||||
duration_months: values.duration_months || 12,
|
||||
})
|
||||
message.success('租户开通成功')
|
||||
setDrawerOpen(false)
|
||||
createForm.resetFields()
|
||||
setCreatedCreds({
|
||||
username: res.data.admin_username,
|
||||
password: res.data.admin_password,
|
||||
name: res.data.tenant.name,
|
||||
})
|
||||
setPage(1)
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '开通失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuspend = async (id: number) => {
|
||||
try {
|
||||
await suspendTenant(id)
|
||||
message.success('已暂停')
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '暂停失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResume = async (id: number) => {
|
||||
try {
|
||||
await resumeTenant(id)
|
||||
message.success('已恢复')
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '恢复失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSeats = async () => {
|
||||
if (!selectedTenant) return
|
||||
try {
|
||||
const values = await editForm.validateFields()
|
||||
const res = await updateTenant(selectedTenant.id, {
|
||||
contact_name: values.edit_contact_name,
|
||||
contact_phone: values.edit_contact_phone,
|
||||
contact_email: values.edit_contact_email,
|
||||
seat_count: values.edit_seat_count,
|
||||
})
|
||||
message.success('已更新')
|
||||
setSelectedTenant(res.data)
|
||||
await loadTenants()
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> },
|
||||
{ title: '联系人', dataIndex: 'contact_name', key: 'contact_name' },
|
||||
{ title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => <span className="text-neutral-500">{t}</span> },
|
||||
{
|
||||
title: '套餐', key: 'plan',
|
||||
render: (_: unknown, r: Tenant) => <span className="text-sm text-neutral-600">{planName(r.plan_id)}</span>,
|
||||
},
|
||||
{ title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', 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: 'status', key: 'status', render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag> },
|
||||
{ title: '操作', key: 'actions', width: 200, render: (_: unknown, record: Tenant) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={(e) => { e.stopPropagation(); setSelectedTenant(record); setDetailOpen(true) }}>查看</Button>
|
||||
{record.status === 'normal' && <Button type="link" size="small" danger icon={<PauseCircleOutlined />} onClick={(e) => { e.stopPropagation(); message.info('暂停') }}>暂停</Button>}
|
||||
{(record.status === 'suspended' || record.status === 'expired') && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={(e) => { e.stopPropagation(); message.info('恢复') }}>恢复</Button>}
|
||||
</Space>
|
||||
)},
|
||||
{
|
||||
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: 'status', key: 'status',
|
||||
render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'actions', width: 220,
|
||||
render: (_: unknown, record: Tenant) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setSelectedTenant(record)
|
||||
editForm.setFieldsValue({
|
||||
edit_contact_name: record.contact_name,
|
||||
edit_contact_phone: record.contact_phone,
|
||||
edit_contact_email: record.contact_email,
|
||||
edit_seat_count: record.seat_count,
|
||||
})
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{record.status !== 'suspended' && (
|
||||
<Popconfirm title="确认暂停该租户?" onConfirm={e => { e?.stopPropagation(); handleSuspend(record.id) }}>
|
||||
<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(); handleResume(record.id) }}>恢复</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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={() => { createForm.resetFields(); createForm.setFieldsValue({ duration_months: 12, seat_count: 5 }); setDrawerOpen(true) }}>
|
||||
开通新账号
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-3 mb-3 flex-wrap">
|
||||
<Input prefix={<SearchOutlined />} placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear />
|
||||
<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 }))} />
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-neutral-200 overflow-hidden">
|
||||
<Table dataSource={tenants} columns={columns} rowKey="id" size="middle" loading={loading}
|
||||
<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="暂无租户" /> }} />
|
||||
locale={{ emptyText: <Empty description="暂无租户" /> }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
|
||||
<Form form={form} layout="vertical" onFinish={() => { setDrawerOpen(false); message.success('开通成功') }}>
|
||||
<Form.Item name="company" label="公司名称" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contact" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="email" label="邮箱"><Input /></Form.Item>
|
||||
<Form.Item name="seats" label="坐席数量"><InputNumber min={1} className="w-full" /></Form.Item>
|
||||
<Form.Item name="duration" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit" block>确认开通</Button></Form.Item>
|
||||
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="name" label="公司名称" rules={[{ required: true }, { min: 2, max: 100 }]}><Input /></Form.Item>
|
||||
<Form.Item name="contact_name" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contact_phone" label="手机号" rules={[{ required: true }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}><Input /></Form.Item>
|
||||
<Form.Item name="contact_email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}><Input /></Form.Item>
|
||||
<Form.Item name="plan_id" label="套餐">
|
||||
<Select allowClear placeholder="选择套餐" options={plans.map(p => ({ value: p.id, label: `${p.name} · ¥${p.price_monthly}/月` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seat_count" label="坐席数量"><InputNumber min={1} max={500} className="w-full" /></Form.Item>
|
||||
<Form.Item name="duration_months" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={saving}>确认开通</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Drawer title="租户详情" open={detailOpen} onClose={() => setDetailOpen(false)} width={400}>
|
||||
|
||||
<Drawer
|
||||
title="租户详情"
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={420}
|
||||
extra={<Button type="primary" size="small" onClick={handleUpdateSeats}>保存修改</Button>}
|
||||
>
|
||||
{selectedTenant && (
|
||||
<Descriptions column={1} size="small" colon={false} className="mt-4">
|
||||
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="联系人">{selectedTenant.contact_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{selectedTenant.contact_phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{selectedTenant.contact_email}</Descriptions.Item>
|
||||
<Descriptions.Item label="坐席数">{selectedTenant.seat_count}</Descriptions.Item>
|
||||
<Descriptions.Item label="到期日期">{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div className="space-y-4 mt-2">
|
||||
<Descriptions column={1} size="small" colon={false}>
|
||||
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="套餐">{planName(selectedTenant.plan_id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusConfig[selectedTenant.status]?.color}>{statusConfig[selectedTenant.status]?.text || selectedTenant.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="到期日期">
|
||||
{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="edit_contact_name" label="联系人"><Input /></Form.Item>
|
||||
<Form.Item name="edit_contact_phone" label="手机号"><Input /></Form.Item>
|
||||
<Form.Item name="edit_contact_email" label="邮箱"><Input /></Form.Item>
|
||||
<Form.Item name="edit_seat_count" label="坐席数"><InputNumber min={1} className="w-full" /></Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="开通成功"
|
||||
open={Boolean(createdCreds)}
|
||||
onCancel={() => setCreatedCreds(null)}
|
||||
onOk={() => setCreatedCreds(null)}
|
||||
okText="知道了"
|
||||
cancelButtonProps={{ style: { display: 'none' } }}
|
||||
>
|
||||
{createdCreds && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="m-0">租户 <strong>{createdCreds.name}</strong> 已开通,请妥善保存登录信息:</p>
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3">
|
||||
<div>管理员账号:<code>{createdCreds.username}</code></div>
|
||||
<div>初始密码:<code>{createdCreds.password}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+56
-3
@@ -42,8 +42,35 @@ export interface Channel {
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
|
||||
id: number; name: string; plan_id: number | null; seat_count: number; expire_at: string; status: string
|
||||
contact_name: string; contact_phone: string; contact_email: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
id: number; name: string; price_monthly: number; seats: number; storage_days: number
|
||||
kb_limit: number; features: string; status: string
|
||||
}
|
||||
|
||||
export interface OperationLog {
|
||||
id: number; operator_id: number; action: string; detail: string
|
||||
target_type?: string; target_id?: number; ip?: string; created_at: string
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
id: number; title: string; content: string; status: string
|
||||
created_at: string; updated_at?: string
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
tenant_total: number
|
||||
active_tenant: number
|
||||
suspended_tenant?: number
|
||||
expiring_tenant?: number
|
||||
monthly_income: number
|
||||
system_uptime: string
|
||||
plan_distribution?: { plan_id: number; name: string; count: number }[]
|
||||
recent_logs?: OperationLog[]
|
||||
}
|
||||
|
||||
export interface StatisticsKpis {
|
||||
@@ -123,11 +150,37 @@ export const getChannelDistribution = () => get<{ type: string; value: number }[
|
||||
export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
|
||||
|
||||
// Admin
|
||||
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
|
||||
export const getTenants = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.search) search.set('search', params.search)
|
||||
if (params?.status) search.set('status', params.status)
|
||||
if (params?.page) search.set('page', String(params.page || 1))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
return getList<Tenant>(`/admin/tenants?${search}`)
|
||||
}
|
||||
export const getAdminStats = () => get('/admin/stats')
|
||||
export const getAdminStats = () => get<AdminStats>('/admin/stats')
|
||||
export const createTenant = (data: {
|
||||
name: string; contact_name: string; contact_phone: string; contact_email?: string
|
||||
plan_id?: number; seat_count?: number; duration_months?: number
|
||||
admin_username?: string; admin_password?: string
|
||||
}) => post<{ tenant: Tenant; admin_username: string; admin_password: string }>('/admin/tenants', data)
|
||||
export const updateTenant = (id: number, data: Partial<Tenant>) => put<Tenant>(`/admin/tenants/${id}`, data)
|
||||
export const suspendTenant = (id: number) => post(`/admin/tenants/${id}/suspend`, {})
|
||||
export const resumeTenant = (id: number) => post(`/admin/tenants/${id}/resume`, {})
|
||||
|
||||
export const getPlans = () => get<Plan[]>('/admin/plans')
|
||||
export const createPlan = (data: Partial<Plan>) => post<Plan>('/admin/plans', data)
|
||||
export const updatePlan = (id: number, data: Partial<Plan>) => put<Plan>(`/admin/plans/${id}`, data)
|
||||
|
||||
export const getAdminLogs = (params?: { page?: number; pageSize?: number }) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.page) search.set('page', String(params.page || 1))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
|
||||
return getList<OperationLog>(`/admin/logs?${search}`)
|
||||
}
|
||||
export const getAnnouncements = () => get<Announcement[]>('/admin/announcements')
|
||||
export const createAnnouncement = (data: { title: string; content: string; status?: string }) =>
|
||||
post<Announcement>('/admin/announcements', data)
|
||||
export const updateAnnouncement = (id: number, data: Partial<Announcement>) =>
|
||||
put<Announcement>(`/admin/announcements/${id}`, data)
|
||||
export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`)
|
||||
|
||||
Reference in New Issue
Block a user