优化平台租户管理页并对齐效果图

固定顶栏与状态筛选工具栏,表格展示公司头像与状态点;详情抽屉支持编辑、续费与暂停恢复。
This commit is contained in:
yml2213
2026-07-15 13:47:06 +08:00
parent 425cd1f0e2
commit 70e19e3f06
+515 -121
View File
@@ -1,19 +1,77 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { import {
Table, Input, Select, Tag, Button, Drawer, Form, InputNumber, Space, message, Input, Select, Button, Drawer, Form, InputNumber, message,
Descriptions, Empty, Modal, Popconfirm, Empty, Modal, Popconfirm, Spin, Pagination,
} from 'antd' } from 'antd'
import { PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined, EyeOutlined } from '@ant-design/icons' import {
PlusOutlined, SearchOutlined, ReloadOutlined, PauseCircleOutlined,
CloseOutlined, CalendarOutlined,
} from '@ant-design/icons'
import { import {
createTenant, getPlans, getTenants, resumeTenant, suspendTenant, updateTenant, createTenant, getPlans, getTenants, resumeTenant, suspendTenant, updateTenant,
type Plan, type Tenant, type Plan, type Tenant,
} from '@/services/api' } from '@/services/api'
const statusConfig: Record<string, { color: string; text: string }> = { const statusMeta: Record<string, { text: string; bg: string; color: string; dot: string }> = {
normal: { color: 'green', text: '正常' }, normal: { text: '正常', bg: '#f0fdf4', color: '#16a34a', dot: '#16a34a' },
expiring: { color: 'orange', text: '即将到期' }, expiring: { text: '即将到期', bg: '#fffbeb', color: '#d97706', dot: '#d97706' },
suspended: { color: 'red', text: '已暂停' }, suspended: { text: '已暂停', bg: '#f1f5f9', color: '#64748b', dot: '#94a3b8' },
expired: { color: 'default', text: '已过期' }, 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 (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
style={{ backgroundColor: s.bg, color: s.color }}
>
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: s.dot }} />
{s.text}
</span>
)
} }
const Tenants = () => { const Tenants = () => {
@@ -22,25 +80,30 @@ const Tenants = () => {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [statusFilter, setStatusFilter] = useState<string>() const [statusFilter, setStatusFilter] = 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 [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [renewMonths, setRenewMonths] = useState(12)
const [createdCreds, setCreatedCreds] = useState<{ username: string; password: string; name: string } | null>(null) const [createdCreds, setCreatedCreds] = useState<{ username: string; password: string; name: string } | null>(null)
const [createForm] = Form.useForm() const [createForm] = Form.useForm()
const [editForm] = Form.useForm() const [editForm] = Form.useForm()
useEffect(() => { loadTenants() }, [page, search, statusFilter]) useEffect(() => { loadTenants() }, [page, pageSize, search, statusFilter])
useEffect(() => { 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 () => { const loadTenants = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await getTenants({ search, status: statusFilter, page, pageSize: 10 }) const res = await getTenants({ search, status: statusFilter, page, pageSize })
setTenants(res.list) setTenants(res.list)
setTotal(res.total) setTotal(res.total)
} catch { } catch {
@@ -55,6 +118,28 @@ const Tenants = () => {
return plans.find(p => p.id === planId)?.name || `#${planId}` 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: { const handleCreate = async (values: {
name: string; contact_name: string; contact_phone: string; contact_email?: string name: string; contact_name: string; contact_phone: string; contact_email?: string
plan_id?: number; seat_count?: number; duration_months?: number plan_id?: number; seat_count?: number; duration_months?: number
@@ -91,6 +176,9 @@ const Tenants = () => {
try { try {
await suspendTenant(id) await suspendTenant(id)
message.success('已暂停') message.success('已暂停')
if (selectedTenant?.id === id) {
setSelectedTenant({ ...selectedTenant, status: 'suspended' })
}
await loadTenants() await loadTenants()
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '暂停失败') message.error(e instanceof Error ? e.message : '暂停失败')
@@ -101,6 +189,9 @@ const Tenants = () => {
try { try {
await resumeTenant(id) await resumeTenant(id)
message.success('已恢复') message.success('已恢复')
if (selectedTenant?.id === id) {
setSelectedTenant({ ...selectedTenant, status: 'normal' })
}
await loadTenants() await loadTenants()
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '恢复失败') message.error(e instanceof Error ? e.message : '恢复失败')
@@ -109,6 +200,7 @@ const Tenants = () => {
const handleUpdateSeats = async () => { const handleUpdateSeats = async () => {
if (!selectedTenant) return if (!selectedTenant) return
setSaving(true)
try { try {
const values = await editForm.validateFields() const values = await editForm.validateFields()
const res = await updateTenant(selectedTenant.id, { const res = await updateTenant(selectedTenant.id, {
@@ -116,133 +208,433 @@ const Tenants = () => {
contact_phone: values.edit_contact_phone, contact_phone: values.edit_contact_phone,
contact_email: values.edit_contact_email, contact_email: values.edit_contact_email,
seat_count: values.edit_seat_count, seat_count: values.edit_seat_count,
plan_id: values.edit_plan_id,
}) })
message.success('已更新') message.success('已更新')
setSelectedTenant(res.data) setSelectedTenant(res.data)
await loadTenants() await loadTenants()
} catch (e) { } catch (e) {
if (e instanceof Error && e.message) message.error(e.message) if (e instanceof Error && e.message) message.error(e.message)
} finally {
setSaving(false)
} }
} }
const columns = [ const handleRenew = async () => {
{ title: '公司名称', dataIndex: 'name', key: 'name', render: (t: string) => <span className="text-sm font-medium text-neutral-800">{t}</span> }, if (!selectedTenant || renewMonths < 1) return
{ title: '联系人', dataIndex: 'contact_name', key: 'contact_name' }, setSaving(true)
{ title: '手机号', dataIndex: 'contact_phone', key: 'contact_phone', render: (t: string) => <span className="text-neutral-500">{t}</span> }, try {
{ const base = selectedTenant.expire_at && new Date(selectedTenant.expire_at) > new Date()
title: '套餐', key: 'plan', ? new Date(selectedTenant.expire_at)
render: (_: unknown, r: Tenant) => <span className="text-sm text-neutral-600">{planName(r.plan_id)}</span>, : new Date()
}, base.setMonth(base.getMonth() + renewMonths)
{ title: '坐席数', dataIndex: 'seat_count', key: 'seat_count', align: 'center' as const }, const res = await updateTenant(selectedTenant.id, {
{ expire_at: base.toISOString(),
title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', status: selectedTenant.status === 'expired' || selectedTenant.status === 'expiring' ? 'normal' : selectedTenant.status,
render: (t: string) => <span className="text-xs text-neutral-500">{t ? new Date(t).toLocaleDateString() : '—'}</span>, })
}, message.success(`已续费 ${renewMonths} 个月`)
{ setSelectedTenant(res.data)
title: '状态', dataIndex: 'status', key: 'status', await loadTenants()
render: (s: string) => <Tag color={statusConfig[s]?.color}>{statusConfig[s]?.text || s}</Tag>, } catch (e) {
}, message.error(e instanceof Error ? e.message : '续费失败')
{ } finally {
title: '操作', key: 'actions', width: 220, setSaving(false)
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 ( return (
<div> <div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
<div className="flex items-center justify-between mb-4"> {/* 顶栏 56px */}
<h2 className="text-lg font-semibold text-neutral-800"></h2> <header
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); createForm.setFieldsValue({ duration_months: 12, seat_count: 5 }); setDrawerOpen(true) }}> className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
</Button> >
</div> <div className="flex items-center gap-3 min-w-0">
<div className="flex gap-3 mb-3 flex-wrap"> <h1 className="text-base font-semibold text-neutral-900 m-0"></h1>
<Input prefix={<SearchOutlined />} placeholder="搜索公司名称或联系人" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-56" allowClear /> <span className="text-xs px-2 py-0.5 rounded bg-neutral-100 text-neutral-500 tabular-nums">
<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 }))} /> {total}
</div> </span>
<div className="bg-white rounded-lg border border-neutral-200 overflow-hidden"> </div>
<Table </header>
dataSource={tenants}
columns={columns} {/* 工具栏 */}
rowKey="id" <div className="shrink-0 px-6 py-3 border-b border-neutral-200 bg-white">
size="middle" <div className="flex items-center justify-between gap-3 flex-wrap">
loading={loading} <div className="flex items-center gap-3 flex-wrap">
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个租户`, onChange: p => setPage(p) }} <div className="flex items-center gap-2 h-8 px-3 rounded-md border border-neutral-200 bg-white w-64">
locale={{ emptyText: <Empty description="暂无租户" /> }} <SearchOutlined className="text-neutral-400 text-xs shrink-0" />
/> <input
type="text"
placeholder="搜索公司名/联系人..."
value={searchInput}
onChange={e => 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"
/>
</div>
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-neutral-200 bg-white">
{statusFilters.map(f => {
const active = statusFilter === f.key || (!statusFilter && f.key === undefined)
return (
<button
key={f.label}
type="button"
onClick={() => { setStatusFilter(f.key); setPage(1) }}
className={`px-3 py-1 text-xs rounded border-0 cursor-pointer transition-colors ${
active
? 'bg-[#2563eb] text-white font-medium'
: 'bg-transparent text-neutral-600 hover:bg-neutral-50'
}`}
>
{f.label}
</button>
)
})}
</div>
</div>
<Button type="primary" icon={<PlusOutlined />} className="!h-8" onClick={openCreate}>
</Button>
</div>
</div> </div>
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}> {/* 表格 */}
<Form form={createForm} layout="vertical" onFinish={handleCreate}> <div className="flex-1 min-h-0 overflow-auto px-6 py-4">
<Form.Item name="name" label="公司名称" rules={[{ required: true }, { min: 2, max: 100 }]}><Input /></Form.Item> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<Form.Item name="contact_name" label="联系人" rules={[{ required: true }]}><Input /></Form.Item> {loading ? (
<Form.Item name="contact_phone" label="手机号" rules={[{ required: true }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}><Input /></Form.Item> <div className="py-20 flex justify-center"><Spin size="large" /></div>
<Form.Item name="contact_email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}><Input /></Form.Item> ) : tenants.length === 0 ? (
<Form.Item name="plan_id" label="套餐"> <Empty className="py-16" description="暂无租户" />
<Select allowClear placeholder="选择套餐" options={plans.map(p => ({ value: p.id, label: `${p.name} · ¥${p.price_monthly}/月` }))} /> ) : (
</Form.Item> <div className="overflow-x-auto">
<Form.Item name="seat_count" label="坐席数量"><InputNumber min={1} max={500} className="w-full" /></Form.Item> <table className="w-full" style={{ minWidth: 960 }}>
<Form.Item name="duration_months" label="开通时长(月)"><InputNumber min={1} max={36} className="w-full" /></Form.Item> <thead>
<Form.Item> <tr className="bg-neutral-50 border-b border-neutral-200">
<Button type="primary" htmlType="submit" block loading={saving}></Button> {['公司名称', '联系人', '手机号', '套餐类型', '坐席', '开通日期', '到期日期', '状态', '操作'].map((h, i) => (
</Form.Item> <th
</Form> key={h}
className={`px-4 py-3 text-[11px] font-semibold text-neutral-500 whitespace-nowrap ${
i === 4 || i === 8 ? 'text-center' : 'text-left'
}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{tenants.map(record => {
const pal = avatarPalette(record.name)
const pname = planName(record.plan_id)
const pstyle = planBadgeStyle(pname)
return (
<tr
key={record.id}
className="border-t border-neutral-100 hover:bg-neutral-50 cursor-pointer"
onClick={() => openDetail(record)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2.5 min-w-0">
<div
className="w-8 h-8 rounded-md flex items-center justify-center text-xs font-bold shrink-0"
style={{ backgroundColor: pal.bg, color: pal.color }}
>
{record.name.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-900 truncate">{record.name}</div>
<div className="text-[11px] text-neutral-400 truncate">
ID: T{String(record.id).padStart(4, '0')}
</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-neutral-700 whitespace-nowrap">
{record.contact_name || '—'}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 whitespace-nowrap font-mono">
{maskPhone(record.contact_phone)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
style={{ backgroundColor: pstyle.bg, color: pstyle.color }}
>
{pname}
</span>
</td>
<td className="px-4 py-3 text-sm text-center text-neutral-700 tabular-nums">
{record.seat_count}
</td>
<td className="px-4 py-3 text-sm text-neutral-500 whitespace-nowrap">
{fmtDate(record.created_at)}
</td>
<td className="px-4 py-3 text-sm text-neutral-700 whitespace-nowrap">
{fmtDate(record.expire_at)}
</td>
<td className="px-4 py-3">
<StatusPill status={record.status} />
</td>
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1 text-xs">
<button
type="button"
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={() => openDetail(record)}
>
</button>
<span className="text-neutral-200">|</span>
<button
type="button"
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={() => openDetail(record, true)}
>
</button>
<span className="text-neutral-200">|</span>
<button
type="button"
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={() => openDetail(record)}
>
</button>
{record.status !== 'suspended' ? (
<>
<span className="text-neutral-200">|</span>
<Popconfirm title="确认暂停该租户?" onConfirm={() => handleSuspend(record.id)}>
<button
type="button"
className="px-1.5 py-1 rounded text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
>
</button>
</Popconfirm>
</>
) : (
<>
<span className="text-neutral-200">|</span>
<button
type="button"
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
onClick={() => handleResume(record.id)}
>
</button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{total > 0 && (
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-neutral-500">
<span className="font-medium text-neutral-700">{total}</span>
</span>
<Pagination
current={page}
total={total}
pageSize={pageSize}
showSizeChanger
pageSizeOptions={[10, 20, 50]}
size="small"
onChange={(p, ps) => {
setPage(p)
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
}
}}
/>
</div>
)}
</div>
{/* 开通抽屉 */}
<Drawer
title={null}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={440}
closable={false}
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
>
<div className="h-full flex flex-col">
<div className="h-14 px-5 flex items-center justify-between border-b border-neutral-200 shrink-0">
<span className="font-semibold text-neutral-900"></span>
<button
type="button"
className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
onClick={() => setDrawerOpen(false)}
>
<CloseOutlined className="text-xs" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4">
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="公司名称" rules={[{ required: true }, { min: 2, max: 100 }]}>
<Input placeholder="公司全称" />
</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>
<Button type="primary" htmlType="submit" block loading={saving} className="!h-9">
</Button>
</Form>
</div>
</div>
</Drawer> </Drawer>
{/* 详情/编辑/续费 */}
<Drawer <Drawer
title="租户详情" title={null}
open={detailOpen} open={detailOpen}
onClose={() => setDetailOpen(false)} onClose={() => setDetailOpen(false)}
width={420} width={420}
extra={<Button type="primary" size="small" onClick={handleUpdateSeats}></Button>} closable={false}
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
> >
{selectedTenant && ( {selectedTenant && (
<div className="space-y-4 mt-2"> <div className="h-full flex flex-col">
<Descriptions column={1} size="small" colon={false}> <div className="h-14 px-5 flex items-center justify-between border-b border-neutral-200 shrink-0">
<Descriptions.Item label="公司名称">{selectedTenant.name}</Descriptions.Item> <span className="font-semibold text-neutral-900"></span>
<Descriptions.Item label="套餐">{planName(selectedTenant.plan_id)}</Descriptions.Item> <button
<Descriptions.Item label="状态"> type="button"
<Tag color={statusConfig[selectedTenant.status]?.color}>{statusConfig[selectedTenant.status]?.text || selectedTenant.status}</Tag> className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
</Descriptions.Item> onClick={() => setDetailOpen(false)}
<Descriptions.Item label="到期日期"> >
{selectedTenant.expire_at ? new Date(selectedTenant.expire_at).toLocaleDateString() : '—'} <CloseOutlined className="text-xs" />
</Descriptions.Item> </button>
</Descriptions> </div>
<Form form={editForm} layout="vertical"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
<Form.Item name="edit_contact_name" label="联系人"><Input /></Form.Item> <div className="flex items-center gap-3">
<Form.Item name="edit_contact_phone" label="手机号"><Input /></Form.Item> {(() => {
<Form.Item name="edit_contact_email" label="邮箱"><Input /></Form.Item> const pal = avatarPalette(selectedTenant.name)
<Form.Item name="edit_seat_count" label="坐席数"><InputNumber min={1} className="w-full" /></Form.Item> return (
</Form> <div
className="w-12 h-12 rounded-lg flex items-center justify-center text-lg font-bold shrink-0"
style={{ backgroundColor: pal.bg, color: pal.color }}
>
{selectedTenant.name.slice(0, 1)}
</div>
)
})()}
<div className="min-w-0">
<div className="text-base font-semibold text-neutral-900 truncate">{selectedTenant.name}</div>
<div className="mt-1 flex items-center gap-2">
<StatusPill status={selectedTenant.status} />
<span className="text-xs text-neutral-400">T{String(selectedTenant.id).padStart(4, '0')}</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-lg bg-neutral-50 border border-neutral-100 p-3">
<div className="text-[11px] text-neutral-400 mb-0.5"></div>
<div className="font-medium text-neutral-800">{planName(selectedTenant.plan_id)}</div>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-100 p-3">
<div className="text-[11px] text-neutral-400 mb-0.5"></div>
<div className="font-medium text-neutral-800">{selectedTenant.seat_count}</div>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-100 p-3">
<div className="text-[11px] text-neutral-400 mb-0.5"></div>
<div className="font-medium text-neutral-800">{fmtDate(selectedTenant.created_at)}</div>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-100 p-3">
<div className="text-[11px] text-neutral-400 mb-0.5"></div>
<div className="font-medium text-neutral-800">{fmtDate(selectedTenant.expire_at)}</div>
</div>
</div>
<div>
<div className="text-xs font-semibold text-neutral-500 mb-2"></div>
<Form form={editForm} layout="vertical" size="small">
<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_plan_id" label="套餐">
<Select
allowClear
options={plans.map(p => ({ value: p.id, label: `${p.name} · ¥${p.price_monthly}/月` }))}
/>
</Form.Item>
<Form.Item name="edit_seat_count" label="坐席数">
<InputNumber min={1} className="!w-full" />
</Form.Item>
</Form>
<Button type="primary" block loading={saving} onClick={handleUpdateSeats} className="!h-9">
</Button>
</div>
<div className="border-t border-neutral-100 pt-4">
<div className="text-xs font-semibold text-neutral-500 mb-2 flex items-center gap-1">
<CalendarOutlined />
</div>
<div className="flex items-center gap-2">
<Select
value={renewMonths}
onChange={setRenewMonths}
className="!flex-1"
options={[
{ value: 1, label: '1 个月' },
{ value: 3, label: '3 个月' },
{ value: 6, label: '6 个月' },
{ value: 12, label: '12 个月' },
{ value: 24, label: '24 个月' },
]}
/>
<Button loading={saving} onClick={handleRenew}></Button>
</div>
</div>
<div className="flex gap-2 pt-1">
{selectedTenant.status !== 'suspended' ? (
<Popconfirm title="确认暂停该租户?" onConfirm={() => handleSuspend(selectedTenant.id)}>
<Button danger block icon={<PauseCircleOutlined />}></Button>
</Popconfirm>
) : (
<Button block icon={<ReloadOutlined />} onClick={() => handleResume(selectedTenant.id)}>
</Button>
)}
</div>
</div>
</div> </div>
)} )}
</Drawer> </Drawer>
@@ -257,10 +649,12 @@ const Tenants = () => {
> >
{createdCreds && ( {createdCreds && (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<p className="m-0"> <strong>{createdCreds.name}</strong> </p> <p className="m-0">
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3"> <strong>{createdCreds.name}</strong>
<div><code>{createdCreds.username}</code></div> </p>
<div><code>{createdCreds.password}</code></div> <div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 space-y-1">
<div><code className="text-[#2563eb]">{createdCreds.username}</code></div>
<div><code className="text-[#2563eb]">{createdCreds.password}</code></div>
</div> </div>
</div> </div>
)} )}