feat: optimize signature workflows and high-frequency queries

This commit is contained in:
hectorzhao
2026-09-02 15:45:48 +08:00
parent 9b8196ecab
commit ad89e8fed7
48 changed files with 1334 additions and 352 deletions
+33 -43
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import {
@@ -7,7 +7,6 @@ import {
type ChannelReportField,
type ClientSmsSignature,
type DictionaryItem,
type ReportRecord,
type ReportTask,
type SingleReportMaterialDetail,
} from '@/api/adminApi';
@@ -50,13 +49,6 @@ function formatSignatureName(value?: string | null) {
return `${name || '-'}`;
}
function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo);
return Array.isArray(payload.links)
? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object')
: [];
}
function DateTime({ value }: { value?: unknown }) {
return value ? (
<span className="channel-report-date">{formatDateTime(String(value))}</span>
@@ -203,8 +195,6 @@ export function AdminChannelReportPage() {
const { channelId = '' } = useParams();
const [channel, setChannel] = useState<AdminChannel>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [records, setRecords] = useState<ReportRecord[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState('');
@@ -214,6 +204,7 @@ export function AdminChannelReportPage() {
const [todaySendMax, setTodaySendMax] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' });
const pageSize = 10;
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
const [detail, setDetail] = useState<{
@@ -228,41 +219,37 @@ export function AdminChannelReportPage() {
const [configType, setConfigType] = useState<ReportType>();
const [error, setError] = useState('');
function loadData() {
Promise.all([
adminApi.listChannels(),
adminApi.listReportTasksPage({
function loadData(targetPage = page, filters = appliedFilters) {
adminApi.listReportTasksPage({
channelId,
keyword: keyword.trim() || undefined,
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
todaySendMin: todaySendMin ? Number(todaySendMin) : undefined,
todaySendMax: todaySendMax ? Number(todaySendMax) : undefined,
keyword: filters.keyword || undefined,
status: filters.status === 'all' ? undefined : filters.status,
carrier: filters.carrier === 'all' ? undefined : filters.carrier,
todaySendMin: filters.todaySendMin ? Number(filters.todaySendMin) : undefined,
todaySendMax: filters.todaySendMax ? Number(filters.todaySendMax) : undefined,
sort: 'todaySendDesc',
page,
page: targetPage,
pageSize,
}),
adminApi.listReportRecords({ channelId }),
adminApi.listEnterpriseSignatures(),
adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(),
])
.then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
})
.then((taskPage) => {
setTasks(taskPage.items);
setTotal(taskPage.total);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
}
useEffect(loadData, [channelId, page]);
useEffect(() => {
void Promise.all([adminApi.listChannels(), adminApi.listChannelReportFields(channelId), adminApi.listDrainageFields()])
.then(([channelItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
})
.catch((failure: Error) => setError(failure.message || '通道报备配置加载失败'));
}, [channelId]);
const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
useEffect(() => { loadData(page); }, [channelId, page]);
const visibleTasks = tasks;
async function openMaterial(task: ReportTask) {
@@ -303,13 +290,10 @@ export function AdminChannelReportPage() {
}
}
function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
}
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
if (!configType) return;
await adminApi.replaceChannelReportFields(channelId, configType, nextFields);
setFields(await adminApi.listChannelReportFields(channelId));
loadData();
}
@@ -420,6 +404,10 @@ export function AdminChannelReportPage() {
setCarrier('all');
setTodaySendMin('');
setTodaySendMax('');
const filters = { keyword: '', status: 'all', carrier: 'all', todaySendMin: '', todaySendMax: '' };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
}}
variant="ghost"
>
@@ -428,8 +416,10 @@ export function AdminChannelReportPage() {
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = { keyword: keyword.trim(), status, carrier, todaySendMin, todaySendMax };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData();
else loadData(1, filters);
}}
>
@@ -453,12 +443,12 @@ export function AdminChannelReportPage() {
<div className="channel-report-empty"></div>
) : (
visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const signature = task.signature as ClientSmsSignature | undefined;
const drainage =
task.reportType === 'drainage'
? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId)
? task.drainageInfo as DrainageItem | undefined
: undefined;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
const reportedAt = task.approvedAt;
return (
<div
className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`}
+5 -8
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type CmppConnectionState } from '@/api/adminApi';
import { adminApi } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select } from '@/components/ui';
import { ChannelFormModal } from './channels/ChannelFormModal';
import { ChannelLogModal } from './channels/ChannelLogModal';
@@ -32,13 +32,10 @@ export function AdminChannelsPage() {
adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }),
adminApi.getSendQuality(),
])
.then(async ([result, quality]) => {
.then(([result, quality]) => {
const visibleChannels = result.items;
const connections = await Promise.all(visibleChannels.map((channel) =>
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
));
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index], qualityByChannel.get(item.id))));
setChannels(visibleChannels.map((item) => mapApiChannel(item, item.connectionStates ?? [], qualityByChannel.get(item.id))));
setTotal(result.total);
setError('');
})
@@ -132,8 +129,8 @@ export function AdminChannelsPage() {
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions">
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadChannels(1); }}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); setPage(1); void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else void loadChannels(1); }}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); if (page !== 1) setPage(1); else void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost"></Button>
</div>
</div>
</div>
@@ -23,6 +23,9 @@ export function AdminDownstreamDeliveriesPage() {
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [dateRange, setDateRange] = useState<DateRangeValue>(recentSevenDays);
const [appliedFilters, setAppliedFilters] = useState(() => ({
keyword: '', status: 'all', deliveryType: 'all', applicationId: 'all', tenantId: 'all', dateRange: recentSevenDays(),
}));
const [requeueTarget, setRequeueTarget] = useState<RequeueTarget | null>(null);
const [requeueBusy, setRequeueBusy] = useState(false);
const [requeueResult, setRequeueResult] = useState<RequeueResult | null>(null);
@@ -45,14 +48,14 @@ export function AdminDownstreamDeliveriesPage() {
const [taskItemTotal, setTaskItemTotal] = useState(0);
const currentTaskFilter = useCallback(() => ({
keyword: keyword || undefined,
status,
deliveryType,
tenantId,
applicationId,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
}), [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, status, tenantId]);
keyword: appliedFilters.keyword || undefined,
status: appliedFilters.status,
deliveryType: appliedFilters.deliveryType,
tenantId: appliedFilters.tenantId,
applicationId: appliedFilters.applicationId,
createdAtFrom: appliedFilters.dateRange.start,
createdAtTo: appliedFilters.dateRange.end,
}), [appliedFilters]);
const loadRequeueTasks = useCallback(() => {
adminApi.listDownstreamRequeueTasks({ status: requeueTaskStatus, page: requeueTaskPage, pageSize: 10 })
@@ -64,43 +67,53 @@ export function AdminDownstreamDeliveriesPage() {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
tenantId,
deliveryType,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
applicationId: appliedFilters.applicationId,
tenantId: appliedFilters.tenantId,
deliveryType: appliedFilters.deliveryType,
createdAtFrom: appliedFilters.dateRange.start,
createdAtTo: appliedFilters.dateRange.end,
}),
adminApi.listDownstreamDeliveries({
keyword,
status,
deliveryType,
applicationId,
tenantId,
keyword: appliedFilters.keyword,
status: appliedFilters.status,
deliveryType: appliedFilters.deliveryType,
applicationId: appliedFilters.applicationId,
tenantId: appliedFilters.tenantId,
page,
pageSize,
createdAtFrom: dateRange.start,
createdAtTo: dateRange.end,
createdAtFrom: appliedFilters.dateRange.start,
createdAtTo: appliedFilters.dateRange.end,
}),
adminApi.listEnterpriseApplications(),
adminApi.listTenants(),
])
.then(([dashboardResponse, response, apps, tenantOptions]) => {
.then(([dashboardResponse, response]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setTenants(tenantOptions);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, dateRange.end, dateRange.start, deliveryType, keyword, page, pageSize, status, tenantId]);
}, [appliedFilters, page, pageSize]);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listEnterpriseApplicationOptions(), adminApi.listTenantOptions()])
.then(([apps, tenantOptions]) => {
if (cancelled) return;
setApplications(apps);
setTenants(tenantOptions);
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业及应用选项加载失败');
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
loadRequeueTasks();
const timer = window.setInterval(loadRequeueTasks, 3000);
@@ -273,12 +286,12 @@ export function AdminDownstreamDeliveriesPage() {
<div className="surface ui-filter-row">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<DateRangeInput label="创建日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<DateRangeInput label="创建日期" onChange={setDateRange} value={dateRange} />
<Select
label="企业"
options={[{ label: '全部企业', value: 'all' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={tenantId}
onChange={(event) => { setTenantId(event.target.value); setApplicationId('all'); setPage(1); }}
onChange={(event) => { setTenantId(event.target.value); setApplicationId('all'); }}
/>
<Select
label="状态"
@@ -294,7 +307,6 @@ export function AdminDownstreamDeliveriesPage() {
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
/>
<Select
@@ -307,7 +319,6 @@ export function AdminDownstreamDeliveriesPage() {
value={deliveryType}
onChange={(event) => {
setDeliveryType(event.target.value);
setPage(1);
}}
/>
<Select
@@ -319,11 +330,10 @@ export function AdminDownstreamDeliveriesPage() {
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions ui-filter-actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button icon={<Search size={16} />} onClick={() => { setPage(1); setAppliedFilters({ keyword: keyword.trim(), status, deliveryType, applicationId, tenantId, dateRange }); }}></Button>
<Button
onClick={() => {
setKeyword('');
@@ -331,8 +341,10 @@ export function AdminDownstreamDeliveriesPage() {
setDeliveryType('all');
setTenantId('all');
setApplicationId('all');
setDateRange(recentSevenDays());
const nextDateRange = recentSevenDays();
setDateRange(nextDateRange);
setPage(1);
setAppliedFilters({ keyword: '', status: 'all', deliveryType: 'all', applicationId: 'all', tenantId: 'all', dateRange: nextDateRange });
}}
variant="ghost"
>
@@ -77,7 +77,7 @@ export function AdminEnterpriseApplicationsPage() {
if (tenants.length === 0) {
setTenantsLoading(true);
try {
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
setTenants(await adminApi.listTenantOptions());
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业列表加载失败');
} finally {
@@ -166,8 +166,8 @@ export function AdminEnterpriseApplicationsPage() {
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedStatus(filters.status);
setPage(1);
void loadSmsApps(filters, 1);
if (page !== 1) setPage(1);
else void loadSmsApps(filters, 1);
}
function resetApplicationFilters() {
@@ -178,8 +178,8 @@ export function AdminEnterpriseApplicationsPage() {
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedStatus('all');
setPage(1);
void loadSmsApps(filters, 1);
if (page !== 1) setPage(1);
else void loadSmsApps(filters, 1);
}
return (
@@ -30,15 +30,9 @@ export function AdminEnterpriseBlacklistPage() {
const [error, setError] = useState('');
function loadData(filters = appliedFilters) {
Promise.all([
adminApi.listEnterpriseBlacklist(filters),
adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(),
])
.then(([blacklist, tenantItems, applicationItems]) => {
adminApi.listEnterpriseBlacklist(filters)
.then((blacklist) => {
setItems(blacklist as EnterpriseBlacklistItem[]);
setTenants(tenantItems);
setApplications(applicationItems);
setError('');
})
.catch((failure: Error) => setError(failure.message || '企业黑名单加载失败'));
@@ -46,6 +40,12 @@ export function AdminEnterpriseBlacklistPage() {
useEffect(() => {
loadData();
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenantItems, applicationItems]) => {
setTenants(tenantItems);
setApplications(applicationItems);
})
.catch((failure: Error) => setError(failure.message || '企业黑名单选项加载失败'));
}, []);
const modalApplications = applications.filter((application) => application.tenantId === formTenantId && application.status !== 'deleted');
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { FileSpreadsheet, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
@@ -51,6 +51,7 @@ export function AdminEnterpriseSignaturesPage() {
const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null);
const listRequestSequence = useRef(0);
const pageSize = 10;
@@ -64,22 +65,33 @@ export function AdminEnterpriseSignaturesPage() {
targetPage = page,
targetSort = signatureSort,
) {
const sequence = ++listRequestSequence.current;
try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(),
]);
const signatureResult = await adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize });
if (sequence !== listRequestSequence.current) return;
setSignatures(signatureResult.items);
setTotal(signatureResult.total);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业签名加载失败');
}
}
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenantItems, applicationItems]) => {
if (cancelled) return;
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业及应用选项加载失败');
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
queueMicrotask(() => void loadData(undefined, page));
}, [page]);
@@ -142,6 +154,46 @@ export function AdminEnterpriseSignaturesPage() {
await loadData();
}
async function editSignature(signature: ClientSmsSignature) {
try {
setSignatureModal(await adminApi.getEnterpriseSignature(signature.id));
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名详情加载失败');
}
}
async function openSignatureReport(signature: ClientSmsSignature) {
try {
const reportTargets = await adminApi.getEnterpriseSignatureReportTargets(signature.id);
setReportStatusTarget({ ...signature, reportTargets });
} catch (failure) {
setError(failure instanceof Error ? failure.message : '签名报备状态加载失败');
}
}
async function editDrainage(signature: ClientSmsSignature, item: DrainageInfo) {
try {
const detail = await adminApi.getEnterpriseSignature(signature.id);
const detailItem = readDrainagePayload(detail).links.find((candidate) => candidate.id === item.id);
if (!detailItem) throw new Error('引流信息不存在或已删除');
setDrainageModal({ signatureId: signature.id, item: detailItem });
} catch (failure) {
setError(failure instanceof Error ? failure.message : '引流信息详情加载失败');
}
}
async function openDrainageReport(signature: ClientSmsSignature, item: DrainageInfo) {
try {
const targets = await adminApi.getDrainageInfoReportTargets(item.id);
setDrainageStatusTarget({
signature: { ...signature, drainageReportTargets: { [item.id]: targets } },
item,
});
} catch (failure) {
setError(failure instanceof Error ? failure.message : '引流报备状态加载失败');
}
}
async function confirmDelete() {
if (!deleteTarget) {
return;
@@ -158,9 +210,10 @@ export function AdminEnterpriseSignaturesPage() {
expandedSignatureId={expandedSignatureId}
filteredSignatures={filteredSignatures}
loadData={loadData}
onAddDrainage={(signature) => setDrainageModal({ signatureId: signature.id })}
setDeleteTarget={setDeleteTarget}
setDrainageModal={setDrainageModal}
setDrainageStatusTarget={setDrainageStatusTarget}
onEditDrainage={(signature, item) => void editDrainage(signature, item)}
onOpenDrainageReport={(signature, item) => void openDrainageReport(signature, item)}
setExpandedSignatureId={setExpandedSignatureId}
setPage={setPage}
setSignatureSort={(nextSort) => {
@@ -168,8 +221,8 @@ export function AdminEnterpriseSignaturesPage() {
if (page === 1) void loadData(undefined, 1, nextSort);
else setPage(1);
}}
setReportStatusTarget={setReportStatusTarget}
setSignatureModal={setSignatureModal}
onEditSignature={(signature) => void editSignature(signature)}
onOpenSignatureReport={(signature) => void openSignatureReport(signature)}
signatureSort={signatureSort}
total={total}
totalPages={totalPages}
@@ -237,8 +290,8 @@ export function AdminEnterpriseSignaturesPage() {
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1);
void loadData(filters, 1);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}
>
@@ -259,8 +312,8 @@ export function AdminEnterpriseSignaturesPage() {
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
setPage(1);
void loadData(filters, 1);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}
variant="ghost"
>
+24 -13
View File
@@ -296,28 +296,39 @@ export function AdminEnterpriseTemplatesPage() {
const [appliedTemplateContentKeyword, setAppliedTemplateContentKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const listRequestSequence = useRef(0);
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) {
const sequence = ++listRequestSequence.current;
try {
const [templateResult, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
]);
const templateResult = await adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize });
if (sequence !== listRequestSequence.current) return;
setTemplates(templateResult.items);
setTotal(templateResult.total);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setSignatureItems(signatureList);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
}
}
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listEnterpriseSignatureOptions()])
.then(([tenantItems, applicationItems, signatureList]) => {
if (cancelled) return;
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setSignatureItems(signatureList);
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业模板选项加载失败');
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
void loadData(undefined, page);
}, [page]);
@@ -379,8 +390,8 @@ export function AdminEnterpriseTemplatesPage() {
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
setPage(1);
void loadData(filters, 1);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
@@ -392,8 +403,8 @@ export function AdminEnterpriseTemplatesPage() {
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
setPage(1);
void loadData(filters, 1);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}} variant="ghost"></Button>
</div>
</div>
@@ -191,6 +191,7 @@ function GatewaySubmitExceptionPanel() {
const [status, setStatus] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [channelId, setChannelId] = useState('all');
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'all', applicationId: 'all', channelId: 'all' });
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
@@ -204,17 +205,11 @@ function GatewaySubmitExceptionPanel() {
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.listGatewaySubmitExceptions({ keyword, status, applicationId, channelId, page, pageSize }),
adminApi.listEnterpriseApplications(),
adminApi.listChannels(),
])
.then(([response, appItems, channelItems]) => {
adminApi.listGatewaySubmitExceptions({ ...appliedFilters, page, pageSize })
.then((response) => {
setItems(response.items);
setTotal(response.total);
setSummary({ ...response.summary, oldestPendingAt: response.summary.oldestPendingAt ?? null });
setApplications(appItems);
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
setError('');
})
.catch((failure: Error) => {
@@ -223,10 +218,24 @@ function GatewaySubmitExceptionPanel() {
setError(failure.message || '提交异常加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, channelId, keyword, page, status]);
}, [appliedFilters, page]);
useEffect(() => { loadData(); }, [loadData]);
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listEnterpriseApplications(), adminApi.listChannels()])
.then(([appItems, channelItems]) => {
if (cancelled) return;
setApplications(appItems);
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '应用及通道选项加载失败');
});
return () => { cancelled = true; };
}, []);
const columns = useMemo<Array<TableColumn<GatewaySubmitException>>>(() => [
{ key: 'createdAt', title: '异常时间', width: '170px', render: (record) => formatTime(record.createdAt) },
{ key: 'messageId', title: '消息编号', width: '170px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
@@ -289,11 +298,11 @@ function GatewaySubmitExceptionPanel() {
<div className="surface mini-status-card"><CheckCircle2 size={22} /><div><span></span><strong>{summary.resolved}</strong><small>Gateway提交结果</small></div></div>
</div>
<div className="surface admin-task-filter">
<Input label="消息编号 / 错误" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="MessageId、SubmitId、失败原因" value={keyword} />
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '正在入队', value: 'requeueing' }, { label: '已重新入队', value: 'requeued' }, { label: '已处理', value: 'resolved' }]} value={status} onChange={(event) => { setStatus(event.target.value); setPage(1); }} />
<Select label="应用" options={[{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} />
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => { setChannelId(event.target.value); setPage(1); }} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button></div>
<Input label="消息编号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="MessageId、SubmitId、失败原因" value={keyword} />
<Select label="状态" options={[{ label: '全部状态', value: 'all' }, { label: '待处理', value: 'pending' }, { label: '正在入队', value: 'requeueing' }, { label: '已重新入队', value: 'requeued' }, { label: '已处理', value: 'resolved' }]} value={status} onChange={(event) => setStatus(event.target.value)} />
<Select label="应用" options={[{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} onChange={(event) => setApplicationId(event.target.value)} />
<Select label="通道" options={[{ label: '全部通道', value: 'all' }, ...channels.map((item) => ({ label: item.name, value: item.id }))]} value={channelId} onChange={(event) => setChannelId(event.target.value)} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { setPage(1); setAppliedFilters({ keyword: keyword.trim(), status, applicationId, channelId }); }}></Button><Button onClick={() => { const next = { keyword: '', status: 'all', applicationId: 'all', channelId: 'all' }; setKeyword(''); setStatus('all'); setApplicationId('all'); setChannelId('all'); setPage(1); setAppliedFilters(next); }} variant="ghost"></Button></div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading gateway-exception-list-heading">
+13 -11
View File
@@ -6,6 +6,7 @@ import { formatCents } from '@/utils/currency';
import { formatDateTime } from '@/utils/dateTime';
const pageSize = 20;
type ProfitFilters = { dateRange: DateRangeValue; dimensionType: 'application' | 'channel'; tenantId: string; applicationId: string; channelId: string };
export function AdminProfitReportsPage() {
const [rows, setRows] = useState<DailyProfitReport[]>([]);
@@ -17,6 +18,7 @@ export function AdminProfitReportsPage() {
const [tenantId, setTenantId] = useState('');
const [applicationId, setApplicationId] = useState('');
const [channelId, setChannelId] = useState('');
const [appliedFilters, setAppliedFilters] = useState<ProfitFilters>(() => ({ dateRange: defaultDateRange(), dimensionType: 'application', tenantId: '', applicationId: '', channelId: '' }));
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<ProfitReportSummary>(emptyProfitSummary);
@@ -25,17 +27,17 @@ export function AdminProfitReportsPage() {
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
.then(([nextTenants, nextApplications, nextChannels]) => { setTenants(nextTenants); setApplications(nextApplications); setChannels(nextChannels); })
.catch(() => { setTenants([]); setApplications([]); setChannels([]); });
}, []);
useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, dimensionType, tenantId, applicationId, channelId]);
useEffect(() => { void loadData(appliedFilters, page); }, [page, appliedFilters]);
async function loadData() {
async function loadData(filters: ProfitFilters, targetPage: number) {
setLoading(true);
setError('');
try {
const response = await adminApi.listProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined, page, pageSize });
const response = await adminApi.listProfitReports({ dateFrom: filters.dateRange.start, dateTo: filters.dateRange.end, dimensionType: filters.dimensionType, tenantId: filters.tenantId || undefined, applicationId: filters.applicationId || undefined, channelId: filters.channelId || undefined, page: targetPage, pageSize });
setRows(response.items);
setTotal(response.total);
setSummary(response.summary);
@@ -51,7 +53,7 @@ export function AdminProfitReportsPage() {
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined }), '利润报表.csv'); }
try { downloadBlob(await adminApi.exportProfitReports({ dateFrom: appliedFilters.dateRange.start, dateTo: appliedFilters.dateRange.end, dimensionType: appliedFilters.dimensionType, tenantId: appliedFilters.tenantId || undefined, applicationId: appliedFilters.applicationId || undefined, channelId: appliedFilters.channelId || undefined }), '利润报表.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '利润报表导出失败'); }
finally { setExporting(false); }
}
@@ -67,11 +69,11 @@ export function AdminProfitReportsPage() {
</div>
<div className="surface admin-report-filter-grid admin-report-filter-grid--profit">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select label="统计维度" onChange={(event) => { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} />
{dimensionType === 'application' ? <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} /> : <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />}
{dimensionType === 'application' ? <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} /> : <div />}
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
<DateRangeInput label="发送日期" onChange={setDateRange} value={dateRange} />
<Select label="统计维度" onChange={(event) => { setDimensionType(event.target.value as 'application' | 'channel'); setTenantId(''); setApplicationId(''); setChannelId(''); }} options={[{ label: '按企业应用', value: 'application' }, { label: '按通道', value: 'channel' }]} value={dimensionType} />
{dimensionType === 'application' ? <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} /> : <Select label="短信通道" onChange={(event) => setChannelId(event.target.value)} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} />}
{dimensionType === 'application' ? <Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} /> : <div />}
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setAppliedFilters({ dateRange, dimensionType, tenantId, applicationId, channelId }); setPage(1); }}></Button><Button onClick={() => { const nextDateRange = defaultDateRange(); setDateRange(nextDateRange); setDimensionType('application'); setTenantId(''); setApplicationId(''); setChannelId(''); setAppliedFilters({ dateRange: nextDateRange, dimensionType: 'application', tenantId: '', applicationId: '', channelId: '' }); setPage(1); }} variant="ghost"></Button></div>
</div>
<div className="surface admin-report-summary">
@@ -85,7 +87,7 @@ export function AdminProfitReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{appliedFilters.dimensionType === 'application' ? '企业 / 企业应用' : '通道'}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={12}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={12}>...</td></tr>
+17 -15
View File
@@ -6,6 +6,7 @@ import { formatDateTime } from '@/utils/dateTime';
type QualityDimension = 'application' | 'channel' | 'signature' | 'drainage';
const pageSize = 20;
type QualityFilters = { dimension: QualityDimension; dateRange: DateRangeValue; tenantId: string; applicationId: string; channelId: string };
const dimensionLabels: Record<QualityDimension, string> = {
application: '企业应用', channel: '通道', signature: '签名', drainage: '引流信息',
};
@@ -20,6 +21,7 @@ export function AdminQualityReportsPage() {
const [tenantId, setTenantId] = useState('');
const [applicationId, setApplicationId] = useState('');
const [channelId, setChannelId] = useState('');
const [appliedFilters, setAppliedFilters] = useState<QualityFilters>(() => ({ dimension: 'application', dateRange: defaultDateRange(), tenantId: '', applicationId: '', channelId: '' }));
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<QualityReportSummary>(emptyQualitySummary);
@@ -28,22 +30,22 @@ export function AdminQualityReportsPage() {
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
.then(([nextTenants, nextApplications, nextChannels]) => { setTenants(nextTenants); setApplications(nextApplications); setChannels(nextChannels); })
.catch(() => { setTenants([]); setApplications([]); setChannels([]); });
}, []);
useEffect(() => { void loadData(); }, [dimension, page, dateRange.start, dateRange.end, tenantId, applicationId, channelId]);
useEffect(() => { void loadData(appliedFilters, page); }, [page, appliedFilters]);
async function loadData() {
async function loadData(filters: QualityFilters, targetPage: number) {
setLoading(true);
setError('');
try {
const response = await adminApi.listQualityReports({
dimensionType: dimension, dateFrom: dateRange.start, dateTo: dateRange.end,
tenantId: dimension === 'channel' ? undefined : tenantId || undefined,
applicationId: dimension === 'channel' ? undefined : applicationId || undefined,
channelId: dimension === 'channel' ? channelId || undefined : undefined,
page, pageSize,
dimensionType: filters.dimension, dateFrom: filters.dateRange.start, dateTo: filters.dateRange.end,
tenantId: filters.dimension === 'channel' ? undefined : filters.tenantId || undefined,
applicationId: filters.dimension === 'channel' ? undefined : filters.applicationId || undefined,
channelId: filters.dimension === 'channel' ? filters.channelId || undefined : undefined,
page: targetPage, pageSize,
});
setRows(response.items);
setTotal(response.total);
@@ -60,7 +62,7 @@ export function AdminQualityReportsPage() {
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportQualityReports({ dimensionType: dimension, dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: dimension === 'channel' ? undefined : tenantId || undefined, applicationId: dimension === 'channel' ? undefined : applicationId || undefined, channelId: dimension === 'channel' ? channelId || undefined : undefined }), '发送质量报表.csv'); }
try { downloadBlob(await adminApi.exportQualityReports({ dimensionType: appliedFilters.dimension, dateFrom: appliedFilters.dateRange.start, dateTo: appliedFilters.dateRange.end, tenantId: appliedFilters.dimension === 'channel' ? undefined : appliedFilters.tenantId || undefined, applicationId: appliedFilters.dimension === 'channel' ? undefined : appliedFilters.applicationId || undefined, channelId: appliedFilters.dimension === 'channel' ? appliedFilters.channelId || undefined : undefined }), '发送质量报表.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '发送质量报表导出失败'); }
finally { setExporting(false); }
}
@@ -70,16 +72,16 @@ export function AdminQualityReportsPage() {
function changeDimension(value: string) {
setDimension(value as QualityDimension);
setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1);
setTenantId(''); setApplicationId(''); setChannelId('');
}
const reportPanel = (
<div className="page-stack" style={{ marginTop: 16 }}>
<div className="surface admin-report-filter-grid admin-report-filter-grid--quality">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
{dimension === 'channel' ? <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />}
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
<DateRangeInput label="发送日期" onChange={setDateRange} value={dateRange} />
{dimension === 'channel' ? <Select label="短信通道" onChange={(event) => setChannelId(event.target.value)} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />}
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setAppliedFilters({ dimension, dateRange, tenantId, applicationId, channelId }); setPage(1); }}></Button><Button onClick={() => { const nextDateRange = defaultDateRange(); setDimension('application'); setDateRange(nextDateRange); setTenantId(''); setApplicationId(''); setChannelId(''); setAppliedFilters({ dimension: 'application', dateRange: nextDateRange, tenantId: '', applicationId: '', channelId: '' }); setPage(1); }} variant="ghost"></Button></div>
</div>
<div className="surface admin-report-summary">
<div className="admin-report-summary__heading"><strong></strong><span></span></div>
@@ -90,7 +92,7 @@ export function AdminQualityReportsPage() {
<div className="surface">
<div className="ui-table-wrap">
<table className="ui-table">
<thead><tr><th></th><th>{dimensionLabels[dimension]}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<thead><tr><th></th><th>{dimensionLabels[appliedFilters.dimension]}</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{error ? <tr><td className="ui-table__empty" colSpan={10}>{error}</td></tr>
: loading ? <tr><td className="ui-table__empty" colSpan={10}>...</td></tr>
+19 -11
View File
@@ -35,19 +35,13 @@ export function AdminRechargeRecordsPage() {
setLoading(true);
setError('');
try {
const [nextTenants, nextAccounts, result] = await Promise.all([
adminApi.listTenants(),
adminApi.listAccounts(),
adminApi.listManualRechargesPage({
const result = await adminApi.listManualRechargesPage({
enterpriseKeyword: filters.enterpriseKeyword.trim() || undefined,
createdAtFrom: filters.dateRange.start,
createdAtTo: filters.dateRange.end,
page: targetPage,
pageSize,
}),
]);
setTenants(nextTenants);
setAccounts(nextAccounts);
});
setRecords(result.items);
setTotal(result.total);
} catch (err) {
@@ -62,6 +56,20 @@ export function AdminRechargeRecordsPage() {
void loadData(page);
}, [page]);
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listAccounts()])
.then(([nextTenants, nextAccounts]) => {
if (cancelled) return;
setTenants(nextTenants);
setAccounts(nextAccounts);
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业及账户选项加载失败');
});
return () => { cancelled = true; };
}, []);
const filteredRows = records;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -73,8 +81,8 @@ export function AdminRechargeRecordsPage() {
function resetFilters() {
setEnterpriseKeyword('');
setDateRange({});
setPage(1);
void loadData(1, { enterpriseKeyword: '', dateRange: {} });
if (page !== 1) setPage(1);
else void loadData(1, { enterpriseKeyword: '', dateRange: {} });
}
return (
@@ -91,7 +99,7 @@ export function AdminRechargeRecordsPage() {
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} value={enterpriseKeyword} />
<DateRangeInput label="充值日期" onChange={setDateRange} value={dateRange} />
<div className="admin-recharge-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadData(1); }}></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else void loadData(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
@@ -5,6 +5,7 @@ import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateR
import { formatDateTime } from '@/utils/dateTime';
const pageSize = 20;
type ReconciliationFilters = { dateRange: DateRangeValue; tenantId: string; applicationId: string };
export function AdminReconciliationReportsPage() {
const [rows, setRows] = useState<DailyReconciliationReport[]>([]);
@@ -13,6 +14,7 @@ export function AdminReconciliationReportsPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
const [tenantId, setTenantId] = useState('');
const [applicationId, setApplicationId] = useState('');
const [appliedFilters, setAppliedFilters] = useState<ReconciliationFilters>(() => ({ dateRange: defaultDateRange(), tenantId: '', applicationId: '' }));
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<ReconciliationReportSummary>(emptyVolumeSummary);
@@ -21,23 +23,23 @@ export function AdminReconciliationReportsPage() {
const [exporting, setExporting] = useState(false);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions()])
.then(([nextTenants, nextApplications]) => { setTenants(nextTenants); setApplications(nextApplications); })
.catch(() => { setTenants([]); setApplications([]); });
}, []);
useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, tenantId, applicationId]);
useEffect(() => { void loadData(appliedFilters, page); }, [page, appliedFilters]);
async function loadData() {
async function loadData(filters: ReconciliationFilters, targetPage: number) {
setLoading(true);
setError('');
try {
const response = await adminApi.listReconciliationReports({
dateFrom: dateRange.start,
dateTo: dateRange.end,
tenantId: tenantId || undefined,
applicationId: applicationId || undefined,
page,
dateFrom: filters.dateRange.start,
dateTo: filters.dateRange.end,
tenantId: filters.tenantId || undefined,
applicationId: filters.applicationId || undefined,
page: targetPage,
pageSize,
});
setRows(response.items);
@@ -55,7 +57,7 @@ export function AdminReconciliationReportsPage() {
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: tenantId || undefined, applicationId: applicationId || undefined }), '对账单.csv'); }
try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: appliedFilters.dateRange.start, dateTo: appliedFilters.dateRange.end, tenantId: appliedFilters.tenantId || undefined, applicationId: appliedFilters.applicationId || undefined }), '对账单.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '对账单导出失败'); }
finally { setExporting(false); }
}
@@ -74,10 +76,10 @@ export function AdminReconciliationReportsPage() {
</div>
<div className="surface admin-report-filter-grid admin-report-filter-grid--reconciliation">
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
<Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />
<Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />
<Button icon={<Search size={16} />} onClick={() => void loadData()}></Button>
<DateRangeInput label="发送日期" onChange={setDateRange} value={dateRange} />
<Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />
<Select label="企业应用" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />
<div className="audit-filter-actions"><Button icon={<Search size={16} />} onClick={() => { setAppliedFilters({ dateRange, tenantId, applicationId }); setPage(1); }}></Button><Button onClick={() => { const nextDateRange = defaultDateRange(); setDateRange(nextDateRange); setTenantId(''); setApplicationId(''); setAppliedFilters({ dateRange: nextDateRange, tenantId: '', applicationId: '' }); setPage(1); }} variant="ghost"></Button></div>
</div>
<ReportVolumeSummaryView summary={summary} />
+12 -5
View File
@@ -37,6 +37,7 @@ export function AdminReportBatchesPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue });
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -45,12 +46,12 @@ export function AdminReportBatchesPage() {
const [error, setError] = useState('');
const pageSize = 20;
function load(target = page) {
function load(target = page, filters = appliedFilters) {
adminApi
.listReportMaterialBatches({
keyword: keyword.trim() || undefined,
startAt: dateRange.start,
endAt: dateRange.end,
keyword: filters.keyword || undefined,
startAt: filters.dateRange.start,
endAt: filters.dateRange.end,
page: target,
pageSize,
})
@@ -263,8 +264,10 @@ export function AdminReportBatchesPage() {
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = { keyword: keyword.trim(), dateRange };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else load(1);
else load(1, filters);
}}
>
@@ -273,6 +276,10 @@ export function AdminReportBatchesPage() {
onClick={() => {
setKeyword('');
setDateRange({});
const filters = { keyword: '', dateRange: {} as DateRangeValue };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else load(1, filters);
}}
variant="ghost"
>
+17 -10
View File
@@ -159,19 +159,20 @@ export function AdminReportRecordsPage() {
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' });
const pageSize = 10;
function loadData(targetPage = page) {
function loadData(targetPage = page, filters = appliedFilters) {
adminApi
.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
batchNo: batchNo.trim() || undefined,
operatorKeyword: operatorKeyword.trim() || undefined,
statusAfter: statusAfter === 'all' ? undefined : statusAfter,
sourceEntry: sourceEntry === 'all' ? undefined : sourceEntry,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
keyword: filters.keyword || undefined,
reportType: filters.reportType === 'all' ? undefined : (filters.reportType as 'signature' | 'drainage'),
batchNo: filters.batchNo || undefined,
operatorKeyword: filters.operatorKeyword || undefined,
statusAfter: filters.statusAfter === 'all' ? undefined : filters.statusAfter,
sourceEntry: filters.sourceEntry === 'all' ? undefined : filters.sourceEntry,
createdAtFrom: filters.dateRange.start || undefined,
createdAtTo: filters.dateRange.end || undefined,
page: targetPage,
pageSize,
})
@@ -330,8 +331,10 @@ export function AdminReportRecordsPage() {
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = { keyword: keyword.trim(), dateRange, reportType, batchNo: batchNo.trim(), operatorKeyword: operatorKeyword.trim(), statusAfter, sourceEntry };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1);
else loadData(1, filters);
}}
>
@@ -345,6 +348,10 @@ export function AdminReportRecordsPage() {
setOperatorKeyword('');
setStatusAfter('all');
setSourceEntry('all');
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
}}
variant="ghost"
>
+17 -9
View File
@@ -140,11 +140,12 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
export function AdminReportTasksPage() {
const [searchParams] = useSearchParams();
const initialStatus = searchParams.get('scope') === 'pending' ? 'pending' : 'all';
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [status, setStatus] = useState(searchParams.get('scope') === 'pending' ? 'pending' : 'all');
const [status, setStatus] = useState(initialStatus);
const [carrier, setCarrier] = useState('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [material, setMaterial] = useState<SingleReportMaterialDetail | null>(null);
@@ -156,18 +157,19 @@ export function AdminReportTasksPage() {
const [busy, setBusy] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: initialStatus, carrier: 'all' });
const pageSize = 10;
function loadData(targetPage = page) {
function loadData(targetPage = page, filters = appliedFilters) {
adminApi
.listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
reportType: filters.reportType === 'all' ? undefined : (filters.reportType as 'signature' | 'drainage'),
status: filters.status === 'all' ? undefined : filters.status,
carrier: filters.carrier === 'all' ? undefined : filters.carrier,
keyword: filters.keyword || undefined,
createdAtFrom: filters.dateRange.start || undefined,
createdAtTo: filters.dateRange.end || undefined,
page: targetPage,
pageSize,
})
@@ -398,8 +400,10 @@ export function AdminReportTasksPage() {
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = { keyword: keyword.trim(), dateRange, reportType, status, carrier };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1);
else loadData(1, filters);
}}
>
@@ -411,6 +415,10 @@ export function AdminReportTasksPage() {
setReportType('all');
setCarrier('all');
setStatus('all');
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', status: 'all', carrier: 'all' };
setAppliedFilters(filters);
if (page !== 1) setPage(1);
else loadData(1, filters);
}}
variant="ghost"
>
+10 -9
View File
@@ -72,25 +72,26 @@ export function AdminSignatureAuditPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue });
const [detail, setDetail] = useState<ClientSmsSignature>();
const [rejectTarget, setRejectTarget] = useState<ClientSmsSignature>();
const [reason, setReason] = useState('');
const [error, setError] = useState('');
function loadData() {
function loadData(filters = appliedFilters) {
adminApi.listEnterpriseSignatures({
keyword,
status: status === 'all' ? undefined : status,
submittedAtFrom: submittedDateRange.start,
submittedAtTo: submittedDateRange.end,
keyword: filters.keyword,
status: filters.status === 'all' ? undefined : filters.status,
submittedAtFrom: filters.submittedDateRange.start,
submittedAtTo: filters.submittedDateRange.end,
})
.then((records) => { setItems(records); setError(''); })
.catch((failure: Error) => setError(failure.message || '签名审核列表加载失败'));
}
useEffect(loadData, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
useEffect(loadData, [appliedFilters]);
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
const visible = items;
async function reject() {
if (!rejectTarget || !reason.trim()) return;
@@ -103,14 +104,14 @@ 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><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> },
{ 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">
<div className="page-heading"><div><Breadcrumb items={['审核中心', '短信签名审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<Tabs items={[
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
{ label: '单条签名审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用或签名" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }, { label: '草稿', value: 'draft' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} }); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={visible} emptyText="暂无签名审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="signature" /> },
]} />
{detail ? <SignatureDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
@@ -78,7 +78,7 @@ export function AdminSignatureRetirementPage() {
adminApi.getSignatureRetirementConfiguration(),
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
adminApi.listSignatureRetirementSuppressions(),
adminApi.listEnterpriseApplications(), adminApi.listChannels(), adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels(), adminApi.listTenantOptions(),
]);
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page);
+33 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { adminApi, type AdminChannel, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
import { SendDetailModal } from './sms-records/SendDetailModal';
@@ -31,6 +31,7 @@ export function AdminSmsRecordsPage() {
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
const [filterChannels, setFilterChannels] = useState<AdminChannel[]>([]);
const detailRequestSequence = useRef(0);
function currentFilters(): MessageFilters {
return {
@@ -65,7 +66,7 @@ export function AdminSmsRecordsPage() {
}, [page]);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
.then(([tenants, applications, channels]) => {
setFilterTenants(tenants
.filter((item) => item.status !== 'deleted')
@@ -78,20 +79,36 @@ export function AdminSmsRecordsPage() {
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
}, []);
useEffect(() => {
if (!selectedRecord) {
setSegmentAudits([]);
return;
}
function openDetail(record: SmsMessageRecord) {
const requestSequence = ++detailRequestSequence.current;
setSelectedRecord(record);
setSegmentAudits([]);
setSegmentLoading(true);
adminApi.listMessageSegmentAudits({ messageRecordId: selectedRecord.id })
.then((items) => {
setSegmentAudits(items);
setError('');
Promise.allSettled([
adminApi.getOperationMessage(record.id),
adminApi.listMessageSegmentAudits({ messageRecordId: record.id }),
])
.then(([detailResult, auditsResult]) => {
if (requestSequence !== detailRequestSequence.current) return;
if (detailResult.status === 'fulfilled') setSelectedRecord(detailResult.value);
else setError(detailResult.reason instanceof Error ? detailResult.reason.message : '短信详情加载失败');
if (auditsResult.status === 'fulfilled') setSegmentAudits(auditsResult.value);
else setError(auditsResult.reason instanceof Error ? auditsResult.reason.message : '分片审计加载失败');
if (detailResult.status === 'fulfilled' && auditsResult.status === 'fulfilled') {
setError('');
}
})
.catch((failure: Error) => setError(failure.message || '分片审计加载失败'))
.finally(() => setSegmentLoading(false));
}, [selectedRecord]);
.finally(() => {
if (requestSequence === detailRequestSequence.current) setSegmentLoading(false);
});
}
function closeDetail() {
detailRequestSequence.current += 1;
setSelectedRecord(null);
setSegmentAudits([]);
setSegmentLoading(false);
}
const enterpriseOptions = useMemo(
() => [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))],
@@ -194,13 +211,13 @@ export function AdminSmsRecordsPage() {
total={total}
totalPages={totalPages}
onExport={() => void exportRecords()}
onOpenDetail={setSelectedRecord}
onOpenDetail={openDetail}
onPageChange={setPage}
/>
{selectedRecord ? (
<SendDetailModal
onClose={() => setSelectedRecord(null)}
onClose={closeDetail}
record={selectedRecord}
segmentAudits={segmentAudits}
segmentLoading={segmentLoading}
+1 -1
View File
@@ -56,7 +56,7 @@ export function AdminSmsTaskProgressPage() {
}, [page]);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenants, applications]) => {
const tenantNameById = new Map(tenants.map((item) => [item.id, item.name]));
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => item.name));
+3 -3
View File
@@ -307,8 +307,8 @@ export function AdminSmsUplinkRecordsPage() {
setDateRange({});
setPhoneKeyword('');
setContentKeyword('');
setPage(1);
loadData(1, { phoneKeyword: '', contentKeyword: '', dateRange: {} });
if (page !== 1) setPage(1);
else loadData(1, { phoneKeyword: '', contentKeyword: '', dateRange: {} });
}
function handleClaim(candidate: SmsUplinkMatchCandidate) {
@@ -367,7 +367,7 @@ export function AdminSmsUplinkRecordsPage() {
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
<div className="admin-uplink-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadData(1); }}></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
+10 -7
View File
@@ -23,13 +23,16 @@ export function AdminTemplateAuditPage() {
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', status: 'pending', submittedDateRange: {} as DateRangeValue });
const [detail, setDetail] = useState<SmsTemplateAudit>();
useEffect(() => {
adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end })
function loadData(filters = appliedFilters) {
return adminApi.listTemplateAudits({ keyword: filters.keyword, status: filters.status, submittedAtFrom: filters.submittedDateRange.start, submittedAtTo: filters.submittedDateRange.end })
.then(setAudits)
.catch(() => setAudits([]));
}, [keyword, status, submittedDateRange.end, submittedDateRange.start]);
}
useEffect(() => { void loadData(); }, [appliedFilters]);
async function rejectTemplate(id: string) {
const updated = await adminApi.rejectTemplate(id);
@@ -58,7 +61,7 @@ export function AdminTemplateAuditPage() {
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status, submittedAtFrom: submittedDateRange.start, submittedAtTo: submittedDateRange.end }).then(setAudits)} targetId={record.id} targetType="template" />
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => void loadData()} targetId={record.id} targetType="template" />
<Button
disabled={record.auditStatus !== 'pending'}
icon={<X size={15} />}
@@ -72,7 +75,7 @@ export function AdminTemplateAuditPage() {
),
},
],
[keyword, status, submittedDateRange.end, submittedDateRange.start],
[appliedFilters],
);
const templateAudits = audits;
@@ -89,8 +92,8 @@ export function AdminTemplateAuditPage() {
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="audit-filter-actions ui-filter-actions">
<Button icon={<Search size={17} />}></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button>
<Button icon={<Search size={17} />} onClick={() => setAppliedFilters({ keyword: keyword.trim(), status, submittedDateRange })}></Button>
<Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); setAppliedFilters({ keyword: '', status: 'pending', submittedDateRange: {} }); }} variant="ghost"></Button>
</div>
</div>
</div>
+1 -1
View File
@@ -99,7 +99,7 @@ export function AdminUsersPage() {
}
useEffect(() => {
void Promise.all([adminApi.listUsers(), adminApi.listTenants()])
void Promise.all([adminApi.listUsers(), adminApi.listTenantOptions()])
.then(([nextUsers, nextTenants]) => {
setUsers(nextUsers);
setTenants(nextTenants);
@@ -0,0 +1,52 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
const { adminApi } = vi.hoisted(() => ({
adminApi: {
analyzeReportMaterialImport: vi.fn(),
listDrainageFields: vi.fn(),
listEnterpriseApplicationOptions: vi.fn(),
listReportImportProfiles: vi.fn(),
listTenantOptions: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ adminApi }));
describe('ReportMaterialImportModal mapping profile action', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
adminApi.listTenantOptions.mockResolvedValue([{ id: 'tenant-1', name: '测试企业', code: 'T001', status: 'active' }]);
adminApi.listEnterpriseApplicationOptions.mockResolvedValue([]);
adminApi.listDrainageFields.mockResolvedValue([]);
adminApi.listReportImportProfiles.mockResolvedValue([]);
adminApi.analyzeReportMaterialImport.mockResolvedValue({
id: 'analysis-1',
columns: [{ sourceColumnIndex: 0, columnLetter: 'A', sourceHeader: '签名', sourceHeaderPath: '签名', imageCount: 0 }],
rows: [],
suggestedMappings: [],
});
});
it('renders the reusable mapping choice as a clear pressed-state shared button', async () => {
const user = userEvent.setup();
render(<ReportMaterialImportModal onClose={vi.fn()} onCompleted={vi.fn()} />);
await waitFor(() => expect(adminApi.listTenantOptions).toHaveBeenCalledTimes(1));
const tenantSelect = screen.getByText('所属企业').closest('label')?.querySelector('button');
expect(tenantSelect).not.toBeNull();
await user.click(tenantSelect!);
await user.click(screen.getByRole('option', { name: /测试企业/ }));
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).not.toBeNull();
fireEvent.change(fileInput!, { target: { files: [new File(['xlsx'], 'mapping.xlsx')] } });
await user.click(screen.getByRole('button', { name: '解析文件并配置映射' }));
const toggle = await screen.findByRole('button', { name: '保存为可复用映射方案' });
expect(toggle).toHaveClass('ui-button', 'report-import-profile__toggle');
expect(toggle).toHaveAttribute('aria-pressed', 'false');
await user.click(toggle);
await waitFor(() => expect(screen.getByRole('button', { name: '本次将保存/更新映射方案' })).toHaveAttribute('aria-pressed', 'true'));
expect(screen.getByLabelText('映射方案名称')).toBeVisible();
});
});
+14 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { FileSpreadsheet, Plus, Trash2 } from 'lucide-react';
import { CheckCircle2, FileSpreadsheet, Plus } from 'lucide-react';
import { adminApi, type DictionaryItem, type EnterpriseApplication, type ReportImportMapping, type ReportImportProfile, type TenantOption } from '@/api/adminApi';
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
@@ -41,7 +41,7 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
const [error, setError] = useState('');
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listDrainageFields()])
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listDrainageFields()])
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
}, []);
@@ -116,7 +116,18 @@ export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: (
const encoded = mapping ? `${mapping.targetKind}:${mapping.targetFieldCode}:${mapping.fieldType}` : '';
return <div className="report-import-mapping-row" key={column.sourceColumnIndex}><span><strong>{column.columnLetter} · {column.sourceHeader}</strong><small>{column.sourceHeaderPath}</small>{column.imageCount ? <Tag tone="warning">{column.imageCount} </Tag> : null}</span><Select onChange={(event) => setTarget(column, event.target.value)} options={targetOptions} value={encoded} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { fieldType: event.target.value as ReportImportMapping['fieldType'] })} options={[{ label: '文本', value: 'string' }, { label: '图片', value: 'image' }, { label: '文件', value: 'file' }]} value={mapping?.fieldType ?? 'string'} /><Select disabled={!mapping} onChange={(event) => patchMapping(column.sourceColumnIndex, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(mapping?.required ?? false)} /><Select disabled={!mapping || mapping.fieldType !== 'string'} onChange={(event) => patchMapping(column.sourceColumnIndex, { transform: event.target.value })} options={transforms} value={mapping?.transform ?? ''} /></div>;
})}</div>
<div className="report-import-profile"><button className={saveProfile ? 'is-active' : ''} onClick={() => setSaveProfile((value) => !value)} type="button">{saveProfile ? <Trash2 size={15} /> : <Plus size={15} />}{saveProfile ? '本次保存/更新映射方案' : '将本次配置保存为可复用映射方案'}</button>{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}</div>
<div className="report-import-profile">
<Button
aria-pressed={saveProfile}
className="report-import-profile__toggle"
icon={saveProfile ? <CheckCircle2 size={16} /> : <Plus size={16} />}
onClick={() => setSaveProfile((value) => !value)}
variant={saveProfile ? 'secondary' : 'ghost'}
>
{saveProfile ? '本次将保存/更新映射方案' : '保存为可复用映射方案'}
</Button>
{saveProfile ? <Input label="映射方案名称" onChange={(event) => setProfileName(event.target.value)} placeholder="例如:海南移动签名资料模板" value={profileName} /> : null}
</div>
{analysis.rows.length ? <details className="report-import-preview"><summary> {analysis.rows.length} </summary><pre>{JSON.stringify(analysis.rows, null, 2)}</pre></details> : null}
</div> : null}
{error ? <p className="form-error">{error}</p> : null}
@@ -43,13 +43,14 @@ function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTa
total: 1,
totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined),
onAddDrainage: vi.fn(),
onEditDrainage: vi.fn(),
onOpenDrainageReport: vi.fn(),
onEditSignature: vi.fn(),
onOpenSignatureReport: vi.fn(),
setDeleteTarget: vi.fn(),
setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setReportStatusTarget: vi.fn(),
setSignatureModal: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides,
@@ -79,6 +80,10 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: /报备状态|编辑|删除/ })).toHaveLength(6);
screen.getAllByRole('button', { name: /报备状态|编辑|删除/ }).forEach((button) => {
expect(button).toHaveClass('ui-button--sm', 'enterprise-signature-action-button');
});
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
});
@@ -18,14 +18,15 @@ type EnterpriseSignaturesTableProps = {
expandedSignatureId: string;
filteredSignatures: ClientSmsSignature[];
loadData: () => Promise<void>;
onAddDrainage: (signature: ClientSmsSignature) => void;
setDeleteTarget: Dispatch<SetStateAction<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>>;
setDrainageModal: Dispatch<SetStateAction<{ signatureId: string; item?: DrainageInfo } | null>>;
setDrainageStatusTarget: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
onEditDrainage: (signature: ClientSmsSignature, item: DrainageInfo) => void;
onOpenDrainageReport: (signature: ClientSmsSignature, item: DrainageInfo) => void;
setExpandedSignatureId: Dispatch<SetStateAction<string>>;
setPage: Dispatch<SetStateAction<number>>;
setSignatureSort: (sort: 'asc' | 'desc') => void;
setReportStatusTarget: Dispatch<SetStateAction<ClientSmsSignature | null>>;
setSignatureModal: Dispatch<SetStateAction<ClientSmsSignature | 'new' | null>>;
onEditSignature: (signature: ClientSmsSignature) => void;
onOpenSignatureReport: (signature: ClientSmsSignature) => void;
signatureSort: 'asc' | 'desc';
total: number;
totalPages: number;
@@ -38,14 +39,15 @@ export function EnterpriseSignaturesTable({
expandedSignatureId,
filteredSignatures,
loadData,
onAddDrainage,
setDeleteTarget,
setDrainageModal,
setDrainageStatusTarget,
onEditDrainage,
onOpenDrainageReport,
setExpandedSignatureId,
setPage,
setSignatureSort,
setReportStatusTarget,
setSignatureModal,
onEditSignature,
onOpenSignatureReport,
signatureSort,
total,
totalPages,
@@ -160,22 +162,25 @@ export function EnterpriseSignaturesTable({
</div>
<div className="signature-actions">
<Button
className="enterprise-signature-action-button"
icon={<Edit3 size={16} />}
onClick={() => setReportStatusTarget(signature)}
onClick={() => onOpenSignatureReport(signature)}
size="sm"
variant="ghost"
>
</Button>
<Button
className="enterprise-signature-action-button"
icon={<Edit3 size={16} />}
onClick={() => setSignatureModal(signature)}
onClick={() => onEditSignature(signature)}
size="sm"
variant="ghost"
>
</Button>
<DeleteRiskAction
className="enterprise-signature-action-button"
onCompleted={() => void loadData()}
portal="admin"
targetId={signature.id}
@@ -209,21 +214,24 @@ export function EnterpriseSignaturesTable({
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button
className="enterprise-signature-action-button"
disabled={item.auditStatus !== 'approved'}
onClick={() => setDrainageStatusTarget({ signature, item })}
onClick={() => onOpenDrainageReport(signature, item)}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => setDrainageModal({ signatureId: signature.id, item })}
className="enterprise-signature-action-button"
onClick={() => onEditDrainage(signature, item)}
size="sm"
variant="ghost"
>
</Button>
<Button
className="enterprise-signature-action-button"
onClick={() =>
setDeleteTarget({
kind: 'drainage',
@@ -248,7 +256,7 @@ export function EnterpriseSignaturesTable({
<div className="drainage-panel__footer">
<Button
icon={<Plus size={16} />}
onClick={() => setDrainageModal({ signatureId: signature.id })}
onClick={() => onAddDrainage(signature)}
size="sm"
variant="ghost"
>
+24 -15
View File
@@ -276,6 +276,7 @@ export function ClientSignaturesPage() {
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
const [keyword, setKeyword] = useState('');
const [applicationFilter, setApplicationFilter] = useState('');
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', applicationId: '' });
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [signatureModal, setSignatureModal] = useState<ClientSmsSignatureView | 'new'>();
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
@@ -290,18 +291,14 @@ export function ClientSignaturesPage() {
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
Promise.all([
clientApi.listApplicationOptions(),
clientApi.getSignatureWorkspace({
keyword: keyword.trim() || undefined,
applicationId: applicationFilter || undefined,
clientApi.getSignatureWorkspace({
keyword: appliedFilters.keyword || undefined,
applicationId: appliedFilters.applicationId || undefined,
page: targetPage,
pageSize,
}),
])
.then(([applicationItems, signatureWorkspace]) => {
})
.then((signatureWorkspace) => {
if (sequence !== requestSequence.current) return;
setApplications(applicationItems.filter((item) => item.status === 'active'));
setWorkspace(signatureWorkspace);
setError('');
})
@@ -310,9 +307,20 @@ export function ClientSignaturesPage() {
}
useEffect(() => {
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [applicationFilter, keyword, page, refreshVersion]);
loadData(page);
}, [appliedFilters, page, refreshVersion]);
useEffect(() => {
let cancelled = false;
void clientApi.listApplicationOptions()
.then((applicationItems) => {
if (!cancelled) setApplications(applicationItems.filter((item) => item.status === 'active'));
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '应用选项加载失败');
});
return () => { cancelled = true; };
}, []);
const filteredItems = workspace.items;
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
@@ -342,7 +350,7 @@ export function ClientSignaturesPage() {
setKeyword('');
setApplicationFilter('');
setPage(1);
setRefreshVersion((version) => version + 1);
setAppliedFilters({ keyword: '', applicationId: '' });
};
return <section className="page-stack client-signature-page">
<header className="client-signature-heading">
@@ -354,8 +362,9 @@ export function ClientSignaturesPage() {
</header>
<div className="client-signature-toolbar">
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名或应用" prefix={<Search size={17} />} value={keyword} />
<Select onChange={(event) => { setApplicationFilter(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名或应用" prefix={<Search size={17} />} value={keyword} />
<Select onChange={(event) => setApplicationFilter(event.target.value)} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
<Button icon={<Search size={15} />} onClick={() => { setPage(1); setAppliedFilters({ keyword: keyword.trim(), applicationId: applicationFilter }); }}></Button>
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost"></Button>
</div>
+24 -8
View File
@@ -214,6 +214,7 @@ export function ClientTemplatesPage() {
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
const [keyword, setKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
@@ -224,13 +225,11 @@ export function ClientTemplatesPage() {
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]) => {
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
.then((templateResult) => {
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) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
@@ -238,9 +237,22 @@ export function ClientTemplatesPage() {
}
useEffect(() => {
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [page, keyword]);
loadData(page);
}, [appliedKeyword, page]);
useEffect(() => {
let cancelled = false;
void Promise.all([clientApi.listApplicationOptions(), clientApi.listSignatureOptions()])
.then(([applicationItems, signatureItems]) => {
if (cancelled) return;
setApplications(applicationItems.filter((item) => item.status === 'active'));
setSignatures(signatureItems);
})
.catch((reason: Error) => {
if (!cancelled) setError(reason.message || '模板选项加载失败');
});
return () => { cancelled = true; };
}, []);
const filteredTemplates = templates;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
@@ -282,11 +294,15 @@ export function ClientTemplatesPage() {
<div className="template-toolbar">
<Input
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称、应用、签名或内容"
prefix={<Search size={17} />}
value={keyword}
/>
<div className="ui-query-actions">
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}></Button>
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost"></Button>
</div>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}></Button>
</div>
{loading ? <p className="muted">...</p> : null}