226 lines
11 KiB
TypeScript
226 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
|
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
|
import { formatCents } from '@/utils/currency';
|
|
|
|
type AdminCustomersPageProps = {
|
|
basePath?: string;
|
|
};
|
|
|
|
type CustomerRow = TenantManagementRow;
|
|
|
|
type RechargeForm = {
|
|
amount: string;
|
|
operator: string;
|
|
remark: string;
|
|
};
|
|
|
|
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
|
return (
|
|
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
|
<p className="admin-confirm-text">{message}</p>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function emptyRechargeForm(): RechargeForm {
|
|
return {
|
|
amount: '',
|
|
operator: '运营',
|
|
remark: '',
|
|
};
|
|
}
|
|
|
|
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
|
const navigate = useNavigate();
|
|
const [records, setRecords] = useState<CustomerRow[]>([]);
|
|
const [queryId, setQueryId] = useState('');
|
|
const [queryName, setQueryName] = useState('');
|
|
const [queryStatus, setQueryStatus] = useState('all');
|
|
const [filters, setFilters] = useState({ id: '', name: '', status: 'all' });
|
|
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
|
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
|
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
|
|
const [rechargeError, setRechargeError] = useState('');
|
|
const [recharging, setRecharging] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
function loadData() {
|
|
adminApi.listTenantManagementRows()
|
|
.then((items) => {
|
|
setRecords(items);
|
|
setError('');
|
|
})
|
|
.catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const filteredRecords = useMemo(() => records.filter((record) => {
|
|
const matchId = filters.id ? record.id.includes(filters.id) : true;
|
|
const matchName = filters.name ? record.name.includes(filters.name) : true;
|
|
const matchStatus = filters.status === 'all' ? true : record.status === filters.status;
|
|
return matchId && matchName && matchStatus;
|
|
}), [filters, records]);
|
|
|
|
const activeCount = records.filter((record) => record.status === 'active').length;
|
|
const disabledCount = records.filter((record) => record.status === 'disabled').length;
|
|
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
|
|
|
|
const columns: Array<TableColumn<CustomerRow>> = [
|
|
{ key: 'id', title: '企业ID', width: '160px', render: (record) => <span className="table-mono-id">{record.id}</span> },
|
|
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
|
|
{
|
|
key: 'balance',
|
|
title: '当前余额',
|
|
width: '150px',
|
|
align: 'right',
|
|
render: (record) => {
|
|
const balance = record.account?.balanceCents ?? 0;
|
|
return (
|
|
<span className={balance < 0 ? 'status-danger' : ''}>
|
|
¥{formatCents(balance)}
|
|
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag">欠费</Tag> : null}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{ key: 'overdraftLimit', title: '透支限额', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.account?.creditCents ?? 0)}` },
|
|
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCents(record.todaySpendCents)}` },
|
|
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
|
|
{
|
|
key: 'actions',
|
|
title: '操作',
|
|
align: 'right',
|
|
width: '300px',
|
|
render: (record) => (
|
|
<div className="table-actions">
|
|
<Button icon={<DollarSign size={15} />} onClick={() => openRechargeModal(record)} size="sm" variant="ghost">充值</Button>
|
|
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
|
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
|
{record.status === 'active' ? '禁用' : '启用'}
|
|
</Button>
|
|
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', record })} size="sm" variant="danger">删除</Button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
function openRechargeModal(record: CustomerRow) {
|
|
setRechargeTarget(record);
|
|
setRechargeForm(emptyRechargeForm());
|
|
setRechargeError('');
|
|
}
|
|
|
|
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
|
|
setRechargeForm((current) => ({ ...current, [key]: value }));
|
|
setRechargeError('');
|
|
}
|
|
|
|
async function submitRecharge() {
|
|
if (!rechargeTarget) return;
|
|
const amount = Number(rechargeForm.amount);
|
|
if (!Number.isFinite(amount) || amount === 0) {
|
|
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
|
|
return;
|
|
}
|
|
setRecharging(true);
|
|
try {
|
|
await adminApi.createManualRecharge({
|
|
tenantId: rechargeTarget.id,
|
|
amountCents: Math.round(amount * 100),
|
|
smsUnits: 0,
|
|
remark: [rechargeForm.operator, rechargeForm.remark].filter(Boolean).join(' / '),
|
|
});
|
|
setRechargeTarget(null);
|
|
setRechargeForm(emptyRechargeForm());
|
|
await loadData();
|
|
} catch (failure) {
|
|
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
|
|
} finally {
|
|
setRecharging(false);
|
|
}
|
|
}
|
|
|
|
function submitConfirmAction() {
|
|
if (!confirmAction) return;
|
|
const action = confirmAction.type === 'delete'
|
|
? adminApi.deleteTenant(confirmAction.record.id)
|
|
: adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
|
|
action.then(() => {
|
|
setConfirmAction(null);
|
|
loadData();
|
|
}).catch((failure: Error) => setError(failure.message || '企业状态更新失败'));
|
|
}
|
|
|
|
return (
|
|
<section className="page-stack">
|
|
<div className="page-heading">
|
|
<div><Breadcrumb items={['企业管理']} /></div>
|
|
<Button icon={<Plus size={16} />} onClick={() => navigate(`${basePath}/new`)}>添加企业</Button>
|
|
</div>
|
|
{error ? <p className="form-error">{error}</p> : null}
|
|
|
|
<div className="dashboard-grid enterprise-summary-grid">
|
|
<div className="surface mini-status-card"><Building2 size={22} /><div><span>企业总数</span><strong>{records.length}</strong><small>真实租户数量。</small></div></div>
|
|
<div className="surface mini-status-card"><TrendingUp size={22} /><div><span>正常运营</span><strong>{activeCount}</strong><small>可正常提交发送任务。</small></div></div>
|
|
<div className="surface mini-status-card"><TrendingDown size={22} /><div><span>已禁用</span><strong>{disabledCount}</strong><small>已暂停发送能力。</small></div></div>
|
|
<div className="surface mini-status-card"><DollarSign size={22} /><div><span>账户余额</span><strong>¥{formatCents(totalBalance)}</strong><small>企业账户余额汇总。</small></div></div>
|
|
</div>
|
|
|
|
<div className="surface ui-query-panel">
|
|
<h2>查询条件</h2>
|
|
<div className="ui-query-panel__grid enterprise-query-grid">
|
|
<Input label="企业ID" onChange={(event) => setQueryId(event.target.value)} placeholder="请输入企业ID" value={queryId} />
|
|
<Input label="企业名称" onChange={(event) => setQueryName(event.target.value)} placeholder="请输入企业名称" value={queryName} />
|
|
<Select label="企业状态" onChange={(event) => setQueryStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={queryStatus} />
|
|
<div className="enterprise-query-actions">
|
|
<Button onClick={() => setFilters({ id: queryId, name: queryName, status: queryStatus })} variant="secondary">查询</Button>
|
|
<Button onClick={() => { setQueryId(''); setQueryName(''); setQueryStatus('all'); setFilters({ id: '', name: '', status: 'all' }); }} variant="ghost">重置</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="surface section-stack">
|
|
<div className="section-heading"><div><h2>企业列表</h2><p className="muted">数据来自租户、账户真实接口。</p></div><Tag tone="info">{filteredRecords.length} 条</Tag></div>
|
|
<Table columns={columns} data={filteredRecords} emptyText="暂无企业" rowKey="id" />
|
|
</div>
|
|
|
|
{confirmAction ? (
|
|
<ConfirmModal
|
|
message={confirmAction.type === 'delete' ? `确认删除企业“${confirmAction.record.name}”吗?` : `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
|
|
onCancel={() => setConfirmAction(null)}
|
|
onConfirm={submitConfirmAction}
|
|
/>
|
|
) : null}
|
|
{rechargeTarget ? (
|
|
<Modal
|
|
footer={(
|
|
<>
|
|
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost">取消</Button>
|
|
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
|
|
</>
|
|
)}
|
|
onClose={() => setRechargeTarget(null)}
|
|
open
|
|
size="md"
|
|
title="企业人工充值"
|
|
>
|
|
<div className="admin-system-modal-form">
|
|
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
|
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
|
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} />
|
|
<Input label="操作人" onChange={(event) => updateRechargeForm('operator', event.target.value)} value={rechargeForm.operator} />
|
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
|
</div>
|
|
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
|
</Modal>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|