feat: add report material workflows and gateway safeguards
This commit is contained in:
+139
-1
@@ -103,6 +103,33 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'RECENT_AUTHENTICATION_REQUIRED') {
|
||||
await requestReauthentication();
|
||||
return requestForm<T>(path, form, true);
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type AdminChannel = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -556,7 +583,6 @@ export type ChannelGroupItem = DictionaryItem & {
|
||||
priority: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
rateLimitPerSecond?: number | null;
|
||||
channel?: AdminChannel;
|
||||
};
|
||||
|
||||
@@ -570,9 +596,53 @@ export type ChannelReportField = DictionaryItem & {
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
exportName?: string | null;
|
||||
columnWidth?: number;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
defaultValue?: string | null;
|
||||
transform?: string | null;
|
||||
drainageField?: DictionaryItem | null;
|
||||
};
|
||||
|
||||
export type ReportMaterialPendingItem = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string | null;
|
||||
materialVersion: number;
|
||||
changedAt: string;
|
||||
name: string;
|
||||
detail?: string | null;
|
||||
signatureName?: string;
|
||||
tenant?: TenantOption;
|
||||
application?: ClientSmsApplication | null;
|
||||
};
|
||||
|
||||
export type ReportImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
sourceColumnIndex: number;
|
||||
targetFieldCode: string;
|
||||
targetKind: 'signatureName' | 'purpose' | 'siteName' | 'url' | 'remark' | 'dynamic';
|
||||
fieldType: 'string' | 'image' | 'file';
|
||||
required?: boolean;
|
||||
transform?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type ReportImportProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
sheetName?: string | null;
|
||||
headerRowCount: number;
|
||||
dataStartRow: number;
|
||||
columns: ReportImportMapping[];
|
||||
};
|
||||
|
||||
export type ApplicationReportField = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -962,6 +1032,51 @@ export type GatewayDownstreamRecoveryStatus = {
|
||||
application?: EnterpriseApplication | null;
|
||||
};
|
||||
|
||||
export type GatewaySubmitException = {
|
||||
id: string;
|
||||
streamMessageId: string;
|
||||
tenantId?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId?: string | null;
|
||||
traceId?: string | null;
|
||||
messageId?: string | null;
|
||||
submitId?: string | null;
|
||||
status: 'pending' | 'requeueing' | 'requeued' | 'resolved' | string;
|
||||
failureCode: string;
|
||||
failureMessage: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
commandPayload?: Record<string, unknown> | null;
|
||||
rawPayloadAvailable?: boolean;
|
||||
messageState?: {
|
||||
status: string;
|
||||
submitStatus?: string | null;
|
||||
receiptStatus?: string | null;
|
||||
phoneNumber: string;
|
||||
content: string;
|
||||
} | null;
|
||||
manualRetryCount: number;
|
||||
lastRetryStreamId?: string | null;
|
||||
lastRetriedAt?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
resolvedStatus?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
|
||||
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
|
||||
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'> | null;
|
||||
};
|
||||
|
||||
export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & {
|
||||
summary: {
|
||||
pending: number;
|
||||
requeueing: number;
|
||||
requeued: number;
|
||||
resolved: number;
|
||||
oldestPendingAt?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DownstreamRecoveryStatusResponse = PagedResponse<GatewayDownstreamRecoveryStatus> & {
|
||||
summary: {
|
||||
total: number;
|
||||
@@ -1163,6 +1278,25 @@ export const adminApi = {
|
||||
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
|
||||
createChannelReportField: (body: Record<string, unknown>) =>
|
||||
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) =>
|
||||
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }),
|
||||
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string } = {}) =>
|
||||
request<ReportMaterialPendingItem[]>(withQuery('/admin/report-materials/pending', query)),
|
||||
listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
|
||||
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
|
||||
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
|
||||
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }),
|
||||
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => {
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); });
|
||||
return requestForm<Record<string, unknown> & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array<Record<string, unknown>>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form);
|
||||
},
|
||||
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
||||
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
|
||||
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -1188,6 +1322,10 @@ export const adminApi = {
|
||||
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
|
||||
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
|
||||
listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
||||
|
||||
type ReportType = 'signature' | 'drainage' | 'both';
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string };
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
|
||||
@@ -22,8 +23,6 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
|
||||
filing: { label: '报备中', tone: 'warning' },
|
||||
};
|
||||
|
||||
const fieldTypeLabel: Record<string, string> = { string: '字符串', image: '图片', file: '文件' };
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -81,9 +80,6 @@ export function AdminChannelReportPage() {
|
||||
const [nextStatus, setNextStatus] = useState('approved');
|
||||
const [statusReason, setStatusReason] = useState('');
|
||||
const [configType, setConfigType] = useState<ReportType>();
|
||||
const [drainageFieldId, setDrainageFieldId] = useState('');
|
||||
const [required, setRequired] = useState(false);
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -119,11 +115,10 @@ export function AdminChannelReportPage() {
|
||||
return records.find((record) => record.taskId === taskId && record.action === action);
|
||||
}
|
||||
|
||||
function createField() {
|
||||
if (!configType || !drainageFieldId) return;
|
||||
adminApi.createChannelReportField({ channelId, drainageFieldId, reportType: configType, required, description, status: 'active' })
|
||||
.then(() => { setConfigType(undefined); setDrainageFieldId(''); setRequired(false); setDescription(''); loadData(); })
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'));
|
||||
async function saveFieldMapping(nextFields: Parameters<typeof adminApi.replaceChannelReportFields>[2]) {
|
||||
if (!configType) return;
|
||||
await adminApi.replaceChannelReportFields(channelId, configType, nextFields);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function saveTaskStatus() {
|
||||
@@ -180,13 +175,7 @@ export function AdminChannelReportPage() {
|
||||
|
||||
{detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
|
||||
<Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost">取消</Button><Button onClick={saveTaskStatus}>保存</Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
|
||||
<Modal footer={<><Button onClick={() => setConfigType(undefined)} variant="ghost">取消</Button><Button disabled={!drainageFieldId} onClick={createField}>保存</Button></>} onClose={() => setConfigType(undefined)} open={Boolean(configType)} title={configType === 'drainage' ? '配置引流信息报备字段' : '配置签名报备字段'}>
|
||||
<div className="admin-system-modal-form">
|
||||
<Select label="报备字段库字段" onChange={(event) => setDrainageFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...libraryFields.map((field) => ({ label: `${field.name ?? field.code}(${fieldTypeLabel[String(field.fieldType)] ?? field.fieldType})`, value: field.id }))]} value={drainageFieldId} />
|
||||
<Select label="是否必填" onChange={(event) => setRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(required)} />
|
||||
<Textarea label="通道报备说明" onChange={(event) => setDescription(event.target.value)} rows={4} value={description} />
|
||||
</div>
|
||||
</Modal>
|
||||
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock3, Eye, RefreshCw, RotateCcw, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type EnterpriseApplication, type GatewaySubmitException } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pending: '待处理',
|
||||
requeueing: '正在入队',
|
||||
requeued: '已重新入队',
|
||||
resolved: '已处理',
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'danger',
|
||||
requeueing: 'warning',
|
||||
requeued: 'info',
|
||||
resolved: 'success',
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function maskPhone(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
|
||||
}
|
||||
|
||||
function commandValue(record: GatewaySubmitException, key: string) {
|
||||
const value = record.commandPayload?.[key];
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function ExceptionDetailModal({ record, onClose, onRequestRequeue }: {
|
||||
record: GatewaySubmitException;
|
||||
onClose: () => void;
|
||||
onRequestRequeue: () => void;
|
||||
}) {
|
||||
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
|
||||
const content = record.messageState?.content ?? commandValue(record, 'content');
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>Gateway提交异常详情</h2><p>{record.messageId ?? record.streamMessageId}</p></div>}
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
{record.status === 'pending' ? <Button icon={<RotateCcw size={15} />} onClick={onRequestRequeue}>校验并重新入队</Button> : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="report-record-detail gateway-exception-detail">
|
||||
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
|
||||
<div className="surface mini-status-card"><AlertTriangle size={20} /><div><span>处理状态</span><strong>{statusLabel[record.status] ?? record.status}</strong></div></div>
|
||||
<div className="surface mini-status-card"><RefreshCw size={20} /><div><span>自动尝试</span><strong>{record.attempts}/{record.maxAttempts}</strong></div></div>
|
||||
<div className="surface mini-status-card"><RotateCcw size={20} /><div><span>人工重新入队</span><strong>{record.manualRetryCount}</strong></div></div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{record.tenant?.name ?? '-'}</strong></div>
|
||||
<div><span>应用</span><strong>{record.application?.name ?? '-'}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
|
||||
<div><span>通道状态</span><strong>{record.channel?.status ?? '-'}</strong></div>
|
||||
<div><span>手机号</span><strong>{maskPhone(phone)}</strong></div>
|
||||
<div><span>短信状态</span><strong>{record.messageState?.status ?? '-'}</strong></div>
|
||||
<div><span>上游提交状态</span><strong>{record.messageState?.submitStatus ?? '-'}</strong></div>
|
||||
<div><span>回执状态</span><strong>{record.messageState?.receiptStatus ?? '-'}</strong></div>
|
||||
<div><span>MessageId</span><strong>{record.messageId ?? '-'}</strong></div>
|
||||
<div><span>SubmitId</span><strong>{record.submitId ?? '-'}</strong></div>
|
||||
<div><span>TraceId</span><strong>{record.traceId ?? '-'}</strong></div>
|
||||
<div><span>Stream消息</span><strong>{record.streamMessageId}</strong></div>
|
||||
<div><span>异常时间</span><strong>{formatTime(record.createdAt)}</strong></div>
|
||||
<div><span>最近重新入队</span><strong>{formatTime(record.lastRetriedAt)}</strong></div>
|
||||
<div><span>处理时间</span><strong>{formatTime(record.resolvedAt)}</strong></div>
|
||||
<div><span>处理结果</span><strong>{record.resolvedStatus ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>短信内容</span><strong>{content || '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败代码</span><strong>{record.failureCode}</strong></div>
|
||||
<div className="detail-grid__wide"><span>失败原因</span><strong>{record.failureMessage}</strong></div>
|
||||
</div>
|
||||
<div className="gateway-exception-command">
|
||||
<div className="section-heading"><h3>脱敏后的Gateway命令</h3><p className="page-inline-hint">密码、密钥和认证数据已由后端移除。</p></div>
|
||||
<pre>{JSON.stringify(record.commandPayload ?? {}, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RequeueModal({ record, submitting, onClose, onSubmit }: {
|
||||
record: GatewaySubmitException;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (reason: string) => void;
|
||||
}) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const phone = record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber');
|
||||
const unsafe = record.messageState?.submitStatus === 'accepted'
|
||||
|| ['submitted', 'delivered', 'unknown'].includes(record.messageState?.status ?? '')
|
||||
|| ['delivered', 'unknown'].includes(record.messageState?.receiptStatus ?? '');
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>校验并重新入队</h2><p>{maskPhone(phone)}</p></div>}
|
||||
footer={(
|
||||
<div className="modal-footer-actions">
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!confirmed || reason.trim().length < 5 || unsafe || submitting} icon={<RotateCcw size={15} />} onClick={() => onSubmit(reason.trim())}>
|
||||
{submitting ? '正在入队...' : '确认重新入队'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="page-stack gateway-requeue-confirm">
|
||||
<div className={`callout ${unsafe ? 'callout--danger' : 'callout--warning'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>{unsafe ? '当前记录禁止重新入队' : '重新提交可能产生重复短信'}</strong>
|
||||
<p>{unsafe ? '系统已存在成功或不确定的上游结果。' : '请先向通道或运营商确认原短信从未被接收。'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<div><span>当前短信状态</span><strong>{record.messageState?.status ?? '-'}</strong></div>
|
||||
<div><span>原通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong></div>
|
||||
<div className="detail-grid__wide"><span>原失败原因</span><strong>{record.failureMessage}</strong></div>
|
||||
</div>
|
||||
<Textarea label="重新入队原因" maxLength={500} onChange={(event) => setReason(event.target.value)} placeholder="至少5个字,例如:已向通道确认该Submit未被接收,连接现已恢复" rows={4} value={reason} />
|
||||
<label className="gateway-requeue-checkbox">
|
||||
<input checked={confirmed} disabled={unsafe} onChange={(event) => setConfirmed(event.target.checked)} type="checkbox" />
|
||||
<span>我已确认运营商未接收该短信,并知晓重复发送风险。</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminGatewaySubmitExceptionsPage() {
|
||||
const [items, setItems] = useState<GatewaySubmitException[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [summary, setSummary] = useState({ pending: 0, requeueing: 0, requeued: 0, resolved: 0, oldestPendingAt: null as string | null });
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [channelId, setChannelId] = useState('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<GatewaySubmitException | null>(null);
|
||||
const [requeueRecord, setRequeueRecord] = useState<GatewaySubmitException | null>(null);
|
||||
const pageSize = 10;
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.listGatewaySubmitExceptions({ keyword, status, applicationId, channelId, page, pageSize }),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
adminApi.listChannels(),
|
||||
])
|
||||
.then(([response, appItems, channelItems]) => {
|
||||
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) => {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
setError(failure.message || 'Gateway提交异常加载失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [applicationId, channelId, keyword, page, status]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
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> },
|
||||
{ key: 'tenant', title: '企业 / 应用', width: '190px', render: (record) => <div><strong>{record.tenant?.name ?? '-'}</strong><small className="table-cell-note">{record.application?.name ?? '-'}</small></div> },
|
||||
{ key: 'channel', title: '通道', width: '180px', render: (record) => <div>{record.channel?.name ?? '-' }<small className="table-cell-note">{record.channel?.code ?? record.channelId ?? '-'}</small></div> },
|
||||
{ key: 'phone', title: '手机号', width: '130px', render: (record) => maskPhone(record.messageState?.phoneNumber ?? commandValue(record, 'phoneNumber')) },
|
||||
{ key: 'failure', title: '异常原因', render: (record) => <div><strong>{record.failureCode}</strong><small className="table-cell-note">{record.failureMessage}</small></div> },
|
||||
{ key: 'attempts', title: '尝试', width: '80px', align: 'center', render: (record) => `${record.attempts}/${record.maxAttempts}` },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={statusTone[record.status] ?? 'neutral'}>{statusLabel[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '105px', align: 'right', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
], []);
|
||||
|
||||
async function submitRequeue(reason: string) {
|
||||
if (!requeueRecord) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await adminApi.requeueGatewaySubmitException(requeueRecord.id, {
|
||||
confirmedNotSubmitted: true,
|
||||
reason,
|
||||
});
|
||||
setRequeueRecord(null);
|
||||
setDetail(null);
|
||||
setError('');
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '重新入队失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<section className="page-stack admin-sms-task-page report-record-page gateway-exception-page">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['运营概览', 'Gateway提交异常']} /><h1>Gateway提交异常</h1><p className="page-inline-hint">仅处理Gateway连续失败且尚未取得明确上游结果的提交命令。</p></div>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={loadData} variant="secondary">刷新</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface mini-status-card"><AlertTriangle size={22} /><div><span>待处理异常</span><strong>{summary.pending}</strong><small>需要人工判断是否可以重新提交。</small></div></div>
|
||||
<div className="surface mini-status-card"><Clock3 size={22} /><div><span>最早待处理</span><strong className="gateway-exception-time">{formatTime(summary.oldestPendingAt)}</strong><small>等待时间过长应优先处理。</small></div></div>
|
||||
<div className="surface mini-status-card"><RotateCcw size={22} /><div><span>已重新入队</span><strong>{summary.requeued + summary.requeueing}</strong><small>消息继续受Gateway通道TPS约束。</small></div></div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading"><h2>提交异常记录</h2><p className="page-inline-hint">详情中的Gateway命令已由后端脱敏,不返回通道密码。</p></div>
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
|
||||
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
|
||||
</div>
|
||||
{detail ? <ExceptionDetailModal record={detail} onClose={() => setDetail(null)} onRequestRequeue={() => setRequeueRecord(detail)} /> : null}
|
||||
{requeueRecord ? <RequeueModal record={requeueRecord} submitting={submitting} onClose={() => setRequeueRecord(null)} onSubmit={submitRequeue} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
|
||||
type Batch = Record<string, unknown> & { id: string; batchNo?: string; status?: string; createdAt?: string; selectedCount?: number; channelCount?: number; exportFiles?: Array<Record<string, unknown>> };
|
||||
|
||||
const statusLabel: Record<string, string> = { completed: '生成完成', partial_failed: '部分资料待补充', failed: '生成失败', processing: '生成中' };
|
||||
|
||||
export function AdminReportMaterialsPage() {
|
||||
const [items, setItems] = useState<ReportMaterialPendingItem[]>([]);
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [reportType, setReportType] = useState('all');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
||||
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
|
||||
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [reportType]);
|
||||
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
||||
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
|
||||
|
||||
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
|
||||
async function createBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
||||
setBusy(true); setError(''); setMessage('');
|
||||
try {
|
||||
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
|
||||
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <section className="page-stack report-material-page">
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;人工勾选后一次生成所有关联通道的任务和 XLSX 文件。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size})`}</Button></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
||||
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本</span><span>变更时间</span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
||||
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { type ChannelReportField, type DictionaryItem } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type DraftField = {
|
||||
drainageFieldId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
fieldType: string;
|
||||
exportName: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
sortOrder: number;
|
||||
columnWidth: number;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
defaultValue: string;
|
||||
transform: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const fieldTypeLabel: Record<string, string> = { string: '文本', image: '图片', file: '文件' };
|
||||
const transformOptions = [
|
||||
{ label: '保持原值', value: '' },
|
||||
{ label: '去除首尾空格', value: 'trim' },
|
||||
{ label: '仅保留数字', value: 'digits' },
|
||||
{ label: '转大写', value: 'uppercase' },
|
||||
{ label: '转小写', value: 'lowercase' },
|
||||
];
|
||||
|
||||
function initialDraft(fields: ChannelReportField[], reportType: ReportType): DraftField[] {
|
||||
return fields
|
||||
.filter((field) => field.reportType === reportType || field.reportType === 'both')
|
||||
.sort((left, right) => (left.sortOrder ?? 100) - (right.sortOrder ?? 100))
|
||||
.map((field, index) => ({
|
||||
drainageFieldId: String(field.drainageFieldId ?? field.drainageField?.id ?? ''),
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
exportName: field.exportName || field.name,
|
||||
required: field.required,
|
||||
description: String(field.description ?? ''),
|
||||
sortOrder: field.sortOrder ?? (index + 1) * 10,
|
||||
columnWidth: field.columnWidth ?? 18,
|
||||
imageWidth: field.imageWidth ?? 120,
|
||||
imageHeight: field.imageHeight ?? 80,
|
||||
defaultValue: String(field.defaultValue ?? ''),
|
||||
transform: String(field.transform ?? ''),
|
||||
status: 'active',
|
||||
}));
|
||||
}
|
||||
|
||||
export function ReportFieldMappingModal({ fields, libraryFields, reportType, onClose, onSave }: {
|
||||
fields: ChannelReportField[];
|
||||
libraryFields: DictionaryItem[];
|
||||
reportType: ReportType;
|
||||
onClose: () => void;
|
||||
onSave: (fields: DraftField[]) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => initialDraft(fields, reportType));
|
||||
const [search, setSearch] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const selectedIds = useMemo(() => new Set(draft.map((field) => field.drainageFieldId)), [draft]);
|
||||
const available = useMemo(() => libraryFields.filter((field) => !selectedIds.has(String(field.id)) && [field.name, field.code].some((value) => String(value ?? '').toLowerCase().includes(search.trim().toLowerCase()))), [libraryFields, search, selectedIds]);
|
||||
|
||||
function addField(field: DictionaryItem) {
|
||||
setDraft((current) => [...current, {
|
||||
drainageFieldId: String(field.id),
|
||||
code: String(field.code ?? field.id),
|
||||
name: String(field.name ?? field.code ?? '未命名字段'),
|
||||
fieldType: String(field.fieldType ?? 'string'),
|
||||
exportName: String(field.name ?? field.code ?? ''),
|
||||
required: Boolean(field.required),
|
||||
description: String(field.description ?? ''),
|
||||
sortOrder: (current.length + 1) * 10,
|
||||
columnWidth: 18,
|
||||
imageWidth: 120,
|
||||
imageHeight: 80,
|
||||
defaultValue: '',
|
||||
transform: '',
|
||||
status: 'active',
|
||||
}]);
|
||||
}
|
||||
|
||||
function patchField(index: number, patch: Partial<DraftField>) {
|
||||
setDraft((current) => current.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field));
|
||||
}
|
||||
|
||||
function move(index: number, offset: number) {
|
||||
setDraft((current) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }));
|
||||
});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (draft.some((field) => !field.exportName.trim())) { setError('导出表头名称不能为空'); return; }
|
||||
setSaving(true); setError('');
|
||||
try { await onSave(draft.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10 }))); onClose(); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '字段配置保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return <Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={saving} onClick={() => void save()}>{saving ? '保存中...' : '保存配置'}</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="channel-field-config-title"><h2>{reportType === 'signature' ? '配置签名报备字段' : '配置引流信息字段'}</h2><p>配置系统字段到通道Excel表头的映射、顺序和图片布局。</p></div>}
|
||||
>
|
||||
<div className="channel-field-config">
|
||||
<section className="channel-field-pool">
|
||||
<div className="channel-field-section-head"><h3>字段池</h3><Tag tone="neutral">{available.length} 个可选</Tag></div>
|
||||
<Input onChange={(event) => setSearch(event.target.value)} placeholder="搜索标准字段" prefix={<Search size={16} />} value={search} />
|
||||
<div className="channel-field-pool-list">
|
||||
{available.map((field) => <button key={String(field.id)} onClick={() => addField(field)} type="button"><span><strong>{String(field.name ?? field.code)}</strong><Tag tone="neutral">{fieldTypeLabel[String(field.fieldType)] ?? field.fieldType}</Tag></span><span>添加 <Plus size={15} /></span></button>)}
|
||||
{available.length === 0 ? <p>没有可添加的字段</p> : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="channel-selected-fields">
|
||||
<div className="channel-field-section-head"><div><h3>导出字段</h3><p>从上到下对应Excel从左到右的列顺序</p></div><Tag tone="info">{draft.length} 列</Tag></div>
|
||||
<div className="channel-export-preview">{draft.map((field, index) => <span key={field.drainageFieldId}>{String.fromCharCode(65 + index)} · {field.exportName || field.name}</span>)}</div>
|
||||
<div className="channel-selected-field-list">
|
||||
{draft.map((field, index) => <article key={field.drainageFieldId}>
|
||||
<div className="channel-selected-field-head"><span className="channel-selected-field-index">{index + 1}</span><strong>{field.name}</strong><Tag tone="neutral">{fieldTypeLabel[field.fieldType] ?? field.fieldType}</Tag><div className="channel-selected-field-order"><button disabled={index === 0} onClick={() => move(index, -1)} type="button"><ChevronUp size={16} /></button><button disabled={index === draft.length - 1} onClick={() => move(index, 1)} type="button"><ChevronDown size={16} /></button></div></div>
|
||||
<div className="channel-field-mapping-grid">
|
||||
<Input label="通道导出表头" onChange={(event) => patchField(index, { exportName: event.target.value })} value={field.exportName} />
|
||||
<Select label="是否必填" onChange={(event) => patchField(index, { required: event.target.value === 'true' })} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(field.required)} />
|
||||
<Input label="列宽" min="6" onChange={(event) => patchField(index, { columnWidth: Number(event.target.value) })} type="number" value={String(field.columnWidth)} />
|
||||
<Select label="文本转换" onChange={(event) => patchField(index, { transform: event.target.value })} options={transformOptions} value={field.transform} />
|
||||
{field.fieldType !== 'string' ? <><Input label="图片宽度(px)" min="24" onChange={(event) => patchField(index, { imageWidth: Number(event.target.value) })} type="number" value={String(field.imageWidth)} /><Input label="图片高度(px)" min="24" onChange={(event) => patchField(index, { imageHeight: Number(event.target.value) })} type="number" value={String(field.imageHeight)} /></> : <Input label="缺省值" onChange={(event) => patchField(index, { defaultValue: event.target.value })} value={field.defaultValue} />}
|
||||
</div>
|
||||
<Textarea label="通道说明" onChange={(event) => patchField(index, { description: event.target.value })} rows={2} value={field.description} />
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDraft((current) => current.filter((_, fieldIndex) => fieldIndex !== index))} size="sm" variant="danger">移除字段</Button>
|
||||
</article>)}
|
||||
{draft.length === 0 ? <div className="channel-report-empty">请从左侧添加报备字段</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
</Modal>;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FileSpreadsheet, Plus, Trash2 } 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';
|
||||
|
||||
type ReportType = 'signature' | 'drainage';
|
||||
type AnalyzeResult = {
|
||||
id: string;
|
||||
sheetName?: string;
|
||||
sheets?: string[];
|
||||
columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>;
|
||||
rows: Array<{ rowNumber: number; values: Record<string, string>; imageColumns: number[] }>;
|
||||
suggestedMappings: ReportImportMapping[];
|
||||
};
|
||||
|
||||
const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾空格', value: 'trim' }, { label: '仅保留数字', value: 'digits' }, { label: '转大写', value: 'uppercase' }, { label: '转小写', value: 'lowercase' }];
|
||||
|
||||
function coreTargets(reportType: ReportType) {
|
||||
return reportType === 'signature'
|
||||
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
|
||||
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '站点名称', value: 'siteName:site_name:string' }, { label: '引流地址', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
|
||||
}
|
||||
|
||||
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
|
||||
const [profiles, setProfiles] = useState<ReportImportProfile[]>([]);
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [reportType, setReportType] = useState<ReportType>('signature');
|
||||
const [profileId, setProfileId] = useState('');
|
||||
const [file, setFile] = useState<File>();
|
||||
const [headerRowCount, setHeaderRowCount] = useState(1);
|
||||
const [dataStartRow, setDataStartRow] = useState(2);
|
||||
const [analysis, setAnalysis] = useState<AnalyzeResult>();
|
||||
const [mappings, setMappings] = useState<ReportImportMapping[]>([]);
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [saveProfile, setSaveProfile] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listDrainageFields()])
|
||||
.then(([tenantItems, applicationItems, fieldItems]) => { setTenants(tenantItems); setApplications(applicationItems); setLibraryFields(fieldItems.filter((item) => item.status === 'active')); })
|
||||
.catch((failure: Error) => setError(failure.message || '基础数据加载失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listReportImportProfiles(reportType).then(setProfiles).catch(() => setProfiles([]));
|
||||
setProfileId(''); setAnalysis(undefined); setMappings([]);
|
||||
}, [reportType]);
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((item) => !tenantId || item.tenantId === tenantId), [applications, tenantId]);
|
||||
const mappingByColumn = useMemo(() => new Map(mappings.map((item) => [item.sourceColumnIndex, item])), [mappings]);
|
||||
|
||||
async function analyze() {
|
||||
if (!tenantId || !file) { setError('请选择企业和 XLSX 文件'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const result = await adminApi.analyzeReportMaterialImport(file, { tenantId, applicationId: applicationId || undefined, reportType, headerRowCount, dataStartRow, profileId: profileId || undefined }) as AnalyzeResult;
|
||||
setAnalysis(result); setMappings(result.suggestedMappings ?? []);
|
||||
const selectedProfile = profiles.find((item) => item.id === profileId);
|
||||
if (selectedProfile) setProfileName(selectedProfile.name);
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件解析失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function setTarget(column: AnalyzeResult['columns'][number], encoded: string) {
|
||||
setMappings((current) => {
|
||||
const remaining = current.filter((item) => item.sourceColumnIndex !== column.sourceColumnIndex);
|
||||
if (!encoded) return remaining;
|
||||
const [targetKind, targetFieldCode, fieldType] = encoded.split(':') as [ReportImportMapping['targetKind'], string, ReportImportMapping['fieldType']];
|
||||
return [...remaining, { sourceHeader: column.sourceHeader, sourceHeaderPath: column.sourceHeaderPath, sourceColumnIndex: column.sourceColumnIndex, targetKind, targetFieldCode, fieldType, required: false, sortOrder: (column.sourceColumnIndex + 1) * 10 }].sort((left, right) => left.sourceColumnIndex - right.sourceColumnIndex);
|
||||
});
|
||||
}
|
||||
|
||||
function patchMapping(columnIndex: number, patch: Partial<ReportImportMapping>) {
|
||||
setMappings((current) => current.map((item) => item.sourceColumnIndex === columnIndex ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
if (!analysis || mappings.length === 0) { setError('请至少配置一个导入字段映射'); return; }
|
||||
if (saveProfile && !profileName.trim()) { setError('请输入映射方案名称'); return; }
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
await adminApi.commitReportMaterialImport(analysis.id, {
|
||||
mappings,
|
||||
profile: saveProfile ? { id: profileId || undefined, name: profileName, reportType, tenantId, applicationId: applicationId || null, sheetName: analysis.sheetName, headerRowCount, dataStartRow, columns: mappings } : undefined,
|
||||
});
|
||||
onCompleted(); onClose();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '导入失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const targetOptions = [
|
||||
{ label: '不导入此列', value: '' },
|
||||
...coreTargets(reportType),
|
||||
...libraryFields.map((field) => ({ label: `报备字段 · ${String(field.name ?? field.code)}`, value: `dynamic:${String(field.code)}:${field.fieldType === 'string' ? 'string' : field.fieldType}` })),
|
||||
];
|
||||
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button>{analysis ? <Button disabled={busy} onClick={() => void commit()}>{busy ? '导入中...' : '确认导入待报备池'}</Button> : <Button disabled={busy || !file || !tenantId} onClick={() => void analyze()}>{busy ? '解析中...' : '解析文件并配置映射'}</Button>}</>} onClose={onClose} open size="xl" title={<div className="channel-field-config-title"><h2>导入签名与引流报备资料</h2><p>支持 WPS 另存的 XLSX 及单元格内嵌图片;导入只更新待报备资料,不自动生成通道任务。</p></div>}>
|
||||
<div className="report-import-basic-grid">
|
||||
<Select label="资料类型" onChange={(event) => setReportType(event.target.value as ReportType)} options={[{ label: '签名资料', value: 'signature' }, { label: '引流信息资料', value: 'drainage' }]} value={reportType} />
|
||||
<Select label="所属企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); }} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: `${item.name}(${item.code})`, value: item.id }))]} value={tenantId} />
|
||||
<Select label="企业应用(可选)" onChange={(event) => setApplicationId(event.target.value)} options={[{ label: '不限定应用', value: '' }, ...availableApplications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationId} />
|
||||
<Select label="复用导入映射(可选)" onChange={(event) => { const id = event.target.value; setProfileId(id); const profile = profiles.find((item) => item.id === id); if (profile) { setHeaderRowCount(profile.headerRowCount); setDataStartRow(profile.dataStartRow); } }} options={[{ label: '新建映射', value: '' }, ...profiles.map((item) => ({ label: item.name, value: item.id }))]} value={profileId} />
|
||||
<Input label="表头行数" max="5" min="1" onChange={(event) => setHeaderRowCount(Number(event.target.value))} type="number" value={String(headerRowCount)} />
|
||||
<Input label="数据起始行" min="2" onChange={(event) => setDataStartRow(Number(event.target.value))} type="number" value={String(dataStartRow)} />
|
||||
</div>
|
||||
<label className="report-import-file"><span><FileSpreadsheet size={22} /><strong>{file?.name ?? '选择 WPS 另存的 .xlsx 文件'}</strong></span><input accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => { setFile(event.target.files?.[0]); setAnalysis(undefined); }} type="file" /></label>
|
||||
{analysis ? <div className="report-import-mapping">
|
||||
<div className="channel-field-section-head"><div><h3>导入字段映射</h3><p>源列顺序不受限制,每一列明确映射到系统标准字段。</p></div><Tag tone="info">检测到 {analysis.columns.length} 列</Tag></div>
|
||||
<div className="report-import-mapping-table"><div className="report-import-mapping-head"><span>源列/图片</span><span>目标字段</span><span>数据类型</span><span>必填</span><span>转换</span></div>{analysis.columns.map((column) => {
|
||||
const mapping = mappingByColumn.get(column.sourceColumnIndex);
|
||||
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>
|
||||
{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}
|
||||
</Modal>;
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
} from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Building2,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
FilePenLine,
|
||||
Gauge,
|
||||
Hash,
|
||||
@@ -88,6 +90,7 @@ export function AdminLayout() {
|
||||
items: [
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: 'Gateway提交异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
@@ -125,6 +128,7 @@ export function AdminLayout() {
|
||||
title: '报备任务',
|
||||
icon: ClipboardList,
|
||||
items: [
|
||||
{ label: '待报备资料', to: '/admin/report-materials', icon: FileSpreadsheet },
|
||||
{ label: '报备任务', to: '/admin/report-tasks', icon: ClipboardList },
|
||||
{ label: '报备记录', to: '/admin/report-records', icon: ListChecks },
|
||||
],
|
||||
|
||||
@@ -15,6 +15,7 @@ import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlackl
|
||||
import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSignaturesPage';
|
||||
import { AdminEnterpriseTemplatesPage } from '@/apps/admin/AdminEnterpriseTemplatesPage';
|
||||
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
||||
import { AdminGatewaySubmitExceptionsPage } from '@/apps/admin/AdminGatewaySubmitExceptionsPage';
|
||||
import { AdminHome } from '@/apps/admin/AdminHome';
|
||||
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
|
||||
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
|
||||
@@ -24,6 +25,7 @@ import { AdminProfitReportsPage } from '@/apps/admin/AdminProfitReportsPage';
|
||||
import { AdminQualityReportsPage } from '@/apps/admin/AdminQualityReportsPage';
|
||||
import { AdminReportRecordsPage } from '@/apps/admin/AdminReportRecordsPage';
|
||||
import { AdminReportTasksPage } from '@/apps/admin/AdminReportTasksPage';
|
||||
import { AdminReportMaterialsPage } from '@/apps/admin/AdminReportMaterialsPage';
|
||||
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
|
||||
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
|
||||
@@ -83,6 +85,7 @@ export function AppRoutes() {
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminHome />} />
|
||||
<Route path="monitor" element={<AdminMonitorPage />} />
|
||||
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
|
||||
<Route path="analytics" element={<AdminAnalyticsPage />} />
|
||||
<Route path="customers" element={<AdminCustomersPage />} />
|
||||
<Route path="customers/new" element={<AdminCustomerFormPage />} />
|
||||
@@ -105,6 +108,7 @@ export function AppRoutes() {
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
|
||||
<Route path="report-records" element={<AdminReportRecordsPage />} />
|
||||
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
|
||||
<Route path="mms-task-progress" element={<PagePlaceholder />} />
|
||||
|
||||
@@ -2,6 +2,88 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gateway-exception-time {
|
||||
font-size: 1rem !important;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.table-cell-note {
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.35;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gateway-exception-command {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.gateway-exception-command pre {
|
||||
max-height: 22rem;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout p {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout--warning {
|
||||
border-color: #f5c66f;
|
||||
background: #fff8e8;
|
||||
color: #8a5700;
|
||||
}
|
||||
|
||||
.gateway-requeue-confirm .callout--danger {
|
||||
border-color: #f2a4a4;
|
||||
background: #fff0f0;
|
||||
color: #a12222;
|
||||
}
|
||||
|
||||
.gateway-requeue-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gateway-requeue-checkbox input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
html {
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@@ -6986,6 +7068,84 @@ h3 {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.channel-export-preview {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.channel-export-preview span {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channel-field-mapping-grid,
|
||||
.report-import-basic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-import-basic-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.report-import-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px dashed var(--primary);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--primary) 5%, var(--surface));
|
||||
}
|
||||
|
||||
.report-import-file span { display: flex; align-items: center; gap: 9px; }
|
||||
.report-import-file input { max-width: 310px; }
|
||||
.report-import-mapping { display: grid; gap: 12px; }
|
||||
.report-import-mapping-table { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.report-import-mapping-head,
|
||||
.report-import-mapping-row { display: grid; grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.5fr) 110px 100px 130px; gap: 10px; align-items: center; min-width: 820px; padding: 10px 12px; }
|
||||
.report-import-mapping-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.report-import-mapping-row { border-top: 1px solid var(--border); }
|
||||
.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 .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; }
|
||||
|
||||
.report-material-filter { display: grid; grid-template-columns: 220px minmax(280px, 1fr) auto; gap: 14px; align-items: end; }
|
||||
.report-material-pool { overflow: hidden; padding: 0; }
|
||||
.report-material-table-head,
|
||||
.report-material-row { display: grid; grid-template-columns: 28px minmax(260px, 1.5fr) minmax(180px, 1fr) 90px 160px; gap: 12px; align-items: center; padding: 12px 16px; }
|
||||
.report-material-table-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.report-material-row { border-top: 1px solid var(--border); cursor: pointer; }
|
||||
.report-material-row:hover { background: color-mix(in srgb, var(--primary) 3%, var(--surface)); }
|
||||
.report-material-row > span { display: grid; gap: 3px; }
|
||||
.report-material-row small, .report-material-row em { color: var(--text-muted); font-size: 12px; font-style: normal; }
|
||||
.report-material-batches { display: grid; gap: 0; }
|
||||
.report-material-batches article { display: flex; justify-content: space-between; gap: 24px; padding: 14px 0; border-top: 1px solid var(--border); }
|
||||
.report-material-batches article > div { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
|
||||
.report-material-batches article small { width: 100%; color: var(--text-muted); }
|
||||
.report-material-batches article a { display: inline-flex; align-items: center; gap: 5px; color: var(--primary); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.report-material-table-head, .report-material-row { grid-template-columns: 28px minmax(220px, 1.5fr) minmax(160px, 1fr); }
|
||||
.report-material-table-head > :nth-child(n+4), .report-material-row > :nth-child(n+4) { display: none; }
|
||||
}
|
||||
|
||||
.channel-field-pool {
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user