feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+6 -19
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
@@ -42,7 +42,7 @@ type ChannelModalState = {
};
type ChannelConfirmAction = {
type: 'toggle' | 'delete' | 'copy';
type: 'toggle' | 'copy';
channel: SmsChannel;
};
@@ -532,11 +532,6 @@ export function AdminChannelsPage() {
loadChannels();
}
async function deleteChannel(id: string) {
await adminApi.deleteChannel(id, '运营端删除通道');
setChannels((items) => items.filter((item) => item.id !== id));
}
async function copyChannel(channel: SmsChannel) {
await adminApi.copyChannel(channel.id);
loadChannels();
@@ -563,10 +558,6 @@ export function AdminChannelsPage() {
void toggleChannel(confirmAction.channel);
}
if (confirmAction.type === 'delete') {
void deleteChannel(confirmAction.channel.id);
}
if (confirmAction.type === 'copy') {
void copyChannel(confirmAction.channel);
}
@@ -574,17 +565,13 @@ export function AdminChannelsPage() {
setConfirmAction(null);
}
const confirmTitle = confirmAction?.type === 'delete'
? '确认删除通道'
: confirmAction?.type === 'copy'
const confirmTitle = confirmAction?.type === 'copy'
? '确认复制通道'
: confirmAction?.channel.status === 'stopped'
? '确认启用通道'
: '确认停用通道';
const confirmDescription = confirmAction?.type === 'delete'
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
: confirmAction?.type === 'copy'
const confirmDescription = confirmAction?.type === 'copy'
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
: confirmAction?.channel.status === 'stopped'
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
@@ -651,7 +638,7 @@ export function AdminChannelsPage() {
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
</button>
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} /></button>
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
</div>
</article>
))}
@@ -691,7 +678,7 @@ export function AdminChannelsPage() {
footer={(
<>
<Button onClick={() => setConfirmAction(null)} variant="ghost"></Button>
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}></Button>
<Button onClick={submitConfirmAction}></Button>
</>
)}
onClose={() => setConfirmAction(null)}
+15 -70
View File
@@ -2,8 +2,8 @@ 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, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatCents } from '@/utils/currency';
type AdminCustomersPageProps = {
basePath?: string;
@@ -11,11 +11,6 @@ type AdminCustomersPageProps = {
type CustomerRow = TenantManagementRow;
type RechargeForm = {
amount: 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="操作确认">
@@ -24,13 +19,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
);
}
function emptyRechargeForm(): RechargeForm {
return {
amount: '',
remark: '',
};
}
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
const navigate = useNavigate();
const [records, setRecords] = useState<CustomerRow[]>([]);
@@ -39,9 +27,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
const [filters, setFilters] = useState({ 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() {
@@ -108,37 +93,6 @@ 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) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
return;
}
setRecharging(true);
try {
await adminApi.createManualRecharge({
tenantId: rechargeTarget.id,
amountCents: yuanToMoneyUnits(rechargeForm.amount),
remark: rechargeForm.remark,
});
setRechargeTarget(null);
setRechargeForm(emptyRechargeForm());
await loadData();
} catch (failure) {
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
} finally {
setRecharging(false);
}
}
function submitConfirmAction() {
@@ -191,28 +145,19 @@ 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={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
<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}
<ManualRechargeDialog
initialTargetId={rechargeTarget?.id}
lockTarget
onClose={() => setRechargeTarget(null)}
onCompleted={loadData}
open={Boolean(rechargeTarget)}
targets={rechargeTarget ? [{
id: rechargeTarget.id,
name: rechargeTarget.name,
code: rechargeTarget.code,
balanceCents: rechargeTarget.account?.balanceCents ?? 0,
}] : []}
/>
</section>
);
}
@@ -6,6 +6,7 @@ import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
manual_requeueing: 'info',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
@@ -14,6 +15,7 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
};
const statusLabel: Record<string, string> = {
manual_requeueing: '人工重投处理中',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime';
@@ -563,7 +563,7 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
export function AdminEnterpriseSignaturesPage() {
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
@@ -672,11 +672,7 @@ export function AdminEnterpriseSignaturesPage() {
if (!deleteTarget) {
return;
}
if (deleteTarget.kind === 'signature') {
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
} else {
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
}
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
setDeleteTarget(null);
await loadData();
}
@@ -708,7 +704,7 @@ export function AdminEnterpriseSignaturesPage() {
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
</div>
</div>
{expanded ? (
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
type TemplateFormState = {
@@ -99,6 +99,8 @@ function TemplateFormModal({
category: item?.category ?? '行业通知',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
});
const initialForm = useRef(form).current;
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
const tenantSignatures = signatures.filter((signature) => (
signature.tenantId === form.tenantId
@@ -146,9 +148,10 @@ function TemplateFormModal({
return (
<Modal
footer={(
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button onClick={requestClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
</>
)}
@@ -277,7 +280,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
export function AdminEnterpriseTemplatesPage() {
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
@@ -363,15 +365,6 @@ export function AdminEnterpriseTemplatesPage() {
}
}
async function confirmDelete() {
if (!deleteTarget) {
return;
}
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
setDeleteTarget(null);
await loadData();
}
return (
<section className="page-stack admin-customer-split-page">
<div className="page-heading">
@@ -442,7 +435,7 @@ export function AdminEnterpriseTemplatesPage() {
<div className="admin-enterprise-template-row__actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost"></Button>
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
</div>
</article>
))}
@@ -475,13 +468,6 @@ export function AdminEnterpriseTemplatesPage() {
/>
) : null}
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
/>
) : null}
</section>
);
}
+19 -70
View File
@@ -1,15 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
type ManualRechargeForm = {
tenantId: string;
amount: string;
remark: string;
};
import { formatCents } from '@/utils/currency';
function getDate(value: string) {
return value.slice(0, 10);
@@ -26,27 +20,26 @@ function RemarkCell({ value }: { value?: string }) {
export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState<RechargeOrder[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [manualError, setManualError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function loadData() {
setLoading(true);
setError('');
try {
const [nextTenants, nextRecords] = await Promise.all([
const [nextTenants, nextAccounts, nextRecords] = await Promise.all([
adminApi.listTenants(),
adminApi.listAccounts(),
adminApi.listManualRecharges(),
]);
setTenants(nextTenants);
setAccounts(nextAccounts);
setRecords(nextRecords);
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
} catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败');
setRecords([]);
@@ -84,35 +77,6 @@ export function AdminRechargeRecordsPage() {
setDateRange({});
}
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
setForm((current) => ({ ...current, [key]: value }));
setManualError('');
}
async function submitManualRecharge() {
const amount = Number(form.amount);
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
return;
}
setSubmitting(true);
setManualError('');
try {
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: yuanToMoneyUnits(form.amount),
remark: form.remark,
});
await loadData();
setManualOpen(false);
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
} catch (failure) {
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
} finally {
setSubmitting(false);
}
}
return (
<section className="page-stack admin-recharge-page">
<div className="page-heading">
@@ -181,33 +145,18 @@ export function AdminRechargeRecordsPage() {
/>
</div>
{manualOpen ? (
<Modal
footer={(
<>
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost"></Button>
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
</>
)}
onClose={() => setManualOpen(false)}
open
size="md"
title="企业人工充值"
>
<div className="admin-system-modal-form">
<Select
label="企业名称"
onChange={(event) => updateForm('tenantId', event.target.value)}
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
required
value={form.tenantId}
/>
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
{manualError ? <p className="form-error">{manualError}</p> : null}
</Modal>
) : null}
<ManualRechargeDialog
initialTargetId={tenants.find((tenant) => tenant.status !== 'deleted')?.id}
onClose={() => setManualOpen(false)}
onCompleted={loadData}
open={manualOpen}
targets={tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
id: tenant.id,
name: tenant.name,
code: tenant.code,
balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0,
}))}
/>
</section>
);
}
+40 -10
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
@@ -17,38 +17,68 @@ export function AdminReportMaterialsPage() {
const [keyword, setKeyword] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
const [operationKey, setOperationKey] = useState('');
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
function loadData() {
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
.then(async ([pendingItems, batchItems]) => {
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
})
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
}
useEffect(loadData, [reportType]);
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
async function beginCreateBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
setOperationKey(`report-batch:${crypto.randomUUID()}`);
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
finally { setPreflightBusy(false); }
}
async function createBatch() {
const chosen = items.filter((item) => selected.has(item.id));
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
setBusy(true); setError(''); setMessage('');
try {
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
finally { setBusy(false); }
}
return <section className="page-stack report-material-page">
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p> XLSX </p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备${selected.size}`}</Button></div></div>
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1></h1><p></p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"> WPS </Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成${selected.size}`}</Button></div></div>
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost"></Button></div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span></span><span></span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span></span><span> / </span><span> / </span><span></span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join('')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2></h2><p> XLSX</p></div><Tag tone="neutral">{batches.length} </Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · {batch.selectedCount ?? 0} · {batch.channelCount ?? 0} </small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}{String(file.rowCount ?? 0)} </a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty"></div> : null}</div>
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
<div className="report-batch-preflight">{preflightBusy ? <p role="status"></p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
</Modal>
</section>;
}
function toBatchItem(item: ReportMaterialPendingItem) {
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
}
+3 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { Eye, Search, X } from 'lucide-react';
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
@@ -84,10 +84,6 @@ export function AdminSignatureAuditPage() {
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
async function approve(item: ClientSmsSignature) {
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
}
async function reject() {
if (!rejectTarget || !reason.trim()) return;
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
@@ -99,7 +95,7 @@ export function AdminSignatureAuditPage() {
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button><Button disabled={!canReviewSignature(record)} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success"></Button><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button></div> },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={loadData} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button></div> },
], []);
return <section className="page-stack admin-template-audit-page">
+3 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { CalendarDays, FileText, Search } from 'lucide-react';
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type OperationLogItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -99,7 +99,7 @@ export function AdminSystemLogsPage() {
<span className="sms-send-title__icon"><FileText size={22} /></span>
<h1></h1>
</div>
<Button icon={<Download size={17} />} variant="secondary"></Button>
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
</div>
<div className="system-log-filters">
+7 -17
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Search, X } from 'lucide-react';
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Search, X } from 'lucide-react';
import { Breadcrumb, Button, Input, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -29,10 +29,8 @@ export function AdminTemplateAuditPage() {
.catch(() => setAudits([]));
}, [keyword, status]);
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
const updated = nextStatus === 'approved'
? await adminApi.approveTemplate(id)
: await adminApi.rejectTemplate(id);
async function rejectTemplate(id: string) {
const updated = await adminApi.rejectTemplate(id);
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
}
@@ -58,19 +56,11 @@ export function AdminTemplateAuditPage() {
align: 'right',
render: (record) => (
<div className="table-actions">
<Button
disabled={record.auditStatus !== 'pending'}
icon={<Check size={15} />}
onClick={() => void reviewTemplate(record.id, 'approved')}
size="sm"
variant="success"
>
</Button>
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
<Button
disabled={record.auditStatus !== 'pending'}
icon={<X size={15} />}
onClick={() => void reviewTemplate(record.id, 'rejected')}
onClick={() => void rejectTemplate(record.id)}
size="sm"
variant="danger"
>
@@ -80,7 +70,7 @@ export function AdminTemplateAuditPage() {
),
},
],
[],
[keyword, status],
);
const templateAudits = audits;
+1 -1
View File
@@ -59,7 +59,7 @@ function generateInitialPassword() {
}
export function AdminUsersPage() {
const session = readSession();
const session = readSession('admin');
const [users, setUsers] = useState<ManagedUser[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [keyword, setKeyword] = useState('');