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

固定顶栏与状态筛选工具栏,表格展示公司头像与状态点;详情抽屉支持编辑、续费与暂停恢复。
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 {
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<string, { color: string; text: string }> = {
normal: { color: 'green', text: '正常' },
expiring: { color: 'orange', text: '即将到期' },
suspended: { color: 'red', text: '已暂停' },
expired: { color: 'default', text: '已过期' },
const statusMeta: Record<string, { text: string; bg: string; color: string; dot: string }> = {
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 (
<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 = () => {
@@ -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<string>()
const [drawerOpen, setDrawerOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(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) => <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: 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>
),
},
]
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 (
<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={() => { 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}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个租户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无租户" /> }}
/>
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
{/* 顶栏 56px */}
<header
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center gap-3 min-w-0">
<h1 className="text-base font-semibold text-neutral-900 m-0"></h1>
<span className="text-xs px-2 py-0.5 rounded bg-neutral-100 text-neutral-500 tabular-nums">
{total}
</span>
</div>
</header>
{/* 工具栏 */}
<div className="shrink-0 px-6 py-3 border-b border-neutral-200 bg-white">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 h-8 px-3 rounded-md border border-neutral-200 bg-white w-64">
<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>
<Drawer title="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
<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>
{/* 表格 */}
<div className="flex-1 min-h-0 overflow-auto px-6 py-4">
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
{loading ? (
<div className="py-20 flex justify-center"><Spin size="large" /></div>
) : tenants.length === 0 ? (
<Empty className="py-16" description="暂无租户" />
) : (
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}>
<thead>
<tr className="bg-neutral-50 border-b border-neutral-200">
{['公司名称', '联系人', '手机号', '套餐类型', '坐席', '开通日期', '到期日期', '状态', '操作'].map((h, i) => (
<th
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
title="租户详情"
title={null}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={420}
extra={<Button type="primary" size="small" onClick={handleUpdateSeats}></Button>}
closable={false}
styles={{ body: { padding: 0 }, header: { display: 'none' } }}
>
{selectedTenant && (
<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 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={() => setDetailOpen(false)}
>
<CloseOutlined className="text-xs" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
<div className="flex items-center gap-3">
{(() => {
const pal = avatarPalette(selectedTenant.name)
return (
<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>
)}
</Drawer>
@@ -257,10 +649,12 @@ const Tenants = () => {
>
{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>
<p className="m-0">
<strong>{createdCreds.name}</strong>
</p>
<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>
)}