feat: add reconciliation and quality reporting

This commit is contained in:
hectorzhao
2026-07-15 14:22:50 +08:00
parent 16311546af
commit 8c3336600e
32 changed files with 1730 additions and 200 deletions
+79 -3
View File
@@ -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) =>
+81 -5
View File
@@ -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>
);
}
+29 -116
View File
@@ -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, {
+94
View File
@@ -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-4T-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')}`;
}
+113
View File
@@ -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-4T-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-4T-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')}`;
}
+38 -28
View File
@@ -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>PDFWord </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}
+9
View File
@@ -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,
+6
View File
@@ -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 />} />