fix: paginate operational list pages

This commit is contained in:
hectorzhao
2026-07-28 23:20:13 +08:00
parent 87c5b1eccc
commit b8560372cc
40 changed files with 1208 additions and 422 deletions
+52 -6
View File
@@ -574,6 +574,9 @@ export type ClientSmsSignatureView = Pick<ClientSmsSignature,
export type ClientSignatureWorkspace = {
items: ClientSmsSignatureView[];
summary: { total: number; pending: number; approved: number; rejected: number; draft: number };
total: number;
page: number;
pageSize: number;
};
export type SmsDrainageInfo = {
@@ -1702,12 +1705,18 @@ export const adminApi = {
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)),
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
listEnterpriseApplicationsPage: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string; page: number; pageSize: number }) =>
request<PagedResult<EnterpriseApplication>>(withQuery('/admin/enterprise-applications', query)),
listEnterpriseApplicationOptions: (query: { tenantId?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-application-options', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; templateMismatchMode?: string; downstreamReceiptRetryEnabled?: boolean; downstreamUplinkRetryEnabled?: boolean; cmppAccount?: string; cmppEnterpriseCode?: string; cmppApplicationExtension?: string; cmppAccessNumberFillEnabled?: boolean; cmppAccessNumberFillPrefix?: string; passwordCipher?: string; interfaceEnabled?: boolean; interfaceType?: 'cmpp20'; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
@@ -1742,6 +1751,8 @@ export const adminApi = {
body: JSON.stringify(body),
}),
listChannels: () => request<AdminChannel[]>('/admin/channels'),
listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) =>
request<PagedResult<AdminChannel>>(withQuery('/admin/channels', query)),
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
@@ -1800,6 +1811,10 @@ export const adminApi = {
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) =>
@@ -1822,6 +1837,8 @@ export const adminApi = {
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) =>
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsTemplate>>(withQuery('/admin/enterprise-templates', query)),
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
@@ -1890,6 +1907,8 @@ export const adminApi = {
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
@@ -1899,8 +1918,12 @@ export const adminApi = {
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)),
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
terminateAdminBatchTask: (id: string) =>
@@ -1909,10 +1932,14 @@ export const adminApi = {
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
requestBlob(withQuery('/admin/operations/messages/export', query)),
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) =>
request<PagedResult<SmsUplinkMessage>>(withQuery('/admin/operations/uplink-messages', query)),
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
@@ -2058,8 +2085,14 @@ export const clientApi = {
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }),
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/application-options', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
@@ -2077,8 +2110,10 @@ export const clientApi = {
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
getSignatureWorkspace: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSignatureWorkspace>('/client/signatures-workspace', { tenantId }),
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }),
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -2101,6 +2136,13 @@ export const clientApi = {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}), { tenantId }),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsTemplate>>(withQuery('/client/templates', {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page,
pageSize: query.pageSize,
}), { tenantId }),
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -2115,6 +2157,8 @@ export const clientApi = {
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }),
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -2125,10 +2169,12 @@ export const clientApi = {
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query), { tenantId }),
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
assertUploadFileSize(file);
const form = new FormData();
+18 -25
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
@@ -497,42 +497,35 @@ export function AdminChannelsPage() {
const [logState, setLogState] = useState<ChannelLogState | null>(null);
const [logKeyword, setLogKeyword] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
function loadChannels() {
Promise.all([adminApi.listChannels(), adminApi.getSendQuality()])
.then(async ([items, quality]) => {
const visibleChannels = items.filter((item) => item.status !== 'deleted');
function loadChannels(targetPage = page, filters = { keyword, carrier, status }) {
Promise.all([
adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }),
adminApi.getSendQuality(),
])
.then(async ([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))));
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
}
useEffect(() => {
loadChannels();
}, []);
loadChannels(page);
}, [page]);
const filteredChannels = useMemo(
() => channels.filter((channel) => {
const matchesKeyword = !keyword || channel.name.includes(keyword);
const matchesCarrier = carrier === 'all' || channel.carrier === carrier;
const matchesStatus = status === 'all' || channel.status === status;
return matchesKeyword && matchesCarrier && matchesStatus;
}),
[carrier, channels, keyword, status],
);
const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize));
const filteredChannels = channels;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [carrier, channels.length, keyword, status]);
const visibleChannels = channels;
async function upsertChannel(nextChannel: SmsChannel) {
try {
@@ -617,8 +610,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={() => void loadChannels()}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost"></Button>
<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>
</div>
</div>
</div>
@@ -677,7 +670,7 @@ export function AdminChannelsPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredChannels.length}
total={total}
/>
</div>
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type ApplicationDeactivationPreview, type CmppDownstreamConnection, type EnterpriseApplication, type HttpApiConfigResponse, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
@@ -316,6 +316,9 @@ function CmppConnectionModal({
export function AdminEnterpriseApplicationsPage() {
const navigate = useNavigate();
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
@@ -339,10 +342,11 @@ export function AdminEnterpriseApplicationsPage() {
>(null);
const [deactivateAction, setDeactivateAction] = useState<{ app: SmsApp; preview: ApplicationDeactivationPreview } | null>(null);
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) {
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }, targetPage = page) {
try {
const applications = await adminApi.listEnterpriseApplications(filters);
setSmsApps(applications.map(mapApplication));
const result = await adminApi.listEnterpriseApplicationsPage({ ...filters, page: targetPage, pageSize });
setSmsApps(result.items.map(mapApplication));
setTotal(result.total);
setError('');
} catch (err) {
setSmsApps([]);
@@ -351,8 +355,8 @@ export function AdminEnterpriseApplicationsPage() {
}
useEffect(() => {
void loadSmsApps();
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus]);
void loadSmsApps(undefined, page);
}, [page]);
async function openAddModal() {
setAddModalOpen(true);
@@ -441,12 +445,7 @@ export function AdminEnterpriseApplicationsPage() {
}
}
const filteredSmsApps = useMemo(
() => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword))
&& (appliedStatus === 'all' || item.status === appliedStatus)),
[appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps],
);
const filteredSmsApps = smsApps;
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
@@ -524,8 +523,8 @@ export function AdminEnterpriseApplicationsPage() {
value={status}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); void loadSmsApps(filters); }}></Button>
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); void loadSmsApps(filters); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), status }; setAppliedEnterpriseKeyword(filters.enterpriseKeyword); setAppliedApplicationKeyword(filters.applicationKeyword); setAppliedStatus(filters.status); setPage(1); void loadSmsApps(filters, 1); }}></Button>
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); setPage(1); void loadSmsApps(filters, 1); }} variant="ghost"></Button>
</div>
</div>
@@ -534,7 +533,7 @@ export function AdminEnterpriseApplicationsPage() {
<div className="surface section-stack">
<Tabs
items={[
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
{ label: '短信应用', value: 'sms', content: <><Table columns={smsColumns} data={filteredSmsApps} pagination={false} rowKey="id" /><Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} /></> },
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty"></div> },
]}
/>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileSpreadsheet, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
@@ -614,19 +614,23 @@ export function AdminEnterpriseSignaturesPage() {
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) {
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page) {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures(filters),
const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
adminApi.listEnterpriseApplicationOptions(),
]);
setSignatures(signatureItems);
setSignatures(signatureResult.items);
setTotal(signatureResult.total);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setError('');
@@ -636,26 +640,13 @@ export function AdminEnterpriseSignaturesPage() {
}
useEffect(() => {
void loadData();
}, []);
void loadData(undefined, page);
}, [page]);
const filteredSignatures = useMemo(() => signatures.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
const drainageItems = readDrainagePayload(item).links;
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword))
&& (!appliedDrainageKeyword || drainageItems.some((drainage) => `${drainage.siteName} ${drainage.url} ${drainage.remark}`.includes(appliedDrainageKeyword)));
}), [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const filteredSignatures = signatures;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
const visibleSignatures = signatures;
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -792,7 +783,7 @@ export function AdminEnterpriseSignaturesPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
total={total}
/>
{filteredSignatures.length === 0 ? <div className="ui-table__empty"></div> : null}
</div>
@@ -823,7 +814,8 @@ export function AdminEnterpriseSignaturesPage() {
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
void loadData(filters);
setPage(1);
void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
@@ -835,7 +827,8 @@ export function AdminEnterpriseSignaturesPage() {
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
void loadData(filters);
setPage(1);
void loadData(filters, 1);
}} variant="ghost"></Button>
</div>
</div>
+21 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -289,6 +289,7 @@ export function AdminEnterpriseTemplatesPage() {
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [total, setTotal] = useState(0);
const [templateNameKeyword, setTemplateNameKeyword] = useState('');
const [appliedTemplateNameKeyword, setAppliedTemplateNameKeyword] = useState('');
const [templateContentKeyword, setTemplateContentKeyword] = useState('');
@@ -296,15 +297,18 @@ export function AdminEnterpriseTemplatesPage() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }) {
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) {
try {
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplates(filters),
const [templateResult, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
adminApi.listEnterpriseSignatures(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
]);
setTemplates(templateItems);
setTemplates(templateResult.items);
setTotal(templateResult.total);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setSignatureItems(signatureList);
@@ -315,25 +319,13 @@ export function AdminEnterpriseTemplatesPage() {
}
useEffect(() => {
void loadData();
}, []);
void loadData(undefined, page);
}, [page]);
const filteredTemplates = useMemo(() => templates.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
&& (!appliedTemplateNameKeyword || item.name.includes(appliedTemplateNameKeyword))
&& (!appliedTemplateContentKeyword || item.content.includes(appliedTemplateContentKeyword));
}), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const filteredTemplates = templates;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword]);
const visibleTemplates = templates;
async function saveTemplate(state: TemplateFormState) {
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
@@ -387,7 +379,8 @@ export function AdminEnterpriseTemplatesPage() {
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
void loadData(filters);
setPage(1);
void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
@@ -399,7 +392,8 @@ export function AdminEnterpriseTemplatesPage() {
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
void loadData(filters);
setPage(1);
void loadData(filters, 1);
}} variant="ghost"></Button>
</div>
</div>
@@ -447,7 +441,7 @@ export function AdminEnterpriseTemplatesPage() {
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
total={total}
totalPages={totalPages}
/>
</div>
+23 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Plus, ReceiptText, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, RechargeReceiptDialog, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
@@ -28,19 +28,28 @@ export function AdminRechargeRecordsPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const pageSize = 10;
async function loadData() {
async function loadData(targetPage = page, filters = { enterpriseKeyword, dateRange }) {
setLoading(true);
setError('');
try {
const [nextTenants, nextAccounts, nextRecords] = await Promise.all([
const [nextTenants, nextAccounts, result] = await Promise.all([
adminApi.listTenants(),
adminApi.listAccounts(),
adminApi.listManualRecharges(),
adminApi.listManualRechargesPage({
enterpriseKeyword: filters.enterpriseKeyword.trim() || undefined,
createdAtFrom: filters.dateRange.start,
createdAtTo: filters.dateRange.end,
page: targetPage,
pageSize,
}),
]);
setTenants(nextTenants);
setAccounts(nextAccounts);
setRecords(nextRecords);
setRecords(result.items);
setTotal(result.total);
} catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败');
setRecords([]);
@@ -50,35 +59,22 @@ export function AdminRechargeRecordsPage() {
}
useEffect(() => {
void loadData();
}, []);
void loadData(page);
}, [page]);
const filteredRows = useMemo(
() => records.filter((item) => {
const rechargeDate = getDate(item.paidAt ?? item.createdAt);
const tenantName = item.tenant?.name ?? tenants.find((tenant) => tenant.id === item.tenantId)?.name ?? item.tenantId;
const matchesEnterprise = !enterpriseKeyword || tenantName.includes(enterpriseKeyword);
const matchesStartDate = !dateRange.start || rechargeDate >= dateRange.start;
const matchesEndDate = !dateRange.end || rechargeDate <= dateRange.end;
return matchesEnterprise && matchesStartDate && matchesEndDate;
}),
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const filteredRows = records;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const visibleRows = records;
const receiptTenant = receiptRecord
? receiptRecord.tenant ?? tenants.find((tenant) => tenant.id === receiptRecord.tenantId)
: undefined;
useEffect(() => {
setPage(1);
}, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]);
function resetFilters() {
setEnterpriseKeyword('');
setDateRange({});
setPage(1);
void loadData(1, { enterpriseKeyword: '', dateRange: {} });
}
return (
@@ -95,7 +91,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} />}></Button>
<Button icon={<Search size={16} />} onClick={() => { setPage(1); void loadData(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
@@ -156,7 +152,7 @@ export function AdminRechargeRecordsPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
total={total}
/>
</div>
+31 -19
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react';
import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral',
@@ -71,28 +71,30 @@ export function AdminReportRecordsPage() {
const [reportType, setReportType] = useState('all');
const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
function loadData() {
adminApi.listReportRecords()
.then((items) => {
setRecords(items);
function loadData(targetPage = page) {
adminApi.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setRecords(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备记录加载失败'));
}
useEffect(() => {
loadData();
}, []);
const filteredRecords = useMemo(() => records.filter((record) => {
const text = `${record.taskId}${record.channel?.name ?? ''}${record.action}${actionLabel[record.action] ?? ''}${recordSource(record)}${record.reason ?? ''}${record.task?.signature?.name ?? ''}${record.task?.signature?.purpose ?? ''}${record.task?.drainageInfo?.siteName ?? ''}${record.task?.drainageInfo?.url ?? ''}${record.task?.drainageInfo?.remark ?? ''}`;
const date = record.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end)
&& (reportType === 'all' || record.task?.reportType === reportType);
}), [dateRange.end, dateRange.start, keyword, records, reportType]);
loadData(page);
}, [page]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
@@ -122,14 +124,24 @@ export function AdminReportRecordsPage() {
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<Table columns={columns} data={filteredRecords} emptyText="暂无报备记录" rowKey="id" />
<Table columns={columns} data={records} emptyText="暂无报备记录" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((current) => current + 1)}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? <RecordDetailModal onClose={() => setDetail(null)} record={detail} /> : null}
</section>
+23 -17
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
@@ -74,25 +74,30 @@ export function AdminReportTasksPage() {
const [statusReason, setStatusReason] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
function loadData() {
adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' })
.then((items) => {
setTasks(items);
function loadData(targetPage = page) {
adminApi.listReportTasksPage({
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setTasks(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备明细加载失败'));
}
useEffect(loadData, [reportType]);
const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageInfo?.siteName ?? ''}${task.drainageInfo?.url ?? ''}${task.signature?.tenant?.name ?? ''}${task.signature?.application?.name ?? ''}`;
const date = task.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [dateRange.end, dateRange.start, keyword, tasks]);
useEffect(() => {
loadData(page);
}, [page]);
async function saveTaskStatus() {
if (!statusTask) return;
@@ -143,13 +148,14 @@ export function AdminReportTasksPage() {
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}></Button><Button onClick={() => {
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button><Button onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
}} variant="ghost"></Button></div>
</div>
<div className="surface report-task-table-card"><Table columns={columns} data={filteredTasks} emptyText="暂无报备明细" rowKey="id" /></div>
<div className="surface report-task-table-card"><Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" /></div>
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost"></Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
{statusTask ? <div className="page-stack">
+51 -33
View File
@@ -419,6 +419,10 @@ export function AdminSmsRecordsPage() {
const [segmentLoading, setSegmentLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [filterTenants, setFilterTenants] = useState<Array<{ id: string; name: string }>>([]);
const [filterApplications, setFilterApplications] = useState<Array<{ id: string; tenantId: string; name: string }>>([]);
type MessageFilters = {
tenantId?: string;
@@ -444,19 +448,30 @@ export function AdminSmsRecordsPage() {
};
}
function loadData(filters = currentFilters()) {
adminApi.listOperationMessages(filters)
.then((items) => {
setRecords(items);
setSelectedRecord((current) => current ? items.find((item) => item.id === current.id) ?? null : null);
setPage(1);
function loadData(filters = currentFilters(), targetPage = page) {
setLoading(true);
adminApi.listOperationMessages({ ...filters, page: targetPage, pageSize })
.then((result) => {
setRecords(result.items);
setTotal(result.total);
setSelectedRecord((current) => current ? result.items.find((item) => item.id === current.id) ?? null : null);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
loadData(currentFilters(), page);
}, [page]);
useEffect(() => {
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplicationOptions()])
.then(([tenants, applications]) => {
setFilterTenants(tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name })));
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
})
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
}, []);
useEffect(() => {
@@ -475,31 +490,19 @@ export function AdminSmsRecordsPage() {
}, [selectedRecord]);
const enterpriseOptions = useMemo(() => {
const tenants = new Map<string, string>();
records.forEach((record) => {
if (record.tenantId) {
tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId);
}
});
return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))];
}, [records]);
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((item) => ({ label: item.name, value: item.id }))];
}, [filterTenants]);
const applicationOptions = useMemo(() => {
const applications = new Map<string, string>();
records
.filter((record) => enterprise === 'all' || record.tenantId === enterprise)
.forEach((record) => {
if (record.applicationId) {
applications.set(record.applicationId, record.application?.name ?? record.applicationId);
}
});
return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))];
}, [enterprise, records]);
return [{ label: '全部应用', value: 'all' }, ...filterApplications
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
.map((item) => ({ label: item.name, value: item.id }))];
}, [enterprise, filterApplications]);
const filteredRows = records;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const visibleRows = filteredRows;
function resetFilters() {
const defaultDateRange = defaultSmsRecordDateRange();
@@ -510,7 +513,22 @@ export function AdminSmsRecordsPage() {
setContentKeyword('');
setChannelKeyword('');
setStatus('all');
loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end });
if (page !== 1) setPage(1);
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
}
async function exportRecords() {
try {
const blob = await adminApi.exportOperationMessages(currentFilters());
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `sms-records-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '短信记录导出失败');
}
}
return (
@@ -551,17 +569,17 @@ export function AdminSmsRecordsPage() {
value={status}
/>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={() => loadData()}></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(currentFilters(), 1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={() => downloadCsv(filteredRows)} variant="ghost">CSV</Button>
<Button icon={<Download size={16} />} onClick={() => void exportRecords()} variant="ghost">CSV</Button>
</div>
<div className="admin-sms-record-list">
{filteredRows.length === 0 ? <div className="ui-table__empty"></div> : visibleRows.map((record) => (
{loading ? <div className="ui-table__empty">...</div> : filteredRows.length === 0 ? <div className="ui-table__empty"></div> : visibleRows.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
@@ -588,7 +606,7 @@ export function AdminSmsRecordsPage() {
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
total={total}
totalPages={totalPages}
/>
</div>
+37 -31
View File
@@ -382,12 +382,25 @@ export function AdminSmsTaskProgressPage() {
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const [filterTenants, setFilterTenants] = useState<string[]>([]);
const [filterApplications, setFilterApplications] = useState<Array<{ tenantName: string; name: string }>>([]);
const pageSize = 10;
function loadTasks() {
function loadTasks(targetPage = page) {
setLoading(true);
adminApi.listAdminBatchTasks()
.then((items) => {
setTasks(items.map(mapTask));
adminApi.listAdminBatchTasksPage({
keyword: keyword || undefined,
enterpriseKeyword: enterprise === 'all' ? undefined : enterprise,
applicationKeyword: application === 'all' ? undefined : application,
createdAtFrom: submittedDateRange.start || undefined,
createdAtTo: submittedDateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setTasks(result.items.map(mapTask));
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信任务进度加载失败'))
@@ -395,7 +408,17 @@ export function AdminSmsTaskProgressPage() {
}
useEffect(() => {
loadTasks();
loadTasks(page);
}, [page]);
useEffect(() => {
Promise.all([adminApi.listTenants(), 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));
setFilterApplications(applications.filter((item) => item.status !== 'deleted').map((item) => ({ tenantName: tenantNameById.get(item.tenantId) ?? '', name: item.name })));
})
.catch((failure: Error) => setError(failure.message || '任务筛选项加载失败'));
}, []);
function loadPhones(target = phoneTarget, page = phonePage, pageSize = phonePageSize) {
@@ -410,35 +433,18 @@ export function AdminSmsTaskProgressPage() {
}, [phoneTarget, phonePage, phonePageSize]);
const enterpriseOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [tasks]);
return [{ label: '全部企业', value: 'all' }, ...filterTenants.map((name) => ({ label: name, value: name }))];
}, [filterTenants]);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasks.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
const names = Array.from(new Set(filterApplications.filter((item) => enterprise === 'all' || item.tenantName === enterprise).map((item) => item.name)));
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
}, [enterprise, tasks]);
}, [enterprise, filterApplications]);
const filteredTasks = useMemo(
() => tasks.filter((item) => {
const submittedDate = item.submittedAt.slice(0, 10);
const matchesKeyword = !keyword || item.id.includes(keyword);
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
const matchesApplication = application === 'all' || item.application === application;
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate;
}),
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
const filteredTasks = tasks;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
const visibleTasks = filteredTasks;
function resetFilters() {
setKeyword('');
@@ -480,7 +486,7 @@ export function AdminSmsTaskProgressPage() {
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadTasks}></Button>
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadTasks(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
@@ -594,7 +600,7 @@ export function AdminSmsTaskProgressPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
total={total}
/>
</div>
+27 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
@@ -7,6 +7,7 @@ import {
DateRangeInput,
Input,
Modal,
Pagination,
Table,
type DateRangeValue,
type TableColumn,
@@ -210,12 +211,23 @@ export function AdminSmsUplinkRecordsPage() {
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
function loadData() {
function loadData(targetPage = page, filters = { phoneKeyword, contentKeyword, dateRange }) {
setLoading(true);
adminApi.listAdminUplinkMessages()
.then((items) => {
setMessages(items);
adminApi.listAdminUplinkMessagesPage({
phoneNumber: filters.phoneKeyword.trim() || undefined,
keyword: filters.contentKeyword.trim() || undefined,
startTime: filters.dateRange.start ? `${filters.dateRange.start}T00:00:00+08:00` : undefined,
endTime: filters.dateRange.end ? `${filters.dateRange.end}T23:59:59.999+08:00` : undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setMessages(result.items);
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
@@ -233,32 +245,22 @@ export function AdminSmsUplinkRecordsPage() {
}
setMatching(true);
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId })
.then((items) => setMatchedRecords(items))
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId, page: 1, pageSize: 10 })
.then((result) => setMatchedRecords(result.items))
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
.finally(() => setMatching(false));
}
useEffect(() => {
loadData();
}, []);
const filteredMessages = useMemo(
() => messages.filter((item) => {
const receivedDate = getDate(item.receivedAt);
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
}),
[contentKeyword, dateRange.end, dateRange.start, messages, phoneKeyword],
);
loadData(page);
}, [page]);
function resetFilters() {
setDateRange({});
setPhoneKeyword('');
setContentKeyword('');
setPage(1);
loadData(1, { phoneKeyword: '', contentKeyword: '', dateRange: {} });
}
function handleClaim(candidate: SmsUplinkMatchCandidate) {
@@ -271,7 +273,7 @@ export function AdminSmsUplinkRecordsPage() {
.then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
loadData();
loadData(page);
})
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
.finally(() => setClaimingId(''));
@@ -316,7 +318,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={loadData}></Button>
<Button icon={<Search size={16} />} onClick={() => { setPage(1); loadData(1); }}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
@@ -324,7 +326,8 @@ export function AdminSmsUplinkRecordsPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-uplink-table-card">
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} pagination={false} rowKey="id" />
<Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} />
</div>
{selectedMessage ? (
+11 -13
View File
@@ -69,12 +69,15 @@ export function ClientApplicationsPage() {
const [paramsError, setParamsError] = useState('');
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState('');
const [total, setTotal] = useState(0);
const pageSize = 10;
function loadApplications() {
setLoading(true);
clientApi.listApplications()
.then((items) => {
setApplications(items.filter((item) => item.status !== 'deleted'));
clientApi.listApplicationsPage({ page, pageSize })
.then((result) => {
setApplications(result.items.filter((item) => item.status !== 'deleted'));
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信应用加载失败'))
@@ -83,7 +86,7 @@ export function ClientApplicationsPage() {
useEffect(() => {
loadApplications();
}, []);
}, [page]);
function openParams(application: ClientSmsApplication) {
if (application.interfaceEnabled === false) return;
@@ -102,14 +105,9 @@ export function ClientApplicationsPage() {
}
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(applications.length / pageSize));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleApplications = applications.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [applications.length]);
const visibleApplications = applications;
function copyParams() {
if (selectedRows.length === 0) {
@@ -128,7 +126,7 @@ export function ClientApplicationsPage() {
<FileText size={22} />
</span>
<h1></h1>
<span className="muted"> {applications.length} </span>
<span className="muted"> {total} </span>
</div>
{loading ? <p className="muted">...</p> : null}
@@ -180,7 +178,7 @@ export function ClientApplicationsPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={applications.length}
total={total}
/>
<Modal
+41 -33
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { Clock3, Eye, FileText, Search, StopCircle } from 'lucide-react';
import {
Button,
@@ -99,6 +99,8 @@ export function ClientBatchTasksPage() {
const [tasks, setTasks] = useState<BatchTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
const [keyword, setKeyword] = useState('');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
@@ -106,11 +108,21 @@ export function ClientBatchTasksPage() {
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
function loadTasks() {
const pageSize = 10;
function loadTasks(targetPage = page) {
setLoading(true);
clientApi.listBatchTasks()
.then((items) => {
setTasks(items.map(mapTask));
clientApi.listBatchTasksPage({
keyword: keyword.trim() || undefined,
applicationKeyword: application === 'all' ? undefined : application,
createdAtFrom: submittedDateRange.start,
createdAtTo: submittedDateRange.end,
page: targetPage,
pageSize,
})
.then((result) => {
setTasks(result.items.map(mapTask));
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '批量任务加载失败'))
@@ -118,39 +130,28 @@ export function ClientBatchTasksPage() {
}
useEffect(() => {
loadTasks();
}, []);
const applicationOptions = useMemo(() => {
const names = Array.from(new Set(tasks.map((item) => item.applicationName)));
return [
{ label: '全部应用', value: 'all' },
...names.map((name) => ({ label: name, value: name })),
];
}, [tasks]);
const filteredTasks = tasks.filter((item) => {
const matchesKeyword = !keyword || item.id.includes(keyword);
const matchesApplication = application === 'all' || item.applicationName === application;
const submittedDate = item.submittedAt.slice(0, 10);
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
});
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
loadTasks(page);
}, [page]);
useEffect(() => {
setPage(1);
}, [application, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
clientApi.listApplicationOptions()
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
.catch(() => undefined);
}, []);
const applicationOptions = [
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.name })),
];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = tasks;
function terminateTask(id: string) {
const source = tasks.find((item) => item.id === id);
if (!source) return;
clientApi.cancelBatchTask(source.backendId)
.then(loadTasks)
.then(() => loadTasks(page))
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
}
@@ -236,17 +237,24 @@ export function ClientBatchTasksPage() {
<QueryPanel
title="查询条件"
summary={<> <strong>{filteredTasks.length}</strong> </>}
summary={<> <strong>{total}</strong> </>}
>
<Input
label="发送批次号"
onChange={(event) => setKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setPage(1);
loadTasks(1);
}
}}
placeholder="输入发送批次号搜索"
prefix={<Search size={16} />}
value={keyword}
/>
<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>
</QueryPanel>
<div className="surface batch-table-card">
@@ -301,7 +309,7 @@ export function ClientBatchTasksPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
total={total}
/>
</div>
+10 -8
View File
@@ -12,23 +12,25 @@ export function ClientBillingPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(orders.length / pageSize));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleOrders = orders.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const visibleOrders = orders;
useEffect(() => {
setLoading(true);
Promise.all([clientApi.getDashboard(), clientApi.listOrders()])
.then(([dashboard, nextOrders]) => {
Promise.all([clientApi.getDashboard(), clientApi.listOrdersPage({ page, pageSize })])
.then(([dashboard, result]) => {
setBalanceCents(dashboard.accounts[0]?.balanceCents ?? 0);
setCreditCents(dashboard.accounts[0]?.creditCents ?? 0);
setOrders(nextOrders);
setOrders(result.items);
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '账户信息加载失败'))
.finally(() => setLoading(false));
}, []);
}, [page]);
return (
<section className="page-stack">
@@ -46,7 +48,7 @@ export function ClientBillingPage() {
</div>
</div>
<div className="surface section-stack">
<div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{orders.length} </Tag></div>
<div className="section-heading"><div><h2></h2><p className="muted"></p></div><Tag tone="info">{total} </Tag></div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
{!loading && !error ? (
@@ -67,7 +69,7 @@ export function ClientBillingPage() {
</table>
</div>
) : null}
<Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={orders.length} />
<Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} totalPages={totalPages} onPageChange={setPage} previousDisabled={currentPage <= 1} total={total} />
</div>
</section>
);
+27 -25
View File
@@ -60,18 +60,26 @@ export function ClientSendDetailPage() {
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadData() {
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,
page: targetPage,
pageSize: 10,
})
.then((items) => {
setRecords(items);
.then((result) => {
setRecords(result.items);
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信发送详情加载失败'))
@@ -79,37 +87,31 @@ export function ClientSendDetailPage() {
}
useEffect(() => {
loadData();
}, [applicationId, phoneKeyword, status]);
loadData(page);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, page, phoneKeyword, status]);
useEffect(() => {
clientApi.listApplicationOptions()
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
}, []);
const applicationOptions = useMemo(() => {
const applications = new Map<string, string>();
records.forEach((item) => {
if (item.applicationId) {
applications.set(item.applicationId, item.application?.name ?? item.applicationId);
}
});
return [
{ label: '全部应用', value: 'all' },
...Array.from(applications.entries()).map(([value, label]) => ({ label, value })),
...applications.map((item) => ({ label: item.name, value: item.id })),
];
}, [records]);
}, [applications]);
const filteredRows = records.filter((item) => {
const sentDate = getDate(item.queuedAt);
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesContent;
});
const filteredRows = records;
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const visibleRows = filteredRows;
useEffect(() => {
setPage(1);
}, [contentKeyword, dateRange.end, dateRange.start, records.length]);
}, [applicationId, contentKeyword, dateRange.end, dateRange.start, phoneKeyword, status]);
return (
<section className="page-stack">
@@ -122,7 +124,7 @@ export function ClientSendDetailPage() {
<QueryPanel
title="查询条件"
summary={<> <strong>{filteredRows.length}</strong> </>}
summary={<> <strong>{total}</strong> </>}
>
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
@@ -232,7 +234,7 @@ export function ClientSendDetailPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
total={total}
/>
</div>
</section>
+29 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import {
@@ -14,6 +14,9 @@ import { getSmsSignatureValidationError, hasForbiddenSmsSignatureCharacter, isCo
const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
items: [],
summary: { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 },
total: 0,
page: 1,
pageSize: 10,
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
@@ -269,9 +272,20 @@ export function ClientSignaturesPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
function loadData() {
const pageSize = 10;
function loadData(targetPage = page) {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.getSignatureWorkspace()])
Promise.all([
clientApi.listApplicationOptions(),
clientApi.getSignatureWorkspace({
keyword: keyword.trim() || undefined,
applicationId: applicationFilter || undefined,
status: statusFilter || undefined,
page: targetPage,
pageSize,
}),
])
.then(([applicationItems, signatureWorkspace]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setWorkspace(signatureWorkspace);
@@ -281,19 +295,15 @@ export function ClientSignaturesPage() {
.finally(() => setLoading(false));
}
useEffect(loadData, []);
useEffect(() => {
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [applicationFilter, keyword, page, statusFilter]);
const filteredItems = useMemo(() => workspace.items.filter((item) => {
const matchesKeyword = !keyword.trim() || [item.name, item.purpose, item.application?.name].join(' ').toLowerCase().includes(keyword.trim().toLowerCase());
return matchesKeyword && (!applicationFilter || item.applicationId === applicationFilter) && (!statusFilter || item.auditStatus === statusFilter);
}), [applicationFilter, keyword, statusFilter, workspace.items]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const filteredItems = workspace.items;
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => setPage(1), [applicationFilter, keyword, statusFilter]);
const visibleItems = workspace.items;
function toggleExpanded(id: string) {
setExpandedIds((current) => {
@@ -314,7 +324,7 @@ export function ClientSignaturesPage() {
}
}
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); };
const resetFilters = () => { setKeyword(''); setApplicationFilter(''); setStatusFilter(''); setPage(1); };
return <section className="page-stack client-signature-page">
<header className="client-signature-heading">
<div className="client-signature-title">
@@ -332,9 +342,9 @@ export function ClientSignaturesPage() {
</section>
<div className="client-signature-toolbar">
<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} />
<Select onChange={(event) => setStatusFilter(event.target.value)} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
<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} />
<Select onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost"></Button>
</div>
@@ -377,7 +387,7 @@ export function ClientSignaturesPage() {
})}
</section>
<Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} total={filteredItems.length} totalPages={totalPages} />
<Pagination nextDisabled={currentPage >= totalPages} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} page={currentPage} previousDisabled={currentPage <= 1} total={workspace.total} totalPages={totalPages} />
{signatureModal ? <SignatureModal applications={applications} onClose={() => setSignatureModal(undefined)} onSaved={() => { setSignatureModal(undefined); loadData(); }} signature={signatureModal === 'new' ? undefined : signatureModal} /> : null}
{drainageModal ? <DrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
+16 -19
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
@@ -217,13 +217,16 @@ export function ClientTemplatesPage() {
const [loading, setLoading] = useState(true);
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
function loadData() {
function loadData(targetPage = page) {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listTemplates({ includeHistory: true }), clientApi.listSignatures()])
.then(([applicationItems, templateItems, signatureItems]) => {
Promise.all([clientApi.listApplicationOptions(), clientApi.listTemplatesPage({ includeHistory: true, keyword: keyword.trim() || undefined, page: targetPage, pageSize }), clientApi.listSignatureOptions()])
.then(([applicationItems, templateResult, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setTotal(templateResult.total);
setSignatures(signatureItems);
setError('');
})
@@ -232,20 +235,14 @@ export function ClientTemplatesPage() {
}
useEffect(() => {
loadData();
}, []);
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [page, keyword]);
const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
)), [keyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const filteredTemplates = templates;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredTemplates.length, keyword]);
const visibleTemplates = templates;
async function saveTemplate(state: TemplateFormState) {
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
@@ -282,7 +279,7 @@ export function ClientTemplatesPage() {
<div className="template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
placeholder="搜索模板名称、应用、签名或内容"
prefix={<Search size={17} />}
value={keyword}
@@ -327,7 +324,7 @@ export function ClientTemplatesPage() {
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
total={total}
/>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null}
+20 -9
View File
@@ -8,6 +8,7 @@ import {
DetailSection,
Input,
Modal,
Pagination,
QueryPanel,
Table,
type DateRangeValue,
@@ -33,12 +34,16 @@ export function ClientUplinkMessagesPage() {
const [matching, setMatching] = useState(false);
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const pageSize = 10;
function loadData() {
function loadData(targetPage = page) {
setLoading(true);
clientApi.listUplinkMessages({ 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 })
.then((items) => {
setMessages(items);
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 })
.then((result) => {
setMessages(result.items);
setTotal(result.total);
setError('');
})
.catch((reason: Error) => setError(reason.message || '上行短信加载失败'))
@@ -55,17 +60,22 @@ export function ClientUplinkMessagesPage() {
}
setMatching(true);
clientApi.listMessages({ messageId: message.messageId })
.then((items) => setMatchedRecords(items))
clientApi.listMessages({ messageId: message.messageId, page: 1, pageSize: 10 })
.then((result) => setMatchedRecords(result.items))
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
.finally(() => setMatching(false));
}
useEffect(() => {
const timer = window.setTimeout(loadData, 300);
setPage(1);
const timer = window.setTimeout(() => loadData(1), 300);
return () => window.clearTimeout(timer);
}, [phoneKeyword, contentKeyword, dateRange.start, dateRange.end]);
useEffect(() => {
if (page > 1) loadData(page);
}, [page]);
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{getTime(record.receivedAt)}</span> },
@@ -92,7 +102,7 @@ export function ClientUplinkMessagesPage() {
<h1></h1>
</div>
<QueryPanel title="查询条件" summary={<> <strong>{messages.length}</strong> </>}>
<QueryPanel title="查询条件" summary={<> <strong>{total}</strong> </>}>
<Input
label="手机号码"
onChange={(event) => setPhoneKeyword(event.target.value)}
@@ -113,7 +123,8 @@ export function ClientUplinkMessagesPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface uplink-table-card">
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
<Table columns={columns} data={loading ? [] : messages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} pagination={false} rowKey="id" />
<Pagination page={page} totalPages={Math.max(1, Math.ceil(total / pageSize))} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(Math.max(1, Math.ceil(total / pageSize)), value + 1))} previousDisabled={page <= 1} nextDisabled={page >= Math.max(1, Math.ceil(total / pageSize))} />
</div>
<Modal