diff --git a/web/src/pages/admin/Tenants.tsx b/web/src/pages/admin/Tenants.tsx index b5d40f6..512261d 100644 --- a/web/src/pages/admin/Tenants.tsx +++ b/web/src/pages/admin/Tenants.tsx @@ -1,19 +1,77 @@ import { useState, useEffect } from 'react' import { - Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, - Descriptions, Empty, Modal, Popconfirm, + Input, Select, Button, Drawer, Form, InputNumber, message, + Empty, Modal, Popconfirm, Spin, Pagination, } from 'antd' -import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons' +import { + PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, + CloseOutlined, CalendarOutlined, +} from '@ant-design/icons' import { createTenant, getPlans, getTenants, resumeTenant, suspendTenant, updateTenant, type Plan, type Tenant, } from '@/services/api' -const statusConfig: Record = { - normal: { color: 'green', text: '正常' }, - expiring: { color: 'orange', text: '即将到期' }, - suspended: { color: 'red', text: '已暂停' }, - expired: { color: 'default', text: '已过期' }, +const statusMeta: Record = { + normal: { text: '正常', bg: '#f0fdf4', color: '#16a34a', dot: '#16a34a' }, + expiring: { text: '即将到期', bg: '#fffbeb', color: '#d97706', dot: '#d97706' }, + suspended: { text: '已暂停', bg: '#f1f5f9', color: '#64748b', dot: '#94a3b8' }, + expired: { text: '已过期', bg: '#fef2f2', color: '#dc2626', dot: '#dc2626' }, +} + +const statusFilters = [ + { key: undefined as string | undefined, label: '全部' }, + { key: 'normal', label: '正常' }, + { key: 'expiring', label: '即将到期' }, + { key: 'suspended', label: '已暂停' }, + { key: 'expired', label: '已过期' }, +] + +const avatarPalettes = [ + { bg: '#dbeafe', color: '#1d4ed8' }, + { bg: '#fef3c7', color: '#92400e' }, + { bg: '#d1fae5', color: '#047857' }, + { bg: '#ede9fe', color: '#6d28d9' }, + { bg: '#ffedd5', color: '#c2410c' }, + { bg: '#fce7f3', color: '#be185d' }, +] + +function avatarPalette(name: string) { + let h = 0 + for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h) + return avatarPalettes[Math.abs(h) % avatarPalettes.length] +} + +function maskPhone(phone?: string) { + if (!phone) return '—' + const d = phone.replace(/\D/g, '') + if (d.length === 11) return `${d.slice(0, 3)}****${d.slice(7)}` + return phone +} + +function fmtDate(iso?: string) { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('zh-CN') +} + +function planBadgeStyle(name: string) { + if (name.includes('企业')) return { bg: '#dbeafe', color: '#2563eb' } + if (name.includes('专业')) return { bg: '#f3e8ff', color: '#7c3aed' } + if (name.includes('基础')) return { bg: '#f0fdf4', color: '#16a34a' } + return { bg: '#f1f5f9', color: '#64748b' } +} + +const StatusPill = ({ status }: { status: string }) => { + const s = statusMeta[status] || statusMeta.normal + return ( + + + {s.text} + + ) } const Tenants = () => { @@ -22,25 +80,30 @@ const Tenants = () => { const [loading, setLoading] = useState(true) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) const [search, setSearch] = useState('') + const [searchInput, setSearchInput] = useState('') const [statusFilter, setStatusFilter] = useState() const [drawerOpen, setDrawerOpen] = useState(false) const [detailOpen, setDetailOpen] = useState(false) const [selectedTenant, setSelectedTenant] = useState(null) const [saving, setSaving] = useState(false) + const [renewMonths, setRenewMonths] = useState(12) 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(() => { loadTenants() }, [page, pageSize, search, statusFilter]) useEffect(() => { - getPlans().then(res => setPlans(Array.isArray(res.data) ? res.data.filter(p => p.status === 'active') : [])).catch(() => setPlans([])) + 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, pageSize: 10 }) + const res = await getTenants({ search, status: statusFilter, page, pageSize }) setTenants(res.list) setTotal(res.total) } catch { @@ -55,6 +118,28 @@ const Tenants = () => { return plans.find(p => p.id === planId)?.name || `#${planId}` } + const openCreate = () => { + createForm.resetFields() + createForm.setFieldsValue({ duration_months: 12, seat_count: 5 }) + setDrawerOpen(true) + } + + const openDetail = (record: Tenant, editFocus = false) => { + 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, + edit_plan_id: record.plan_id, + }) + setRenewMonths(12) + setDetailOpen(true) + if (editFocus) { + // detail drawer shows edit form always + } + } + const handleCreate = async (values: { name: string; contact_name: string; contact_phone: string; contact_email?: string plan_id?: number; seat_count?: number; duration_months?: number @@ -91,6 +176,9 @@ const Tenants = () => { try { await suspendTenant(id) message.success('已暂停') + if (selectedTenant?.id === id) { + setSelectedTenant({ ...selectedTenant, status: 'suspended' }) + } await loadTenants() } catch (e) { message.error(e instanceof Error ? e.message : '暂停失败') @@ -101,6 +189,9 @@ const Tenants = () => { try { await resumeTenant(id) message.success('已恢复') + if (selectedTenant?.id === id) { + setSelectedTenant({ ...selectedTenant, status: 'normal' }) + } await loadTenants() } catch (e) { message.error(e instanceof Error ? e.message : '恢复失败') @@ -109,6 +200,7 @@ const Tenants = () => { const handleUpdateSeats = async () => { if (!selectedTenant) return + setSaving(true) try { const values = await editForm.validateFields() const res = await updateTenant(selectedTenant.id, { @@ -116,133 +208,433 @@ const Tenants = () => { contact_phone: values.edit_contact_phone, contact_email: values.edit_contact_email, seat_count: values.edit_seat_count, + plan_id: values.edit_plan_id, }) message.success('已更新') setSelectedTenant(res.data) await loadTenants() } catch (e) { if (e instanceof Error && e.message) message.error(e.message) + } finally { + setSaving(false) } } - const columns = [ - { 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: '套餐', key: 'plan', - render: (_: unknown, r: Tenant) => {planName(r.plan_id)}, - }, - { 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: 220, - render: (_: unknown, record: Tenant) => ( - - - {record.status !== 'suspended' && ( - { e?.stopPropagation(); handleSuspend(record.id) }}> - - - )} - {(record.status === 'suspended' || record.status === 'expired') && ( - - )} - - ), - }, - ] + const handleRenew = async () => { + if (!selectedTenant || renewMonths < 1) return + setSaving(true) + try { + const base = selectedTenant.expire_at && new Date(selectedTenant.expire_at) > new Date() + ? new Date(selectedTenant.expire_at) + : new Date() + base.setMonth(base.getMonth() + renewMonths) + const res = await updateTenant(selectedTenant.id, { + expire_at: base.toISOString(), + status: selectedTenant.status === 'expired' || selectedTenant.status === 'expiring' ? 'normal' : selectedTenant.status, + }) + message.success(`已续费 ${renewMonths} 个月`) + setSelectedTenant(res.data) + await loadTenants() + } catch (e) { + message.error(e instanceof Error ? e.message : '续费失败') + } finally { + setSaving(false) + } + } return ( -
-
-

租户管理

- -
-
- } placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear /> - setSearchInput(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + setSearch(searchInput.trim()) + setPage(1) + } + }} + className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400" + /> +
+
+ {statusFilters.map(f => { + const active = statusFilter === f.key || (!statusFilter && f.key === undefined) + return ( + + ) + })} +
+
+ + - setDrawerOpen(false)} width={480}> -
- - - - - - + + + + + + + + + + + + - - - -
+
+
+ 租户详情 + +
+
+
+ {(() => { + const pal = avatarPalette(selectedTenant.name) + return ( +
+ {selectedTenant.name.slice(0, 1)} +
+ ) + })()} +
+
{selectedTenant.name}
+
+ + T{String(selectedTenant.id).padStart(4, '0')} +
+
+
+ +
+
+
套餐
+
{planName(selectedTenant.plan_id)}
+
+
+
坐席
+
{selectedTenant.seat_count}
+
+
+
开通
+
{fmtDate(selectedTenant.created_at)}
+
+
+
到期
+
{fmtDate(selectedTenant.expire_at)}
+
+
+ +
+
联系与配置
+
+ + + + + + +
+
+ +
+ {selectedTenant.status !== 'suspended' ? ( + handleSuspend(selectedTenant.id)}> + + + ) : ( + + )} +
+
)}
@@ -257,10 +649,12 @@ const Tenants = () => { > {createdCreds && (
-

租户 {createdCreds.name} 已开通,请妥善保存登录信息:

-
-
管理员账号:{createdCreds.username}
-
初始密码:{createdCreds.password}
+

+ 租户 {createdCreds.name} 已开通,请妥善保存登录信息: +

+
+
管理员账号:{createdCreds.username}
+
初始密码:{createdCreds.password}
)}