fix: align reporting fields queries and disk monitoring

This commit is contained in:
hectorzhao
2026-08-31 12:19:39 +08:00
parent 119d57772e
commit b0deef5e6e
37 changed files with 596 additions and 110 deletions
@@ -0,0 +1,27 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } }));
vi.mock('@/api/adminApi', () => ({ adminApi }));
describe('common reporting configuration', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
adminApi.listDrainageFields.mockResolvedValue([field]);
adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]);
adminApi.updateCommonReportField.mockResolvedValue({});
});
it('opens existing values and saves the edited requirement with PUT API', async () => {
render(<AdminDrainageFieldsPage />);
fireEvent.click(await screen.findByRole('button', { name: '修改通用字段主体证明' }));
expect(screen.getByRole('dialog')).toHaveTextContent('修改通用字段配置');
fireEvent.click(screen.getByRole('button', { name: '是否必填' }));
fireEvent.click(screen.getByRole('option', { name: '必填' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(adminApi.updateCommonReportField).toHaveBeenCalledWith('common-1', { drainageFieldId: 'field-1', reportType: 'signature', required: true }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
});
});
+29 -11
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Database, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
@@ -39,6 +39,8 @@ export function AdminDrainageFieldsPage() {
const [error, setError] = useState('');
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
const [configuringCommon, setConfiguringCommon] = useState(false);
const [editingCommonId, setEditingCommonId] = useState<string>();
const [commonSaving, setCommonSaving] = useState(false);
const [commonFieldId, setCommonFieldId] = useState('');
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
const [commonRequired, setCommonRequired] = useState(false);
@@ -92,8 +94,12 @@ export function AdminDrainageFieldsPage() {
}
function createCommonField() {
if (!commonFieldId) return;
adminApi.createCommonReportField({ drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired })
if (!commonFieldId || commonSaving) return;
setCommonSaving(true);
setError('');
const body = { drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired };
const request = editingCommonId ? adminApi.updateCommonReportField(editingCommonId, body) : adminApi.createCommonReportField(body);
request
.then(() => {
setCommonFieldId('');
setCommonReportType('signature');
@@ -101,7 +107,17 @@ export function AdminDrainageFieldsPage() {
setConfiguringCommon(false);
loadData();
})
.catch((failure: Error) => setError(failure.message || '通用字段配置失败'));
.catch((failure: Error) => setError(failure.message || '通用字段配置失败'))
.finally(() => setCommonSaving(false));
}
function openCommonField(field?: CommonReportField) {
setEditingCommonId(field?.id);
setCommonFieldId(field?.drainageFieldId ?? '');
setCommonReportType(field?.reportType ?? 'signature');
setCommonRequired(field?.required ?? false);
setError('');
setConfiguringCommon(true);
}
function deleteCommonField() {
@@ -151,11 +167,11 @@ export function AdminDrainageFieldsPage() {
<h2></h2>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setConfiguringCommon(true)} size="sm" variant="secondary"></Button>
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary"></Button>
</div>
<div className="admin-drainage-common-grid">
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onDelete={setCommonDeleteTarget} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onDelete={setCommonDeleteTarget} tone="warning" />
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="warning" />
</div>
</div>
@@ -172,15 +188,17 @@ export function AdminDrainageFieldsPage() {
</div>
<Modal
footer={<><Button onClick={() => setConfiguringCommon(false)} variant="ghost"></Button><Button disabled={!commonFieldId} onClick={createCommonField}></Button></>}
footer={<><Button disabled={commonSaving} onClick={() => setConfiguringCommon(false)} variant="ghost"></Button><Button disabled={!commonFieldId || commonSaving} onClick={createCommonField}>{commonSaving ? '保存中...' : '保存'}</Button></>}
onClose={() => setConfiguringCommon(false)}
open={configuringCommon}
title="配置通用字段"
title={editingCommonId ? '修改通用字段配置' : '配置通用字段'}
>
<div className="admin-system-modal-form">
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}${field.code}`, value: field.id }))]} value={commonFieldId} />
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
<p></p>
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>
@@ -237,6 +255,6 @@ export function AdminDrainageFieldsPage() {
);
}
function CommonFieldGroup({ fields, label, onDelete, tone }: { fields: CommonReportField[]; label: string; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
function CommonFieldGroup({ fields, label, onEdit, onDelete, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost"></Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
}
@@ -31,15 +31,21 @@ export function SignatureFormModal({
telecom: payload?.carrierStatus.telecom ?? 'filing',
reportValues: payload?.signatureReportValues ?? {},
});
const [reportFields, setReportFields] = useState<ApplicationReportField[]>([]);
const [fieldResult, setFieldResult] = useState<{ applicationId: string; fields: ApplicationReportField[]; error: string } | null>(null);
const fieldsLoading = fieldResult?.applicationId !== form.applicationId;
const reportFields = fieldsLoading ? [] : fieldResult?.fields ?? [];
const fieldsError = fieldsLoading ? '' : fieldResult?.error ?? '';
const [nameInputError, setNameInputError] = useState('');
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
useEffect(() => {
let active = true;
const request = form.applicationId
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
: adminApi.listCommonApplicationReportFields('signature');
request.then(setReportFields).catch(() => setReportFields([]));
request.then((items) => { if (active) setFieldResult({ applicationId: form.applicationId, fields: items, error: '' }); })
.catch((failure: Error) => { if (active) setFieldResult({ applicationId: form.applicationId, fields: [], error: failure.message || '报备资料要求加载失败' }); });
return () => { active = false; };
}, [form.applicationId]);
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
@@ -58,7 +64,7 @@ export function SignatureFormModal({
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !signatureNameValid || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
<Button disabled={!form.tenantId || !signatureNameValid || fieldsLoading || Boolean(fieldsError) || hasMissingRequiredReportValue(reportFields, form.reportValues)} onClick={() => onSubmit(form)}></Button>
</>
)}
onClose={onClose}
@@ -72,6 +78,7 @@ export function SignatureFormModal({
)}
>
<div className="signature-form">
{fieldsLoading ? <p>...</p> : fieldsError ? <p className="form-error">{fieldsError}</p> : null}
<section>
<h3></h3>
<div className="signature-alert">
@@ -59,8 +59,8 @@
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: 96px 96px minmax(360px, 1fr) 96px 112px 36px;
min-width: 960px;
grid-template-columns: 96px 96px minmax(360px, 1fr) 112px 36px;
min-width: 850px;
}
.admin-sms-record-list__header {
@@ -110,7 +110,8 @@
color: #92400e;
}
.admin-sms-record-content mark {
.admin-sms-record-content mark,
.admin-sms-detail-content mark {
background: color-mix(in srgb, #f59e0b 32%, transparent);
border-radius: 3px;
color: inherit;
@@ -122,11 +123,25 @@
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin-left: var(--space-2);
padding: 1px var(--space-2);
vertical-align: middle;
}
.admin-sms-record-status-stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.admin-sms-record-filter__actions .ui-query-buttons {
grid-column: 1 / -1;
}
.admin-sms-record-filter__actions .ui-query-buttons > button {
flex: 1;
}
.admin-sms-record-drainage-badge.is-yes {
background: color-mix(in srgb, #f59e0b 18%, transparent);
color: #92400e;
@@ -1,6 +1,7 @@
import { AlertTriangle, Info, MessageSquare } from 'lucide-react';
import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi';
import { Button, CarrierTag, Modal, Tag } from '@/components/ui';
import { DrainageContent } from './SmsRecordList';
import {
buildRouteRows,
getReceiptNotice,
@@ -49,6 +50,8 @@ export function SendDetailModal({
</div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
<div><span></span><strong>{getTime(record.deliveredAt)}</strong></div>
<div><span></span><Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}</Tag></div>
<div><span></span><strong>{getTime(record.queuedAt)}</strong></div>
<div><span></span><strong>{record.phoneNumber || '-'}</strong></div>
<div><span></span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
@@ -64,7 +67,7 @@ export function SendDetailModal({
) : null}
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{record.content}</p>
<p className="admin-sms-detail-content"><DrainageContent record={record} /></p>
</section>
<section>
@@ -1,6 +1,6 @@
import { Search, Smartphone } from 'lucide-react';
import { Smartphone } from 'lucide-react';
import {
Button,
QueryButtons,
DateRangeInput,
Input,
Select,
@@ -95,8 +95,7 @@ export function SmsRecordFilter({
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button>
<QueryButtons onQuery={onQuery} onReset={onReset} />
</div>
</div>
);
+6 -9
View File
@@ -21,7 +21,7 @@ function StatusLine({ record }: { record: SmsMessageRecord }) {
);
}
function DrainageContent({ record }: { record: SmsMessageRecord }) {
export function DrainageContent({ record }: { record: SmsMessageRecord }) {
const ranges = (record.drainageDetection?.matches ?? [])
.filter((match) => Number.isInteger(match.start) && Number.isInteger(match.end) && match.start >= 0 && match.end > match.start && match.end <= record.content.length)
.sort((a, b) => a.start - b.start || a.end - b.end)
@@ -70,7 +70,7 @@ export function SmsRecordList({
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : (
<>
<div aria-hidden="true" className="admin-sms-record-list__header">
<span></span><span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
{records.map((record) => {
const submitDate = getDate(record.queuedAt);
@@ -80,16 +80,16 @@ export function SmsRecordList({
<div className="admin-sms-record-group" key={record.id}>
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
<article className="admin-sms-record-card">
<StatusLine record={record} />
<div className="admin-sms-record-status-stack">
<StatusLine record={record} />
{record.hasDrainageContent === true ? <span className="admin-sms-record-drainage-badge is-yes"></span> : null}
</div>
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
</time>
<div className="admin-sms-record-main">
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-context">
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
@@ -97,9 +97,6 @@ export function SmsRecordList({
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
</div>
</div>
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span></span>}
</time>
<div className="admin-sms-record-billing">
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
<span>{record.billingUnits} · {record.content.length} </span>
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { SmsMessageRecord } from '@/api/adminApi';
import { SmsRecordList } from './SmsRecordList';
import { SendDetailModal } from './SendDetailModal';
const record = {
id: 'record-1', messageId: 'message-1', content: '请访问example.com查询', hasDrainageContent: true,
drainageDetection: { matches: [{ start: 3, end: 14, value: 'example.com' }] },
queuedAt: '2026-08-31T01:00:00Z', deliveredAt: '2026-08-31T01:00:05Z', status: 'delivered',
amountCents: 5, billingUnits: 1, phoneNumber: '13800138000', submitRecords: [], receiptRecords: [],
} as unknown as SmsMessageRecord;
describe('SMS drainage and final receipt presentation', () => {
it('shows only a positive drainage badge under status and removes receipt time from list', () => {
const { container } = render(<SmsRecordList currentPage={1} loading={false} records={[record, { ...record, id: 'record-2', hasDrainageContent: false }]} total={2} totalPages={1} onExport={() => {}} onOpenDetail={() => {}} onPageChange={() => {}} />);
expect(screen.getAllByText('含引流')).toHaveLength(1);
expect(container.querySelector('.admin-sms-record-status-stack .admin-sms-record-drainage-badge')).toHaveTextContent('含引流');
expect(screen.queryByText('不含引流')).not.toBeInTheDocument();
expect(screen.queryByText('回执时间')).not.toBeInTheDocument();
expect(screen.queryByText('09:00:05')).not.toBeInTheDocument();
});
it('highlights backend detection ranges and displays the final receipt time in details', () => {
render(<SendDetailModal record={record} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
expect(screen.getByText('最终回执时间')).toBeVisible();
expect(screen.getAllByText('2026-08-31 09:00:05').length).toBeGreaterThan(0);
expect(screen.getByText('含引流')).toBeVisible();
expect(document.querySelector('.admin-sms-detail-content mark')).toHaveTextContent('example.com');
});
it('shows negative drainage only in details and does not mislabel untested historical records', () => {
const { rerender } = render(<SendDetailModal record={{ ...record, hasDrainageContent: false }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
expect(screen.getByText('不含引流')).toBeVisible();
expect(document.querySelector('mark')).toBeNull();
rerender(<SendDetailModal record={{ ...record, hasDrainageContent: undefined }} segmentAudits={[]} segmentLoading={false} onClose={() => {}} />);
expect(screen.getByText('未检测')).toBeVisible();
});
});
@@ -111,12 +111,12 @@ function makeTrendOption(params: {
suffix: string;
maximum?: number;
}): EChartsOption {
const first = params.series[0]?.points ?? [];
const timestamps = [...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp)))].sort();
return {
animationDuration: 280,
color: params.series.map((item) => item.color),
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
legend: params.series.length > 1 ? { top: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
legend: params.series.length > 1 ? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
tooltip: {
trigger: 'axis',
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
@@ -124,7 +124,7 @@ function makeTrendOption(params: {
xAxis: {
type: 'category',
boundaryGap: false,
data: timeLabels(first, params.range),
data: timeLabels(timestamps.map((timestamp) => ({ timestamp, value: 0 })), params.range),
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
@@ -134,15 +134,18 @@ function makeTrendOption(params: {
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
splitLine: { lineStyle: { color: '#eef0f3' } },
},
series: params.series.map((item) => ({
series: params.series.map((item) => {
const values = new Map(item.points.map((point) => [point.timestamp, point.value]));
return {
name: item.name,
data: item.points.map((point) => point.value),
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.07 },
})),
};
}),
};
}
@@ -278,8 +281,12 @@ export function AdminSystemMonitoringPage() {
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
}), [overview?.trends.memoryUsagePercent, range]);
const diskOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: '根磁盘', points: overview?.trends.diskUsagePercent ?? [], color: '#d97706' }],
}), [overview?.trends.diskUsagePercent, range]);
range, maximum: 100, suffix: '%', series: (overview?.disks ?? []).map((disk, index) => ({
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
points: disk.trend,
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
})),
}), [overview?.disks, range]);
const networkOption = useMemo(() => makeTrendOption({
range, suffix: ' B/s', series: [
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
@@ -345,7 +352,11 @@ export function AdminSystemMonitoringPage() {
<div className="system-monitoring-metrics">
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>使</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div><div><span>使</span><strong>{formatPercent(metrics?.diskUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.diskAvailableBytes ?? null)} / {formatBytes(metrics?.diskTotalBytes ?? null)}</small></div></article>
{(overview?.disks ?? []).map((disk) => <article className="surface system-monitoring-metric" key={disk.id}>
<div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div>
<div><span>{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}</span><strong>{formatPercent(disk.usagePercent)}</strong><small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>{disk.device} · {disk.filesystem}</small><small>{formatBytes(disk.availableBytes)} / {formatBytes(disk.totalBytes)}</small></div>
</article>)}
{!overview?.disks?.length ? <article className="surface system-monitoring-metric"><HardDrive size={19} /><div><span></span><strong></strong></div></article> : null}
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span></span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small> {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
</div>
@@ -353,7 +364,7 @@ export function AdminSystemMonitoringPage() {
<div className="system-monitoring-chart-stack">
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU </strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong></strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong></strong></div><span>{formatPercent(metrics?.diskUsagePercent ?? null)}</span></header>{overview?.trends.diskUsagePercent.length ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong></strong></div><span>{overview?.disks?.length ?? 0} </span></header>{overview?.disks?.some((disk) => disk.trend.length) ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong></strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
</div>
+23 -8
View File
@@ -13,6 +13,7 @@ import {
Modal,
Pagination,
QueryPanel,
QueryButtons,
Select,
Tag,
type DateRangeValue,
@@ -97,6 +98,7 @@ export function ClientBatchTasksPage() {
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [applied, setApplied] = useState(() => ({ keyword, application, submittedDateRange }));
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
@@ -106,10 +108,10 @@ export function ClientBatchTasksPage() {
function loadTasks(targetPage = page) {
setLoading(true);
clientApi.listBatchTasksPage({
keyword: keyword.trim() || undefined,
applicationKeyword: application === 'all' ? undefined : application,
createdAtFrom: submittedDateRange.start,
createdAtTo: submittedDateRange.end,
keyword: applied.keyword.trim() || undefined,
applicationKeyword: applied.application === 'all' ? undefined : applied.application,
createdAtFrom: applied.submittedDateRange.start,
createdAtTo: applied.submittedDateRange.end,
page: targetPage,
pageSize,
})
@@ -124,7 +126,21 @@ export function ClientBatchTasksPage() {
useEffect(() => {
loadTasks(page);
}, [page]);
}, [applied, page]);
function query() {
setPage(1);
setApplied({ keyword, application, submittedDateRange });
}
function reset() {
const defaults = { keyword: '', application: 'all', submittedDateRange: recentBeijingDateRange(7) };
setKeyword('');
setApplication('all');
setSubmittedDateRange(defaults.submittedDateRange);
setPage(1);
setApplied(defaults);
}
useEffect(() => {
clientApi.listApplicationOptions()
@@ -236,8 +252,7 @@ export function ClientBatchTasksPage() {
onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setPage(1);
loadTasks(1);
query();
}
}}
placeholder="输入发送批次号搜索"
@@ -246,7 +261,7 @@ export function ClientBatchTasksPage() {
/>
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<Button onClick={() => { setPage(1); loadTasks(1); }} variant="primary"></Button>
<QueryButtons onQuery={query} onReset={reset} />
</QueryPanel>
<div className="surface batch-table-card">
+43
View File
@@ -0,0 +1,43 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientBatchTasksPage } from './ClientBatchTasksPage';
import { ClientSendDetailPage } from './ClientSendDetailPage';
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
const { clientApi } = vi.hoisted(() => ({ clientApi: {
listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
} }));
vi.mock('@/api/adminApi', () => ({ clientApi }));
describe('explicit client queries', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
clientApi.listApplicationOptions.mockResolvedValue([]);
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
});
it.each([
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
{ Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' },
])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => {
render(<MemoryRouter><Component /></MemoryRouter>);
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); });
expect(method).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })));
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })));
expect(screen.getByLabelText(label)).toHaveValue('');
});
});
+24 -9
View File
@@ -7,6 +7,7 @@ import {
Input,
Pagination,
QueryPanel,
QueryButtons,
Select,
Tag,
type DateRangeValue,
@@ -71,6 +72,7 @@ export function ClientSendDetailPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [applied, setApplied] = useState(() => ({ applicationId, status, dateRange, contentKeyword, phoneKeyword }));
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
@@ -80,12 +82,12 @@ export function ClientSendDetailPage() {
function loadData(targetPage = page) {
setLoading(true);
clientApi.listMessages({
applicationId: applicationId === 'all' ? undefined : applicationId,
phoneNumber: phoneKeyword || undefined,
status: status === 'all' ? undefined : status,
contentKeyword: contentKeyword || undefined,
queuedAtFrom: dateRange.start || undefined,
queuedAtTo: dateRange.end || undefined,
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
phoneNumber: applied.phoneKeyword.trim() || undefined,
status: applied.status === 'all' ? undefined : applied.status,
contentKeyword: applied.contentKeyword.trim() || undefined,
queuedAtFrom: applied.dateRange.start || undefined,
queuedAtTo: applied.dateRange.end || undefined,
page: targetPage,
pageSize: 10,
})
@@ -100,7 +102,7 @@ export function ClientSendDetailPage() {
useEffect(() => {
loadData(page);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]);
}, [applied, page]);
useEffect(() => {
clientApi.listApplicationOptions()
@@ -121,9 +123,21 @@ export function ClientSendDetailPage() {
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows;
useEffect(() => {
function query() {
setPage(1);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, phoneKeyword, status]);
setApplied({ applicationId, status, dateRange, contentKeyword, phoneKeyword });
}
function reset() {
const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' };
setApplicationId(defaults.applicationId);
setStatus(defaults.status);
setDateRange(defaults.dateRange);
setContentKeyword('');
setPhoneKeyword('');
setPage(1);
setApplied(defaults);
}
return (
<section className="page-stack">
@@ -165,6 +179,7 @@ export function ClientSendDetailPage() {
prefix={<Smartphone size={16} />}
value={phoneKeyword}
/>
<QueryButtons onQuery={query} onReset={reset} />
</QueryPanel>
{error ? <p className="form-error">{error}</p> : null}
+30 -1
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientSignaturesPage } from './ClientSignaturesPage';
@@ -57,6 +57,7 @@ describe('ClientSignaturesPage drainage presentation', () => {
clientApi.listApplicationOptions.mockResolvedValue([{ id: 'app-1', name: '测试应用', status: 'active' }]);
clientApi.getSignatureWorkspace.mockResolvedValue({ items: [signature], summary: { total: 1, pending: 0, approved: 1, rejected: 0, draft: 0 }, total: 1, page: 1, pageSize: 10 });
clientApi.listApplicationReportFields.mockResolvedValue([]);
clientApi.listCommonApplicationReportFields.mockResolvedValue([]);
});
it('groups the three real carrier summaries into a readable status area', async () => {
@@ -82,4 +83,32 @@ describe('ClientSignaturesPage drainage presentation', () => {
expect(within(dialog).queryByLabelText('名称')).not.toBeInTheDocument();
expect(within(dialog).queryByLabelText('访问地址')).not.toBeInTheDocument();
});
it('keeps the newest application field requirements when older requests finish late', async () => {
let resolveOld!: (value: unknown[]) => void;
clientApi.listCommonApplicationReportFields.mockReturnValue(new Promise((resolve) => { resolveOld = resolve; }));
clientApi.listApplicationReportFields.mockResolvedValue([{ id: 'current', code: 'owner', name: '应用最新主体', fieldType: 'string', required: true }]);
render(<ClientSignaturesPage />);
await screen.findByText('【测试签名】');
fireEvent.click(screen.getByRole('button', { name: '新增签名' }));
const dialog = screen.getByRole('dialog', { name: '新增签名' });
expect(within(dialog).getByRole('button', { name: '提交审核' })).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText('所属应用'), { target: { value: 'app-1' } });
await within(dialog).findByLabelText('* 应用最新主体');
await act(async () => resolveOld([{ id: 'old', code: 'old', name: '过期字段', fieldType: 'string', required: false }]));
expect(within(dialog).queryByLabelText('过期字段')).not.toBeInTheDocument();
expect(within(dialog).getByLabelText('* 应用最新主体')).toBeVisible();
});
it('blocks signature submission when current reporting requirements cannot be loaded', async () => {
clientApi.listCommonApplicationReportFields.mockRejectedValue(new Error('字段加载失败'));
render(<ClientSignaturesPage />);
await screen.findByText('【测试签名】');
fireEvent.click(screen.getByRole('button', { name: '新增签名' }));
const dialog = screen.getByRole('dialog', { name: '新增签名' });
fireEvent.change(within(dialog).getByLabelText('短信签名'), { target: { value: '【新签名】' } });
await waitFor(() => expect(within(dialog).getByText('字段加载失败')).toBeVisible());
expect(within(dialog).getByRole('button', { name: '提交审核' })).toBeDisabled();
expect(clientApi.createSignature).not.toHaveBeenCalled();
});
});
+18 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
import { Button, CarrierTag, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Textarea } from '@/components/ui';
import {
@@ -72,6 +72,7 @@ function ReviewFields({
? <Input
key={field.id}
label={`${field.required ? '* ' : ''}${field.name}`}
hint={field.description ?? undefined}
onChange={(event) => onChange(field.code, event.target.value)}
value={String(values[field.code] ?? '')}
/>
@@ -103,7 +104,10 @@ function SignatureModal({
}) {
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
const [name, setName] = useState(signature?.name ?? '');
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [fieldResult, setFieldResult] = useState<{ applicationId: string; fields: ClientApplicationReportField[]; error: string } | null>(null);
const fieldsLoading = fieldResult?.applicationId !== applicationId;
const fields = fieldsLoading ? [] : fieldResult?.fields ?? [];
const fieldsError = fieldsLoading ? '' : fieldResult?.error ?? '';
const [values, setValues] = useState<Record<string, unknown>>(signature?.reportValues ?? {});
const [uploadingCode, setUploadingCode] = useState('');
const [saving, setSaving] = useState(false);
@@ -111,10 +115,13 @@ function SignatureModal({
const [nameInputError, setNameInputError] = useState('');
useEffect(() => {
let active = true;
const request = applicationId
? clientApi.listApplicationReportFields(applicationId, 'signature')
: clientApi.listCommonApplicationReportFields('signature');
request.then(setFields).catch((failure: Error) => setError(failure.message || '审核资料加载失败'));
request.then((items) => { if (active) setFieldResult({ applicationId, fields: items, error: '' }); })
.catch((failure: Error) => { if (active) setFieldResult({ applicationId, fields: [], error: failure.message || '审核资料加载失败' }); });
return () => { active = false; };
}, [applicationId]);
async function upload(field: ClientApplicationReportField, file?: File) {
@@ -132,6 +139,7 @@ function SignatureModal({
}
async function save() {
if (fieldsLoading || fieldsError) return;
if (!isCompleteSmsSignature(name)) {
setError(getSmsSignatureValidationError(name) ?? '短信签名格式不正确');
return;
@@ -157,7 +165,7 @@ function SignatureModal({
const signatureNameValid = !nameInputError && isCompleteSmsSignature(name);
const signatureNameError = nameInputError || (name ? getSmsSignatureValidationError(name) : undefined);
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!signatureNameValid || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!signatureNameValid || missingRequired || saving || fieldsLoading || Boolean(fieldsError) || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose}
open
size="xl"
@@ -190,7 +198,7 @@ function SignatureModal({
/>
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
{fieldsLoading ? <p>...</p> : fieldsError ? <p className="form-error">{fieldsError}</p> : <ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />}
</section>
{error ? <p className="form-error">{error}</p> : null}
</div>
@@ -263,6 +271,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
}
export function ClientSignaturesPage() {
const requestSequence = useRef(0);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
const [keyword, setKeyword] = useState('');
@@ -279,6 +288,7 @@ export function ClientSignaturesPage() {
const pageSize = 10;
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
Promise.all([
clientApi.listApplicationOptions(),
@@ -290,12 +300,13 @@ export function ClientSignaturesPage() {
}),
])
.then(([applicationItems, signatureWorkspace]) => {
if (sequence !== requestSequence.current) return;
setApplications(applicationItems.filter((item) => item.status === 'active'));
setWorkspace(signatureWorkspace);
setError('');
})
.catch((failure: Error) => setError(failure.message || '签名与引流信息加载失败'))
.finally(() => setLoading(false));
.catch((failure: Error) => { if (sequence === requestSequence.current) setError(failure.message || '签名与引流信息加载失败'); })
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
}
useEffect(() => {
+5 -2
View File
@@ -209,6 +209,7 @@ function TemplateModal({
}
export function ClientTemplatesPage() {
const requestSequence = useRef(0);
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
@@ -221,17 +222,19 @@ export function ClientTemplatesPage() {
const pageSize = 10;
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()])
.then(([applicationItems, templateResult, signatureItems]) => {
if (sequence !== requestSequence.current) return;
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setTotal(templateResult.total);
setSignatures(signatureItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
.finally(() => setLoading(false));
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
}
useEffect(() => {
+19 -8
View File
@@ -10,6 +10,7 @@ import {
Modal,
Pagination,
QueryPanel,
QueryButtons,
Table,
type DateRangeValue,
type TableColumn,
@@ -30,6 +31,7 @@ export function ClientUplinkMessagesPage() {
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [applied, setApplied] = useState(() => ({ phoneKeyword, contentKeyword, dateRange }));
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
@@ -41,7 +43,7 @@ export function ClientUplinkMessagesPage() {
function loadData(targetPage = page) {
setLoading(true);
clientApi.listUplinkMessagesPage({ phoneNumber: phoneKeyword || undefined, keyword: contentKeyword || undefined, startTime: dateRange.start ? `${dateRange.start}T00:00:00+08:00` : undefined, endTime: dateRange.end ? `${dateRange.end}T23:59:59+08:00` : undefined, page: targetPage, pageSize })
clientApi.listUplinkMessagesPage({ phoneNumber: applied.phoneKeyword.trim() || undefined, keyword: applied.contentKeyword.trim() || undefined, startTime: applied.dateRange.start ? `${applied.dateRange.start}T00:00:00+08:00` : undefined, endTime: applied.dateRange.end ? `${applied.dateRange.end}T23:59:59+08:00` : undefined, page: targetPage, pageSize })
.then((result) => {
setMessages(result.items);
setTotal(result.total);
@@ -68,14 +70,22 @@ export function ClientUplinkMessagesPage() {
}
useEffect(() => {
setPage(1);
const timer = window.setTimeout(() => loadData(1), 300);
return () => window.clearTimeout(timer);
}, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]);
loadData(page);
}, [applied, page]);
useEffect(() => {
if (page > 1) loadData(page);
}, [page]);
function query() {
setPage(1);
setApplied({ phoneKeyword, contentKeyword, dateRange });
}
function reset() {
const defaults = { phoneKeyword: '', contentKeyword: '', dateRange: recentBeijingDateRange(7) };
setPhoneKeyword('');
setContentKeyword('');
setDateRange(defaults.dateRange);
setPage(1);
setApplied(defaults);
}
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
@@ -119,6 +129,7 @@ export function ClientUplinkMessagesPage() {
prefix={<Search size={16} />}
value={contentKeyword}
/>
<QueryButtons onQuery={query} onReset={reset} />
</QueryPanel>
{error ? <p className="form-error">{error}</p> : null}