fix: harden real backend workflows and channel connections

This commit is contained in:
hectorzhao
2026-07-06 17:54:53 +08:00
parent 8cca361441
commit b5132d7f4e
47 changed files with 2530 additions and 314 deletions
+109 -22
View File
@@ -1,17 +1,25 @@
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 TenantAccount, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
type AdminCustomersPageProps = {
basePath?: string;
};
type CustomerRow = TenantOption & {
account?: TenantAccount;
type CustomerRow = TenantManagementRow;
type RechargeForm = {
amount: string;
operator: string;
remark: string;
};
function formatCurrency(cents: number) {
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
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="操作确认">
@@ -20,6 +28,14 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
);
}
function emptyRechargeForm(): RechargeForm {
return {
amount: '',
operator: '运营',
remark: '',
};
}
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
const navigate = useNavigate();
const [records, setRecords] = useState<CustomerRow[]>([]);
@@ -28,15 +44,16 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
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() {
Promise.all([adminApi.listTenants(), adminApi.listAccounts()])
.then(([tenants, accounts]) => {
setRecords(tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
...tenant,
account: accounts.find((account) => account.tenantId === tenant.id),
})));
adminApi.listTenantManagementRows()
.then((items) => {
setRecords(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业列表加载失败'));
@@ -47,7 +64,7 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
}, []);
const filteredRecords = useMemo(() => records.filter((record) => {
const matchId = filters.id ? record.id.includes(filters.id) || record.code.includes(filters.id) : true;
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;
@@ -58,23 +75,34 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
const columns: Array<TableColumn<CustomerRow>> = [
{ key: 'id', title: '企业ID', width: '240px', render: (record) => <span className="table-mono-id">{record.id}</span> },
{ 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: 'code', title: '企业编码', width: '180px', render: (record) => <span className="table-mono-id">{record.code}</span> },
{ key: 'creditCode', title: '统一社会信用代码', width: '220px', render: (record) => record.enterpriseProfile?.creditCode || '-' },
{ key: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' },
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' },
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')}` },
{
key: 'balance',
title: '当前余额',
width: '150px',
align: 'right',
render: (record) => {
const balance = record.account?.balanceCents ?? 0;
return (
<span className={balance < 0 ? 'status-danger' : ''}>
¥{formatCurrency(balance)}
{balance < 0 ? <Tag tone="danger" className="enterprise-inline-tag"></Tag> : null}
</span>
);
},
},
{ key: 'overdraftLimit', title: '透支限额', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(record.account?.creditCents ?? 0)}` },
{ key: 'todaySpend', title: '今日消费', width: '150px', align: 'right', render: (record) => `¥${formatCurrency(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: '280px',
width: '300px',
render: (record) => (
<div className="table-actions">
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost"></Button>
<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' ? '禁用' : '启用'}
@@ -85,6 +113,42 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
},
];
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'
@@ -108,13 +172,13 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
<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>¥{(totalBalance / 100).toLocaleString('zh-CN')}</strong><small></small></div></div>
<div className="surface mini-status-card"><DollarSign size={22} /><div><span></span><strong>¥{formatCurrency(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="企业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">
@@ -136,6 +200,29 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
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={formatCurrency(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>
);
}