优化平台租户管理页并对齐效果图
固定顶栏与状态筛选工具栏,表格展示公司头像与状态点;详情抽屉支持编辑、续费与暂停恢复。
This commit is contained in:
+500
-106
@@ -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,
|
||||
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,
|
||||
})
|
||||
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
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) }}>
|
||||
<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 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="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="开通新账号" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={480}>
|
||||
{/* 开通抽屉 */}
|
||||
<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 /></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="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}/月` }))} />
|
||||
<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 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">
|
||||
<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_seat_count" label="坐席数"><InputNumber min={1} className="w-full" /></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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user