feat: add reconciliation and quality reporting
This commit is contained in:
+79
-3
@@ -581,7 +581,16 @@ export type ApplicationReportField = {
|
||||
required: boolean;
|
||||
description?: string | null;
|
||||
reportTypes: string[];
|
||||
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both' }>;
|
||||
commonReportTypes?: Array<'signature' | 'drainage'>;
|
||||
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>;
|
||||
};
|
||||
|
||||
export type CommonReportField = DictionaryItem & {
|
||||
drainageFieldId: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
required: boolean;
|
||||
sortOrder: number;
|
||||
drainageField: DictionaryItem & { code?: string; name?: string; fieldType?: string; description?: string | null };
|
||||
};
|
||||
|
||||
export type ReportTask = DictionaryItem & {
|
||||
@@ -696,6 +705,59 @@ export type PagedResponse<T> = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type DailyReconciliationReport = {
|
||||
id: string;
|
||||
reportDate: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationId: string;
|
||||
applicationName: string;
|
||||
sentUnits: number;
|
||||
successUnits: number;
|
||||
generatedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DailyProfitReport = {
|
||||
id: string;
|
||||
reportDate: string;
|
||||
dimensionType: 'application' | 'channel';
|
||||
dimensionId: string;
|
||||
dimensionName: string;
|
||||
tenantId?: string | null;
|
||||
tenantName?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId?: string | null;
|
||||
sentUnits: number;
|
||||
successUnits: number;
|
||||
revenueCents: number;
|
||||
costCents: number;
|
||||
profitCents: number;
|
||||
profitRateBps: number;
|
||||
generatedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type DailyQualityReport = {
|
||||
id: string;
|
||||
reportDate: string;
|
||||
dimensionType: 'application' | 'channel' | 'signature' | 'drainage';
|
||||
dimensionId: string;
|
||||
dimensionName: string;
|
||||
tenantId?: string | null;
|
||||
tenantName?: string | null;
|
||||
applicationId?: string | null;
|
||||
channelId?: string | null;
|
||||
signatureId?: string | null;
|
||||
drainageInfoId?: string | null;
|
||||
sentUnits: number;
|
||||
successUnits: number;
|
||||
successRateBps: number;
|
||||
avgArrivalMs?: number | null;
|
||||
generatedAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CursorPage<T> = {
|
||||
items: T[];
|
||||
pageSize: number;
|
||||
@@ -979,9 +1041,17 @@ export const adminApi = {
|
||||
request<ApplicationConnectionsResponse>(`/admin/enterprise-applications/${applicationId}/connections`),
|
||||
listApplicationReportFields: (applicationId: string, reportType?: 'signature' | 'drainage') =>
|
||||
request<ApplicationReportField[]>(withQuery(`/admin/enterprise-applications/${applicationId}/report-fields`, { reportType })),
|
||||
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
|
||||
request<ApplicationReportField[]>(withQuery('/admin/report-fields/common', { reportType })),
|
||||
getApplicationCmppParams: (applicationId: string) =>
|
||||
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
|
||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
|
||||
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)),
|
||||
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
@@ -1150,6 +1220,10 @@ export const adminApi = {
|
||||
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
||||
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
|
||||
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
|
||||
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
@@ -1215,8 +1289,10 @@ export const clientApi = {
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(`/client/applications/${applicationId}/report-fields`, { tenantId }),
|
||||
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
|
||||
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
type DrainageField = DictionaryItem & {
|
||||
code?: string;
|
||||
@@ -10,6 +10,7 @@ type DrainageField = DictionaryItem & {
|
||||
required?: boolean;
|
||||
description?: string | null;
|
||||
usageCount?: number;
|
||||
commonUsageCount?: number;
|
||||
};
|
||||
|
||||
type ReportFieldType = 'string' | 'image' | 'file';
|
||||
@@ -25,6 +26,7 @@ const typeLabels: Record<string, string> = { string: '字符串', image: '图片
|
||||
|
||||
export function AdminDrainageFieldsPage() {
|
||||
const [fields, setFields] = useState<DrainageField[]>([]);
|
||||
const [commonFields, setCommonFields] = useState<CommonReportField[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [appliedKeyword, setAppliedKeyword] = useState('');
|
||||
const [type, setType] = useState('all');
|
||||
@@ -36,12 +38,18 @@ export function AdminDrainageFieldsPage() {
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<DrainageField | null>(null);
|
||||
const [configuringCommon, setConfiguringCommon] = useState(false);
|
||||
const [commonFieldId, setCommonFieldId] = useState('');
|
||||
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
||||
const [commonRequired, setCommonRequired] = useState(false);
|
||||
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
|
||||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||||
|
||||
function loadData() {
|
||||
adminApi.listDrainageFields()
|
||||
.then((items) => {
|
||||
Promise.all([adminApi.listDrainageFields(), adminApi.listCommonReportFields()])
|
||||
.then(([items, commonItems]) => {
|
||||
setFields(items as DrainageField[]);
|
||||
setCommonFields(commonItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段加载失败'));
|
||||
@@ -74,7 +82,7 @@ export function AdminDrainageFieldsPage() {
|
||||
}
|
||||
|
||||
function deleteField() {
|
||||
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0) return;
|
||||
if (!deleteTarget || (deleteTarget.usageCount ?? 0) > 0 || (deleteTarget.commonUsageCount ?? 0) > 0) return;
|
||||
adminApi.deleteDrainageField(deleteTarget.id)
|
||||
.then(() => {
|
||||
setDeleteTarget(null);
|
||||
@@ -83,6 +91,29 @@ export function AdminDrainageFieldsPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段删除失败'));
|
||||
}
|
||||
|
||||
function createCommonField() {
|
||||
if (!commonFieldId) return;
|
||||
adminApi.createCommonReportField({ drainageFieldId: commonFieldId, reportType: commonReportType, required: commonRequired })
|
||||
.then(() => {
|
||||
setCommonFieldId('');
|
||||
setCommonReportType('signature');
|
||||
setCommonRequired(false);
|
||||
setConfiguringCommon(false);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通用字段配置失败'));
|
||||
}
|
||||
|
||||
function deleteCommonField() {
|
||||
if (!commonDeleteTarget) return;
|
||||
adminApi.deleteCommonReportField(commonDeleteTarget.id)
|
||||
.then(() => {
|
||||
setCommonDeleteTarget(null);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
|
||||
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
|
||||
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
|
||||
@@ -90,7 +121,17 @@ export function AdminDrainageFieldsPage() {
|
||||
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
|
||||
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
|
||||
{ key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => <Tag tone={(record.usageCount ?? 0) > 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
{ key: 'commonUsageCount', title: '通用配置数', width: '130px', render: (record) => <Tag tone={(record.commonUsageCount ?? 0) > 0 ? 'info' : 'neutral'}>{record.commonUsageCount ?? 0}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0 || (record.commonUsageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
], []);
|
||||
|
||||
const commonColumns = useMemo<Array<TableColumn<CommonReportField>>>(() => [
|
||||
{ key: 'name', title: '字段名称', render: (record) => <strong>{String(record.drainageField.name ?? record.drainageField.code ?? '-')}</strong> },
|
||||
{ key: 'code', title: '字段代码', width: '170px', render: (record) => String(record.drainageField.code ?? '-') },
|
||||
{ key: 'reportType', title: '资料用途', width: '180px', render: (record) => <Tag tone={record.reportType === 'signature' ? 'info' : 'warning'}>{record.reportType === 'signature' ? '签名报备资料' : '引流信息报备资料'}</Tag> },
|
||||
{ key: 'fieldType', title: '字段类型', width: '130px', render: (record) => typeLabels[String(record.drainageField.fieldType ?? '')] ?? String(record.drainageField.fieldType ?? '-') },
|
||||
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'neutral'}>{record.required ? '必填' : '选填'}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setCommonDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
], []);
|
||||
|
||||
return (
|
||||
@@ -114,9 +155,34 @@ export function AdminDrainageFieldsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<h2>通用字段配置</h2>
|
||||
<p>通用字段会与企业应用目标通道配置的字段合并,分别用于签名报备资料和引流信息报备资料。</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />} onClick={() => setConfiguringCommon(true)}>配置通用字段</Button>
|
||||
</div>
|
||||
<Table columns={commonColumns} data={commonFields} emptyText="暂无通用字段配置" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<div className="page-heading"><div><h2>字段库</h2><p>字段定义被通道或通用配置引用后,需要先删除对应配置才能删除字段。</p></div></div>
|
||||
<Table columns={columns} data={filteredFields} emptyText="暂无字段" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<><Button onClick={() => setConfiguringCommon(false)} variant="ghost">取消</Button><Button disabled={!commonFieldId} onClick={createCommonField}>保存</Button></>}
|
||||
onClose={() => setConfiguringCommon(false)}
|
||||
open={configuringCommon}
|
||||
title="配置通用字段"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Select label="报备字段" onChange={(event) => setCommonFieldId(event.target.value)} options={[{ label: '请选择字段', value: '' }, ...fields.filter((field) => field.status !== 'deleted').map((field) => ({ label: `${field.name}(${field.code})`, value: field.id }))]} value={commonFieldId} />
|
||||
<Select label="资料用途" onChange={(event) => setCommonReportType(event.target.value as 'signature' | 'drainage')} options={[{ label: '签名报备资料', value: 'signature' }, { label: '引流信息报备资料', value: 'drainage' }]} value={commonReportType} />
|
||||
<Select label="是否必填" onChange={(event) => setCommonRequired(event.target.value === 'true')} options={[{ label: '选填', value: 'false' }, { label: '必填', value: 'true' }]} value={String(commonRequired)} />
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
@@ -156,6 +222,16 @@ export function AdminDrainageFieldsPage() {
|
||||
<p>确认删除“{deleteTarget.name}”吗?未被通道使用的字段将从数据库中永久删除。</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
{commonDeleteTarget ? (
|
||||
<Modal
|
||||
footer={<><Button onClick={() => setCommonDeleteTarget(null)} variant="ghost">取消</Button><Button onClick={deleteCommonField} variant="danger">确认删除</Button></>}
|
||||
onClose={() => setCommonDeleteTarget(null)}
|
||||
open
|
||||
title="删除通用字段配置"
|
||||
>
|
||||
<p>确认删除“{String(commonDeleteTarget.drainageField.name ?? commonDeleteTarget.drainageField.code)}”的{commonDeleteTarget.reportType === 'signature' ? '签名报备' : '引流信息报备'}通用配置吗?字段库定义和历史报备资料不会删除。</p>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,28 +34,11 @@ type DrainageInfo = {
|
||||
type UploadedFileRef = FileRef;
|
||||
type ReportValues = Record<string, string | UploadedFileRef | null>;
|
||||
|
||||
type SignatureProfile = {
|
||||
basis: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
legalPersonName: string;
|
||||
legalPersonIdCard: string;
|
||||
responsibleName: string;
|
||||
responsiblePhone: string;
|
||||
responsibleIdCard: string;
|
||||
credentialFile?: UploadedFileRef | null;
|
||||
legalFrontFile?: UploadedFileRef | null;
|
||||
legalBackFile?: UploadedFileRef | null;
|
||||
responsibleFrontFile?: UploadedFileRef | null;
|
||||
responsibleBackFile?: UploadedFileRef | null;
|
||||
};
|
||||
|
||||
type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
profile: SignatureProfile;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
@@ -114,7 +97,7 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
|
||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||
},
|
||||
signatureProfile: normalizeSignatureProfile(profile, signature),
|
||||
signatureProfile: profile,
|
||||
signatureReportValues: normalizeReportValues(payload.signatureReportValues),
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
@@ -142,7 +125,7 @@ function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: SignatureProfile, signatureReportValues?: ReportValues) {
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[], signatureProfile?: Record<string, unknown>, signatureReportValues?: ReportValues) {
|
||||
return { carrierStatus, links, signatureProfile, signatureReportValues };
|
||||
}
|
||||
|
||||
@@ -164,43 +147,6 @@ function normalizeUploadedFile(value: unknown): UploadedFileRef | null {
|
||||
return fileObjectId || fileName ? { contentType, fileObjectId, fileName } : null;
|
||||
}
|
||||
|
||||
function emptySignatureProfile(signature?: ClientSmsSignature): SignatureProfile {
|
||||
return {
|
||||
basis: '',
|
||||
companyName: signature?.tenant?.name ?? '',
|
||||
creditCode: '',
|
||||
legalPersonName: '',
|
||||
legalPersonIdCard: '',
|
||||
responsibleName: '',
|
||||
responsiblePhone: '',
|
||||
responsibleIdCard: '',
|
||||
credentialFile: null,
|
||||
legalFrontFile: null,
|
||||
legalBackFile: null,
|
||||
responsibleFrontFile: null,
|
||||
responsibleBackFile: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSignatureProfile(value: Record<string, unknown>, signature?: ClientSmsSignature): SignatureProfile {
|
||||
return {
|
||||
...emptySignatureProfile(signature),
|
||||
basis: String(value.basis ?? ''),
|
||||
companyName: String(value.companyName ?? signature?.tenant?.name ?? ''),
|
||||
creditCode: String(value.creditCode ?? ''),
|
||||
legalPersonName: String(value.legalPersonName ?? ''),
|
||||
legalPersonIdCard: String(value.legalPersonIdCard ?? ''),
|
||||
responsibleName: String(value.responsibleName ?? ''),
|
||||
responsiblePhone: String(value.responsiblePhone ?? ''),
|
||||
responsibleIdCard: String(value.responsibleIdCard ?? ''),
|
||||
credentialFile: normalizeUploadedFile(value.credentialFile),
|
||||
legalFrontFile: normalizeUploadedFile(value.legalFrontFile),
|
||||
legalBackFile: normalizeUploadedFile(value.legalBackFile),
|
||||
responsibleFrontFile: normalizeUploadedFile(value.responsibleFrontFile),
|
||||
responsibleBackFile: normalizeUploadedFile(value.responsibleBackFile),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||
}
|
||||
@@ -270,6 +216,7 @@ function DynamicReportFields({ fields, onChange, title, values }: { fields: Appl
|
||||
const channels = Array.from(new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel])).values());
|
||||
const groups = Array.from(new Map(channels.map((channel) => [channel.groupId, channel.groupName])).entries());
|
||||
const requiredCount = fields.filter((field) => field.required).length;
|
||||
const commonCount = fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).length;
|
||||
return (
|
||||
<section>
|
||||
<div className="report-requirement-heading">
|
||||
@@ -278,16 +225,19 @@ function DynamicReportFields({ fields, onChange, title, values }: { fields: Appl
|
||||
</div>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>当前要求由 {groups.length} 个通道组、{channels.length} 个通道合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||
<span>当前要求由 {commonCount} 项通用字段及 {groups.length} 个通道组、{channels.length} 个通道配置合并生成,共 {fields.length} 项,其中 {requiredCount} 项必填。保存时会固化本次要求快照。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
{fields.map((field) => {
|
||||
const channelHint = field.channels.map((channel) => channel.name).join('、');
|
||||
const requiredChannels = field.required ? field.channels.filter((channel) => channel.required).map((channel) => channel.name).join('、') : '';
|
||||
const isCommon = (field.commonReportTypes?.length ?? 0) > 0;
|
||||
const label = `${field.required ? '* ' : ''}${field.name}`;
|
||||
const hint = field.required
|
||||
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||
: `适用通道:${channelHint}`;
|
||||
const hint = isCommon
|
||||
? `平台通用${field.required ? '必填' : '选填'}资料${channelHint ? `,适用于:${channelHint}` : ''}`
|
||||
: field.required
|
||||
? `由 ${requiredChannels} 要求,至少一个通道配置为必填`
|
||||
: `适用通道:${channelHint}`;
|
||||
return field.fieldType === 'file' || field.fieldType === 'image' ? (
|
||||
<div key={field.id}>
|
||||
<SignatureUploadBox compact file={typeof values[field.code] === 'object' ? values[field.code] as UploadedFileRef : null} label={label} onUploaded={(file) => onChange(field.code, file)} />
|
||||
@@ -303,7 +253,13 @@ function DynamicReportFields({ fields, onChange, title, values }: { fields: Appl
|
||||
</div>
|
||||
<Modal footer={<Button onClick={() => setExplanationOpen(false)}>我知道了</Button>} onClose={() => setExplanationOpen(false)} open={explanationOpen} size="xl" title="这些资料从哪里来?">
|
||||
<div className="report-requirement-explanation">
|
||||
<p>资料要求按“企业应用 → 通道组 → 通道 → 通道报备字段”实时计算;相同字段只填写一次,但会按来源通道分别用于报备。</p>
|
||||
<p>资料要求由“报备字段库通用配置”和“企业应用 → 通道组 → 通道 → 通道报备字段”实时合并;相同字段只填写一次,但会按目标通道分别用于报备。</p>
|
||||
{commonCount > 0 ? (
|
||||
<section className="report-source-group">
|
||||
<h4>平台通用字段</h4>
|
||||
<ul>{fields.filter((field) => (field.commonReportTypes?.length ?? 0) > 0).map((field) => <li key={field.id}>{field.name} · {field.commonReportTypes?.includes('signature') ? '签名报备' : '引流信息报备'} · {field.required ? '必填' : '选填'}</li>)}</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{groups.map(([groupId, groupName]) => (
|
||||
<section className="report-source-group" key={groupId}>
|
||||
<h4>通道组:{groupName}</h4>
|
||||
@@ -344,7 +300,6 @@ function SignatureFormModal({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
name: item?.name ?? '',
|
||||
purpose: item?.purpose ?? '',
|
||||
profile: payload?.signatureProfile ?? emptySignatureProfile(item),
|
||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||
@@ -354,21 +309,16 @@ function SignatureFormModal({
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.applicationId) {
|
||||
setReportFields([]);
|
||||
return;
|
||||
}
|
||||
adminApi.listApplicationReportFields(form.applicationId, 'signature').then(setReportFields).catch(() => setReportFields([]));
|
||||
const request = form.applicationId
|
||||
? adminApi.listApplicationReportFields(form.applicationId, 'signature')
|
||||
: adminApi.listCommonApplicationReportFields('signature');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [form.applicationId]);
|
||||
|
||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function updateProfile<Key extends keyof SignatureProfile>(key: Key, value: SignatureProfile[Key]) {
|
||||
setForm((current) => ({ ...current, profile: { ...current.profile, [key]: value } }));
|
||||
}
|
||||
|
||||
function updateReportValue(code: string, value: string | UploadedFileRef | null) {
|
||||
setForm((current) => ({ ...current, reportValues: { ...current.reportValues, [code]: value } }));
|
||||
}
|
||||
@@ -419,50 +369,11 @@ function SignatureFormModal({
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="* 签名依据"
|
||||
onChange={(event) => updateProfile('basis', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择签名依据', value: '' },
|
||||
{ label: '企事业单位证明', value: 'company' },
|
||||
{ label: '商标注册证', value: 'trademark' },
|
||||
{ label: '授权委托书', value: 'authorization' },
|
||||
]}
|
||||
value={form.profile.basis}
|
||||
/>
|
||||
<Input label="* 短信签名" onChange={(event) => update('name', event.target.value)} placeholder="请输入短信签名,如【XXXX公司】" required value={form.name} />
|
||||
</div>
|
||||
<SignatureUploadBox
|
||||
file={form.profile.credentialFile}
|
||||
label="* 资质凭证"
|
||||
onUploaded={(file) => updateProfile('credentialFile', file)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>公司信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 公司名称" onChange={(event) => updateProfile('companyName', event.target.value)} placeholder="请输入公司名称" value={form.profile.companyName} />
|
||||
<Input label="* 统一社会信用代码" onChange={(event) => updateProfile('creditCode', event.target.value)} placeholder="请输入统一社会信用代码" value={form.profile.creditCode} />
|
||||
<Input label="* 法人姓名" onChange={(event) => updateProfile('legalPersonName', event.target.value)} placeholder="请输入法人姓名" value={form.profile.legalPersonName} />
|
||||
<Input label="法人身份证号" onChange={(event) => updateProfile('legalPersonIdCard', event.target.value)} placeholder="请输入法人身份证号" value={form.profile.legalPersonIdCard} />
|
||||
<SignatureUploadBox compact file={form.profile.legalFrontFile} label="法人身份证照片-人像面" onUploaded={(file) => updateProfile('legalFrontFile', file)} />
|
||||
<SignatureUploadBox compact file={form.profile.legalBackFile} label="法人身份证照片-国徽面" onUploaded={(file) => updateProfile('legalBackFile', file)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>责任人信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 责任人姓名" onChange={(event) => updateProfile('responsibleName', event.target.value)} placeholder="请输入责任人姓名" value={form.profile.responsibleName} />
|
||||
<Input label="* 责任人手机号" onChange={(event) => updateProfile('responsiblePhone', event.target.value)} placeholder="请输入责任人手机号" value={form.profile.responsiblePhone} />
|
||||
<Input className="signature-form-grid__wide" label="* 责任人身份证号" onChange={(event) => updateProfile('responsibleIdCard', event.target.value)} placeholder="请输入责任人身份证号" value={form.profile.responsibleIdCard} />
|
||||
<SignatureUploadBox compact file={form.profile.responsibleFrontFile} label="责任人身份证照片-人像面" onUploaded={(file) => updateProfile('responsibleFrontFile', file)} />
|
||||
<SignatureUploadBox compact file={form.profile.responsibleBackFile} label="责任人身份证照片-国徽面" onUploaded={(file) => updateProfile('responsibleBackFile', file)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道签名报备资料" values={form.reportValues} />
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="签名报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -494,8 +405,10 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!applicationId) return;
|
||||
adminApi.listApplicationReportFields(applicationId, 'drainage').then(setReportFields).catch(() => setReportFields([]));
|
||||
const request = applicationId
|
||||
? adminApi.listApplicationReportFields(applicationId, 'drainage')
|
||||
: adminApi.listCommonApplicationReportFields('drainage');
|
||||
request.then(setReportFields).catch(() => setReportFields([]));
|
||||
}, [applicationId]);
|
||||
|
||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||
@@ -541,7 +454,7 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
|
||||
<Input label="* 引流信息" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入引流信息" value={form.siteName} />
|
||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="应用通道引流信息报备资料" values={form.reportValues} />
|
||||
<DynamicReportFields fields={reportFields} onChange={updateReportValue} title="引流信息报备资料(通用 + 通道)" values={form.reportValues} />
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -720,12 +633,12 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
|
||||
async function saveSignature(state: SignatureFormState) {
|
||||
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
|
||||
const existingPayload = existing ? readDrainagePayload(existing) : { links: [] };
|
||||
const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined };
|
||||
const drainageInfo = buildDrainagePayload({
|
||||
mobile: state.mobile,
|
||||
unicom: state.unicom,
|
||||
telecom: state.telecom,
|
||||
}, existingPayload.links, state.profile, state.reportValues);
|
||||
}, existingPayload.links, existingPayload.signatureProfile, state.reportValues);
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function AdminProfitReportsPage() {
|
||||
const [rows, setRows] = useState<DailyProfitReport[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
|
||||
const [dimensionType, setDimensionType] = useState<'application' | 'channel'>('application');
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), 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]);
|
||||
|
||||
async function loadData() {
|
||||
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 });
|
||||
setRows(response.items);
|
||||
setTotal(response.total);
|
||||
} catch (failure) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(failure instanceof Error ? failure.message : '利润报表加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['报表对账', '利润报表']} /><h1>利润报表</h1></div>
|
||||
<Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(270px, 1.3fr) minmax(180px, .8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={9}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={9}>正在加载真实利润数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={9}>暂无已生成的利润报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>¥{formatCents(row.revenueCents)}</td><td>¥{formatCents(row.costCents)}</td><td style={{ color: row.profitCents < 0 ? 'var(--danger)' : undefined }}>¥{formatCents(row.profitCents)}</td><td>{(row.profitRateBps / 100).toFixed(2)}%</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} previousDisabled={page <= 1} nextDisabled={page >= totalPages} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultDateRange(): DateRangeValue {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 29);
|
||||
return { start: localDate(start), end: localDate(end) };
|
||||
}
|
||||
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tabs, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type QualityDimension = 'application' | 'channel' | 'signature' | 'drainage';
|
||||
const pageSize = 20;
|
||||
const dimensionLabels: Record<QualityDimension, string> = {
|
||||
application: '企业应用', channel: '通道', signature: '签名', drainage: '引流信息',
|
||||
};
|
||||
|
||||
export function AdminQualityReportsPage() {
|
||||
const [dimension, setDimension] = useState<QualityDimension>('application');
|
||||
const [rows, setRows] = useState<DailyQualityReport[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), 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]);
|
||||
|
||||
async function loadData() {
|
||||
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,
|
||||
});
|
||||
setRows(response.items);
|
||||
setTotal(response.total);
|
||||
} catch (failure) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(failure instanceof Error ? failure.message : '发送质量报表加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
function changeDimension(value: string) {
|
||||
setDimension(value as QualityDimension);
|
||||
setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1);
|
||||
}
|
||||
|
||||
const reportPanel = (
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<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>
|
||||
</div>
|
||||
<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></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} previousDisabled={page <= 1} nextDisabled={page >= totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1>发送质量报表</h1></div><Tag tone="info">剔除最慢 5% · 每日重算 T-4~T-1</Tag></div>
|
||||
<Tabs value={dimension} onChange={changeDimension} items={(Object.keys(dimensionLabels) as QualityDimension[]).map((value) => ({ value, label: dimensionLabels[value], content: reportPanel }))} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds?: number | null) {
|
||||
if (milliseconds === null || milliseconds === undefined) return '-';
|
||||
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
|
||||
if (milliseconds < 60_000) return `${(milliseconds / 1000).toFixed(2)} 秒`;
|
||||
return `${(milliseconds / 60_000).toFixed(2)} 分钟`;
|
||||
}
|
||||
|
||||
function defaultDateRange(): DateRangeValue {
|
||||
const end = new Date(); end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end); start.setDate(start.getDate() - 29);
|
||||
return { start: localDate(start), end: localDate(end) };
|
||||
}
|
||||
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export function AdminReconciliationReportsPage() {
|
||||
const [rows, setRows] = useState<DailyReconciliationReport[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()])
|
||||
.then(([nextTenants, nextApplications]) => { setTenants(nextTenants); setApplications(nextApplications); })
|
||||
.catch(() => { setTenants([]); setApplications([]); });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadData(); }, [page, dateRange.start, dateRange.end, tenantId, applicationId]);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await adminApi.listReconciliationReports({
|
||||
dateFrom: dateRange.start,
|
||||
dateTo: dateRange.end,
|
||||
tenantId: tenantId || undefined,
|
||||
applicationId: applicationId || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setRows(response.items);
|
||||
setTotal(response.total);
|
||||
} catch (failure) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(failure instanceof Error ? failure.message : '对账单加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(
|
||||
() => applications.filter((application) => !tenantId || application.tenantId === tenantId),
|
||||
[applications, tenantId],
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['报表对账', '对账单']} /><h1>对账单</h1></div>
|
||||
<Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>企业</th><th>企业应用</th><th>日发送条数</th><th>成功条数</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={6}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={6}>正在加载真实对账数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={6}>暂无已生成的对账单</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td>{row.tenantName}</td><td>{row.applicationName}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} previousDisabled={page <= 1} nextDisabled={page >= totalPages} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultDateRange(): DateRangeValue {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 29);
|
||||
return { start: localDate(start), end: localDate(end) };
|
||||
}
|
||||
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -71,8 +71,10 @@ function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: Cli
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!signature.applicationId) return;
|
||||
clientApi.listApplicationReportFields(signature.applicationId).then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败'));
|
||||
const request = signature.applicationId
|
||||
? clientApi.listApplicationReportFields(signature.applicationId, 'drainage')
|
||||
: clientApi.listCommonApplicationReportFields('drainage');
|
||||
request.then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败'));
|
||||
}, [signature.applicationId]);
|
||||
|
||||
async function upload(field: ApplicationReportField, file?: File) {
|
||||
@@ -101,7 +103,7 @@ function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: Cli
|
||||
<Input label="引流信息" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
|
||||
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
|
||||
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
|
||||
<section className="surface" style={{ padding: 16 }}><h3>应用通道引流信息报备资料</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
||||
<section className="surface" style={{ padding: 16 }}><h3>引流信息报备资料(通用 + 通道)</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
||||
{fields.map((field) => field.fieldType === 'string' ? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(values[field.code] ?? '')} /> : <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(values[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void upload(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
|
||||
</div></section>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -119,7 +121,9 @@ export function ClientSignaturesPage() {
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [signatureFields, setSignatureFields] = useState<ApplicationReportField[]>([]);
|
||||
const [signatureValues, setSignatureValues] = useState<Record<string, unknown>>({});
|
||||
const [signatureUploadingCode, setSignatureUploadingCode] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignature; item?: ClientDrainageInfo }>();
|
||||
|
||||
@@ -139,6 +143,14 @@ export function ClientSignaturesPage() {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return;
|
||||
const request = applicationId
|
||||
? clientApi.listApplicationReportFields(applicationId, 'signature')
|
||||
: clientApi.listCommonApplicationReportFields('signature');
|
||||
request.then(setSignatureFields).catch((reason: Error) => setError(reason.message || '签名报备字段加载失败'));
|
||||
}, [applicationId, modalOpen]);
|
||||
|
||||
const filteredSignatures = useMemo(() => signatures.filter((item) => (
|
||||
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
|
||||
)), [keyword, signatures]);
|
||||
@@ -153,30 +165,33 @@ export function ClientSignaturesPage() {
|
||||
|
||||
async function createSignature() {
|
||||
try {
|
||||
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose });
|
||||
if (file) {
|
||||
const fileObject = await clientApi.uploadFileObject(file, {
|
||||
purpose: 'signature_material',
|
||||
prefix: `signature-materials/${signature.id}`,
|
||||
});
|
||||
await clientApi.createSignatureMaterial(signature.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
materialType: file.type.startsWith('image/') ? 'image' : 'file',
|
||||
title: file.name,
|
||||
});
|
||||
}
|
||||
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose, drainageInfo: { signatureReportValues: signatureValues } });
|
||||
await clientApi.submitSignature(signature.id);
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setPurpose('');
|
||||
setFile(null);
|
||||
setSignatureFields([]);
|
||||
setSignatureValues({});
|
||||
loadData();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '签名提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadSignatureField(field: ApplicationReportField, file?: File) {
|
||||
if (!file) return;
|
||||
setSignatureUploadingCode(field.code);
|
||||
try {
|
||||
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'signature_report_material', prefix: 'signature-materials' });
|
||||
setSignatureValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '签名报备资料上传失败');
|
||||
} finally {
|
||||
setSignatureUploadingCode('');
|
||||
}
|
||||
}
|
||||
|
||||
function disableSignature(id: string) {
|
||||
clientApi.changeSignatureStatus(id, 'disabled')
|
||||
.then(loadData)
|
||||
@@ -260,7 +275,7 @@ export function ClientSignaturesPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!name} onClick={createSignature}>提交审核</Button>
|
||||
<Button disabled={!name || signatureFields.some((field) => field.required && !signatureValues[field.code]) || Boolean(signatureUploadingCode)} onClick={createSignature}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
@@ -277,16 +292,11 @@ export function ClientSignaturesPage() {
|
||||
/>
|
||||
<Input label="短信签名" onChange={(event) => setName(event.target.value)} placeholder="请输入短信签名,如【某某科技】" value={name} />
|
||||
<Input label="用途" onChange={(event) => setPurpose(event.target.value)} placeholder="请输入签名用途" value={purpose} />
|
||||
<label className="signature-upload">
|
||||
<Upload size={36} />
|
||||
<strong>{file ? file.name : '上传资质图片或文件'}</strong>
|
||||
<small>支持图片、PDF、Word 等真实材料文件</small>
|
||||
<input
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<section className="surface" style={{ padding: 16 }}><h3>签名报备资料</h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
|
||||
{signatureFields.map((field) => field.fieldType === 'string'
|
||||
? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setSignatureValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(signatureValues[field.code] ?? '')} />
|
||||
: <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(signatureValues[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{signatureUploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(signatureValues[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void uploadSignatureField(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
|
||||
</div></section>
|
||||
</div>
|
||||
</Modal>
|
||||
{drainageModal ? <ClientDrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
|
||||
|
||||
@@ -149,6 +149,15 @@ export function AdminLayout() {
|
||||
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '报表对账',
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: '对账单', to: '/admin/reconciliation-reports', icon: ReceiptText },
|
||||
{ label: '利润报表', to: '/admin/profit-reports', icon: TrendingUp },
|
||||
{ label: '发送质量报表', to: '/admin/quality-reports', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '安全控制',
|
||||
icon: Shield,
|
||||
|
||||
@@ -19,6 +19,9 @@ import { AdminHome } from '@/apps/admin/AdminHome';
|
||||
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
|
||||
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
|
||||
import { AdminRechargeRecordsPage } from '@/apps/admin/AdminRechargeRecordsPage';
|
||||
import { AdminReconciliationReportsPage } from '@/apps/admin/AdminReconciliationReportsPage';
|
||||
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 { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
@@ -111,6 +114,9 @@ export function AppRoutes() {
|
||||
<Route path="downstream-deliveries" element={<AdminDownstreamDeliveriesPage />} />
|
||||
<Route path="downstream-recovery-statuses" element={<AdminDownstreamRecoveryStatusesPage />} />
|
||||
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
|
||||
<Route path="reconciliation-reports" element={<AdminReconciliationReportsPage />} />
|
||||
<Route path="profit-reports" element={<AdminProfitReportsPage />} />
|
||||
<Route path="quality-reports" element={<AdminQualityReportsPage />} />
|
||||
<Route path="channels" element={<AdminChannelsPage />} />
|
||||
<Route path="channels/:channelId/reports" element={<AdminChannelReportPage />} />
|
||||
<Route path="channel-groups" element={<AdminChannelGroupsPage />} />
|
||||
|
||||
Reference in New Issue
Block a user