feat: optimize signature workflows and high-frequency queries
This commit is contained in:
@@ -44,6 +44,9 @@ export const adminGovernanceApi = {
|
||||
request<PagedResult<ClientSmsSignature>>(withQuery('/admin/enterprise-signatures', query)),
|
||||
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
|
||||
getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`),
|
||||
getEnterpriseSignatureReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['reportTargets']>>(`/admin/enterprise-signatures/${id}/report-targets`),
|
||||
getDrainageInfoReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['drainageReportTargets']>[string]>(`/admin/drainage-infos/${id}/report-targets`),
|
||||
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> }) =>
|
||||
|
||||
@@ -16,6 +16,7 @@ export const adminIdentityApi = {
|
||||
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||
portalSessionApi.changeOwnPassword('admin', body),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
listTenantOptions: () => request<TenantOption[]>('/admin/tenants/options'),
|
||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||
createTenant: (body: { name: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) =>
|
||||
|
||||
@@ -40,6 +40,7 @@ export const adminOperationsApi = {
|
||||
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; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||
getOperationMessage: (id: string) => request<SmsMessageRecord>(`/admin/operations/messages/${id}`),
|
||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||
|
||||
@@ -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'}`}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Textarea } from './Textarea';
|
||||
|
||||
export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = <Trash2 size={15} />, disabled, onCompleted }: {
|
||||
export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = <Trash2 size={15} />, disabled, onCompleted, className, size = 'sm' }: {
|
||||
portal: 'admin' | 'client';
|
||||
targetType: DeletionTargetType;
|
||||
targetId: string;
|
||||
@@ -14,6 +14,8 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onCompleted?: (result: DeletionResult) => void;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -81,7 +83,7 @@ export function DeleteRiskAction({ portal, targetType, targetId, children = '删
|
||||
</>;
|
||||
|
||||
return <>
|
||||
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button>
|
||||
<Button className={className} disabled={disabled} icon={icon} onClick={() => void begin()} size={size} variant="danger">{children}</Button>
|
||||
<Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
|
||||
<div className="risk-action-content delete-risk-action">
|
||||
{result ? (
|
||||
|
||||
@@ -2021,8 +2021,11 @@
|
||||
width: 224px;
|
||||
}
|
||||
|
||||
.admin-enterprise-signature-list .signature-actions .ui-button {
|
||||
.admin-enterprise-signature-list .signature-actions .ui-button,
|
||||
.admin-enterprise-signature-list .drainage-row-actions .ui-button {
|
||||
height: var(--control-height-sm);
|
||||
justify-content: center;
|
||||
min-height: var(--control-height-sm);
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
@@ -5998,8 +6001,8 @@
|
||||
.report-import-mapping-row > span { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
|
||||
.report-import-mapping-row small { width: 100%; color: var(--text-muted); }
|
||||
.report-import-profile { display: flex; align-items: end; gap: 12px; }
|
||||
.report-import-profile button { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); padding: 9px 12px; display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||
.report-import-profile button.is-active { border-color: var(--primary); color: var(--primary); }
|
||||
.report-import-profile__toggle { min-width: 238px; box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); }
|
||||
.report-import-profile__toggle[aria-pressed="true"] { box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); }
|
||||
.report-import-profile .field { min-width: 320px; }
|
||||
.report-import-preview pre { max-height: 240px; overflow: auto; padding: 12px; background: #111827; color: #d1fae5; border-radius: 8px; font-size: 11px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user