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
+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}