feat: enhance operations dashboard and reporting controls
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
CreateSensitiveWordDto,
|
||||
DictionariesService,
|
||||
DictionaryStatusDto,
|
||||
ReorderCommonReportFieldsDto,
|
||||
UpdateDrainageFieldDto,
|
||||
} from './dictionaries.service';
|
||||
|
||||
@ApiTags('dictionaries')
|
||||
@@ -128,6 +130,15 @@ export class DictionariesController {
|
||||
return this.dictionaries.createDrainageField(body);
|
||||
}
|
||||
|
||||
@Put('drainage-fields/:id')
|
||||
updateDrainageField(
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdateDrainageFieldDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.dictionaries.updateDrainageField(id, body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('drainage-fields/:id')
|
||||
deleteDrainageField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteDrainageField(id);
|
||||
@@ -168,6 +179,14 @@ export class DictionariesController {
|
||||
return this.dictionaries.createCommonReportField(body);
|
||||
}
|
||||
|
||||
@Put('common-report-fields/order')
|
||||
reorderCommonReportFields(
|
||||
@Body() body: ReorderCommonReportFieldsDto,
|
||||
@CurrentSessionUserId() operatorId?: string,
|
||||
) {
|
||||
return this.dictionaries.reorderCommonReportFields(body, operatorId);
|
||||
}
|
||||
|
||||
@Delete('common-report-fields/:id')
|
||||
deleteCommonReportField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteCommonReportField(id);
|
||||
|
||||
@@ -33,6 +33,7 @@ function createPrismaMock() {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||
},
|
||||
channelReportField: {
|
||||
@@ -46,6 +47,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'common-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'common-1' }),
|
||||
update: jest.fn(),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
@@ -76,6 +78,42 @@ describe('DictionariesService', () => {
|
||||
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||
});
|
||||
|
||||
it('reorders every common field in one report type transaction and writes an audit trail', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
commonReportField: {
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'common-2' }, { id: 'common-1' }]),
|
||||
},
|
||||
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-2', 'common-1'] }, 'admin-1'),
|
||||
).resolves.toEqual([{ id: 'common-2' }, { id: 'common-1' }]);
|
||||
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: 'common-2' },
|
||||
data: { sortOrder: 10 },
|
||||
});
|
||||
expect(tx.commonReportField.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: 'common-1' },
|
||||
data: { sortOrder: 20 },
|
||||
});
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'common_report_field.reorder', userId: 'admin-1' }),
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
|
||||
tx.commonReportField.findMany.mockResolvedValue([{ id: 'common-1' }]);
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1'] }),
|
||||
).resolves.toEqual([{ id: 'common-1' }]);
|
||||
await expect(
|
||||
service.reorderCommonReportFields({ reportType: 'signature', ids: ['common-1', 'common-2'] }),
|
||||
).rejects.toThrow('排序范围已变化');
|
||||
});
|
||||
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||
@@ -130,6 +168,32 @@ describe('DictionariesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('edits an unreferenced field atomically and protects mapping keys once referenced', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const existing = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', description: null };
|
||||
prisma.drainageField.findUnique.mockResolvedValue(existing as never);
|
||||
const tx = {
|
||||
drainageField: { update: jest.fn().mockResolvedValue({ ...existing, name: '企业主体证明', description: '最新版' }) },
|
||||
operationLog: { create: jest.fn().mockResolvedValue({}) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.updateDrainageField('field-1', {
|
||||
code: 'license', name: '企业主体证明', fieldType: 'file', description: ' 最新版 ',
|
||||
}, 'admin-1')).resolves.toEqual(expect.objectContaining({ name: '企业主体证明' }));
|
||||
expect(tx.drainageField.update).toHaveBeenCalledWith({
|
||||
where: { id: 'field-1' },
|
||||
data: { code: 'license', name: '企业主体证明', fieldType: 'file', description: '最新版' },
|
||||
});
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'drainage_field.update', userId: 'admin-1' }) });
|
||||
|
||||
prisma.channelReportField.count.mockResolvedValue(1);
|
||||
await expect(service.updateDrainageField('field-1', {
|
||||
code: 'newCode', name: '企业主体证明', fieldType: 'file', description: '',
|
||||
})).rejects.toThrow('不能修改字段代码或类型');
|
||||
});
|
||||
|
||||
it('ignores deleted-channel references and removes those stale mappings when deleting the field', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface CreateDrainageFieldDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type UpdateDrainageFieldDto = CreateDrainageFieldDto;
|
||||
|
||||
export interface UpsertDrainageDetectionRuleDto {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -87,6 +89,11 @@ export interface CreateCommonReportFieldDto {
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ReorderCommonReportFieldsDto {
|
||||
reportType: 'signature' | 'drainage';
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface DictionaryStatusDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
@@ -399,6 +406,58 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateDrainageField(id: string, data: UpdateDrainageFieldDto, operatorId?: string) {
|
||||
const code = data.code?.trim();
|
||||
const name = data.name?.trim();
|
||||
if (!code || !/^[A-Za-z0-9]+$/.test(code)) {
|
||||
throw new BadRequestException('code must contain only Arabic numerals and English letters');
|
||||
}
|
||||
if (!name) throw new BadRequestException('name is required');
|
||||
if (!['string', 'image', 'file'].includes(data.fieldType)) {
|
||||
throw new BadRequestException('fieldType must be string, image or file');
|
||||
}
|
||||
const existing = await this.prisma.drainageField.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('报备字段不存在');
|
||||
const [usageCount, commonUsageCount] = await Promise.all([
|
||||
this.prisma.channelReportField.count({ where: { drainageFieldId: id, channel: { status: { not: 'deleted' } } } }),
|
||||
this.prisma.commonReportField.count({ where: { drainageFieldId: id } }),
|
||||
]);
|
||||
if ((usageCount > 0 || commonUsageCount > 0) && (code !== existing.code || data.fieldType !== existing.fieldType)) {
|
||||
throw new BadRequestException('字段已被引用,只能修改名称和说明,不能修改字段代码或类型');
|
||||
}
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.drainageField.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code,
|
||||
name,
|
||||
fieldType: data.fieldType,
|
||||
description: data.description?.trim() || null,
|
||||
},
|
||||
});
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'drainage_field.update',
|
||||
resource: 'drainage_field',
|
||||
resourceId: id,
|
||||
detail: {
|
||||
before: { code: existing.code, name: existing.name, fieldType: existing.fieldType, description: existing.description },
|
||||
after: { code: updated.code, name: updated.name, fieldType: updated.fieldType, description: updated.description },
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException('字段代码已存在');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDrainageField(id: string) {
|
||||
const [usageCount, commonUsageCount] = await Promise.all([
|
||||
this.prisma.channelReportField.count({
|
||||
@@ -541,6 +600,40 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
async reorderCommonReportFields(data: ReorderCommonReportFieldsDto, operatorId?: string) {
|
||||
if (!['signature', 'drainage'].includes(data?.reportType) || !Array.isArray(data?.ids) || !data.ids.length) {
|
||||
throw new BadRequestException('通用字段排序参数无效');
|
||||
}
|
||||
if (new Set(data.ids).size !== data.ids.length) throw new BadRequestException('通用字段排序不能包含重复项');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.commonReportField.findMany({
|
||||
where: { reportType: data.reportType, status: 'active' },
|
||||
select: { id: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
const existingIds = existing.map((field) => field.id);
|
||||
if (existingIds.length !== data.ids.length || existingIds.some((id) => !data.ids.includes(id))) {
|
||||
throw new BadRequestException('通用字段排序范围已变化,请刷新页面后重试');
|
||||
}
|
||||
for (const [index, id] of data.ids.entries()) {
|
||||
await tx.commonReportField.update({ where: { id }, data: { sortOrder: (index + 1) * 10 } });
|
||||
}
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: operatorId,
|
||||
action: 'common_report_field.reorder',
|
||||
resource: 'common_report_field',
|
||||
detail: { reportType: data.reportType, before: existingIds, after: data.ids } as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return tx.commonReportField.findMany({
|
||||
where: { reportType: data.reportType, status: 'active' },
|
||||
include: { drainageField: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||
}
|
||||
|
||||
deleteCommonReportField(id: string) {
|
||||
return this.prisma.commonReportField.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -511,6 +511,11 @@ describe('OperationsService', () => {
|
||||
todaySpendCents: 24000n,
|
||||
balanceCents: 1000000n,
|
||||
creditCents: 50000n,
|
||||
}]).mockResolvedValueOnce([{
|
||||
segmentCount: 20n,
|
||||
deliveredSegmentCount: 18n,
|
||||
billedCents: 360n,
|
||||
costCents: 216n,
|
||||
}]).mockResolvedValueOnce([
|
||||
{ hour: 9, submittedCount: 12n, successCount: 10n },
|
||||
{ hour: 10, submittedCount: 5n, successCount: 4n },
|
||||
@@ -541,7 +546,15 @@ describe('OperationsService', () => {
|
||||
templates: 1,
|
||||
total: 5,
|
||||
},
|
||||
today: expect.objectContaining({ returnedCents: 10 }),
|
||||
today: expect.objectContaining({
|
||||
returnedCents: 10,
|
||||
segmentCount: 20,
|
||||
deliveredSegmentCount: 18,
|
||||
arrivalRate: 90,
|
||||
billedCents: 360,
|
||||
profitCents: 144,
|
||||
profitRate: 40,
|
||||
}),
|
||||
hourlySendTrend: expect.arrayContaining([
|
||||
{ hour: 9, label: '09:00', submittedCount: 12, successCount: 10 },
|
||||
{ hour: 10, label: '10:00', submittedCount: 5, successCount: 4 },
|
||||
@@ -592,7 +605,11 @@ describe('OperationsService', () => {
|
||||
updatedAt: { gte: expect.any(Date) },
|
||||
},
|
||||
});
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
const dashboardMetricsQuery = prisma.$queryRaw.mock.calls[1]?.[0] as { sql?: string };
|
||||
expect(dashboardMetricsQuery.sql).toContain('FROM "SmsMessageSegmentAudit" segment');
|
||||
expect(dashboardMetricsQuery.sql).toContain('message."billingUnits" * message."unitPrice"');
|
||||
expect(dashboardMetricsQuery.sql).toContain('submit."costUnitPrice"');
|
||||
const hourlyTrendQuery = prisma.$queryRaw.mock.calls[2]?.[0] as { sql?: string };
|
||||
expect(hourlyTrendQuery.sql).toContain(
|
||||
`HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'`,
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ async dashboard(query: { tenantId?: string }) {
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
enterpriseSpendRows,
|
||||
todayBusinessMetricsRows,
|
||||
downstreamPendingCount,
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
@@ -117,6 +118,67 @@ async dashboard(query: { tenantId?: string }) {
|
||||
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
|
||||
ORDER BY "todaySpendCents" DESC, tenant.name ASC
|
||||
`),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
segmentCount: bigint;
|
||||
deliveredSegmentCount: bigint;
|
||||
billedCents: bigint;
|
||||
costCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
WITH segment_metrics AS (
|
||||
SELECT
|
||||
COUNT(segment.id)::bigint AS "segmentCount",
|
||||
COUNT(segment.id) FILTER (WHERE segment."receiptStatus" = 'delivered')::bigint AS "deliveredSegmentCount"
|
||||
FROM "SmsMessageSegmentAudit" segment
|
||||
JOIN "SmsMessageRecord" message ON message.id = segment."messageRecordId"
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
),
|
||||
message_revenue AS (
|
||||
SELECT COALESCE(SUM(
|
||||
CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered'
|
||||
THEN message."billingUnits" * message."unitPrice" ELSE 0 END
|
||||
), 0)::bigint AS "billedCents"
|
||||
FROM "SmsMessageRecord" message
|
||||
WHERE message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
),
|
||||
submit_cost AS (
|
||||
SELECT COALESCE(SUM(submit."costUnitPrice" * CASE
|
||||
WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count
|
||||
WHEN legacy_receipt.delivered THEN message."billingUnits"
|
||||
ELSE 0
|
||||
END), 0)::bigint AS "costCents"
|
||||
FROM "SmsSubmitRecord" submit
|
||||
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*)::integer AS audit_count,
|
||||
COUNT(*) FILTER (WHERE audit."receiptStatus" = 'delivered')::integer AS delivered_count
|
||||
FROM "SmsMessageSegmentAudit" audit
|
||||
WHERE audit."submitRecordId" = submit.id
|
||||
) segment_receipts ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM "SmsReceiptRecord" receipt
|
||||
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) AS delivered
|
||||
) legacy_receipt ON TRUE
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
AND message."queuedAt" >= ${businessDay.startAt}
|
||||
AND message."queuedAt" < ${businessDay.endAt}
|
||||
AND (${query.tenantId ?? null}::text IS NULL OR message."tenantId" = ${query.tenantId ?? null})
|
||||
)
|
||||
SELECT
|
||||
segment_metrics."segmentCount",
|
||||
segment_metrics."deliveredSegmentCount",
|
||||
message_revenue."billedCents",
|
||||
submit_cost."costCents"
|
||||
FROM segment_metrics, message_revenue, submit_cost
|
||||
`),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { tenantId: query.tenantId, status: 'pending' },
|
||||
}),
|
||||
@@ -239,6 +301,12 @@ async dashboard(query: { tenantId?: string }) {
|
||||
`),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const todayBusinessMetrics = todayBusinessMetricsRows[0];
|
||||
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
|
||||
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
|
||||
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
|
||||
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
|
||||
const profitCents = billedCents - costCents;
|
||||
const hourlyRowsByHour = new Map(hourlySendRows.map((row) => [Number(row.hour), row]));
|
||||
// Always return all 24 Shanghai-time buckets so the line chart does not imply missing hours are missing data.
|
||||
const hourlySendTrend = Array.from({ length: 24 }, (_, hour) => {
|
||||
@@ -279,6 +347,12 @@ async dashboard(query: { tenantId?: string }) {
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
segmentCount,
|
||||
deliveredSegmentCount,
|
||||
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
|
||||
billedCents,
|
||||
profitCents,
|
||||
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
|
||||
},
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
|
||||
@@ -331,7 +331,8 @@ export class ReportChannelExportService {
|
||||
};
|
||||
}
|
||||
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||
if (!data) throw new BadRequestException('导出参数不能为空');
|
||||
if ((data.reportType ?? 'signature') !== 'signature')
|
||||
throw new BadRequestException('首版仅支持单条签名报备资料导出');
|
||||
const detail = await this.getSingleMaterialDetail(data);
|
||||
|
||||
@@ -214,7 +214,7 @@ export class ReportMaterialsController {
|
||||
@Post('single-export')
|
||||
@RequireRecentAuthentication()
|
||||
async exportSingleMaterial(
|
||||
@Body() body: SingleReportMaterialDto,
|
||||
@Body() body: SingleReportMaterialDto | undefined,
|
||||
@CurrentSessionUserId() operatorId: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { ReportMaterialsService } from './report-materials.service';
|
||||
import { mappedCorePatchValue } from './report-materials.helpers';
|
||||
|
||||
describe('ReportMaterialsService', () => {
|
||||
it('rejects an unparsed single-export body as a readable 400 instead of throwing a TypeError', async () => {
|
||||
const service = new ReportMaterialsService({} as never, {} as never, {} as never);
|
||||
await expect(service.exportSingleMaterial(undefined, 'operator-1')).rejects.toThrow('导出参数不能为空');
|
||||
});
|
||||
|
||||
it('does not clear an existing core field when the import column is unmapped or blank', () => {
|
||||
expect(mappedCorePatchValue([], {}, 'purpose')).toBeUndefined();
|
||||
expect(
|
||||
|
||||
@@ -121,7 +121,7 @@ export class ReportMaterialsService {
|
||||
return this.channelExport.getSingleMaterialDetail(data);
|
||||
}
|
||||
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto, operatorId?: string) {
|
||||
async exportSingleMaterial(data: SingleReportMaterialDto | undefined, operatorId?: string) {
|
||||
return this.channelExport.exportSingleMaterial(data, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5048,3 +5048,17 @@ npm run verify:phase8
|
||||
| TC-HFQ-008 | 获取企业筛选选项 | 使用轻量options接口,仅返回id/name/code/status且后端过滤deleted,不返回企业认证材料 |
|
||||
| TC-HFQ-009 | 批量导入解析后切换“保存为可复用映射方案” | 控件使用通用按钮外观、图标和清晰选中态;aria-pressed随状态切换,选中后展示方案名称输入框 |
|
||||
| TC-HFQ-010 | 查看签名及引流两级操作按钮 | 报备状态、编辑、删除均使用通用sm按钮高度,删除按钮不再高低不齐 |
|
||||
|
||||
## TC-ADMIN-ENHANCEMENT-20260904 运营看板与配置交互增强
|
||||
|
||||
| 用例ID | 场景 | 预期 |
|
||||
| --- | --- | --- |
|
||||
| TC-DASHBOARD-FRAGMENT-001 | 准备当天单分片、长短信多分片及成功/失败回执后打开运营看板,并用PostgreSQL独立聚合复核 | “今日消息分片数”取真实`SmsMessageSegmentAudit`行数;“今日到达率”等于成功到达分片数/发送总分片数,零分片时为0;原总体成功率仍按业务短信统计 |
|
||||
| TC-DASHBOARD-PROFIT-001 | 准备当天成功短信、返还流水和不同客户价/通道成本快照后打开运营看板 | 今日返还取真实返还流水;今日计收按最终成功短信计费条数×客户价快照;成本按成功分片×通道成本快照,利润与利润率计算一致,收入为0时利润率为0 |
|
||||
| TC-ENTERPRISE-SIGNATURE-HOVER-001 | 在企业签名列表悬停被截断的签名 | 可查看完整签名、企业、应用、用途、审核状态及创建/更新时间;不增加接口或使用静态数据 |
|
||||
| TC-NAV-DENSITY-001 | 在桌面端和390px窄屏展开运营端长菜单 | 一级分组、二级菜单上下间距更紧凑,菜单仍可滚动,图标、文本、角标、选中态及点击导航完整可用 |
|
||||
| TC-SIGNATURE-QUALITY-MATRIX-001 | 打开任一签名发送质量详情,切换整体统计与按引流切分 | 通道×运营商矩阵保留通道、运营商、引流状态、提交次数、成功率、平均到达时间和提交失败数;表头与通道列滚动时可辨识,成功率层级清晰;请求及后端接口不变 |
|
||||
| TC-REPORT-FIELD-EDIT-001 | 编辑未引用字段的代码、名称、类型和说明后刷新页面 | 修改通过真实PUT接口写入PostgreSQL并记录操作日志,刷新后仍存在;非法代码、空名称、非法类型和重复代码由API拒绝 |
|
||||
| TC-REPORT-FIELD-EDIT-002 | 编辑已被通道或通用字段引用的字段 | 页面锁定代码和类型,允许修改名称和说明;绕过前端直接修改代码或类型时API返回400,既有通道映射和历史资料不受影响 |
|
||||
| TC-REPORT-FIELD-ORDER-001 | 分别在签名、引流通用字段中点击上移/下移并刷新 | 仅在当前资料类型内整体重排,真实`sortOrder`按新顺序持久化;并发导致集合变化时明确失败,不产生部分更新 |
|
||||
| TC-REPORT-WORKBENCH-025 | 在通道报备明细点击单条导出,并分别模拟空请求体和缺失必填资料 | 正常请求以JSON Content-Type提交并下载真实XLSX;空请求体返回可读400而不是500;资料错误明确返回且不静默生成空文件,不改变报备状态或触发短信链路 |
|
||||
|
||||
@@ -4425,3 +4425,16 @@ git diff --check
|
||||
- 预生产最终`.deployed-commit=dada0d978bb05d7046469c1ec77371ddb2fb03bc`。PostgreSQL、Redis、MinIO、API、四个Worker、Gateway、Security Agent、Nginx和Node Exporter共12项服务均`active`,七项应用服务`NRestarts=0 / Result=success`;内外`12026`、`sms.lisglo.com`、API、Gateway和Callback健康均通过,发布后error级journal为空。
|
||||
- 服务重启后3条富泷供应商通道一度认证失败,供应商连接从9/9暂为6/9;旧版本回滚阶段同样出现,未修改通道配置或凭据。系统按既有计划自动重试,于20:56:52恢复`desired=9 / connected=9`并持续稳定;Gateway当前4条下游连接均在线且持续收发心跳。三条Redis Stream最终仍为`pending=0 / lag=0`,其entries-read仅随正常连接恢复、回执和协议日志前进;本轮未发送、补发、重投或重新入队短信。
|
||||
- 工作站从预生产实际回读主资源`index-DHpz4GTj.js`、`index-rk3aXfEF.css`和状态记录分块`AdminReportRecordsPage-Bo0P2l0_.js`,均HTTP 200,长度分别为370340、236129和8658字节;登录后的状态记录真实交互沿用本地真实API/PostgreSQL/Redis验收结果,未在没有预生产运营登录态时冒充线上登录验收。
|
||||
|
||||
## 2026-09-04 运营看板、签名与报备配置增强(本地修改)
|
||||
|
||||
- 运营看板新增今日消息分片数和按分片计算的今日到达率,并按当天实时短信、分片审计、客户价及通道成本快照展示今日返还、计收、利润和利润率;聚合直接查询真实业务表,不依赖尚未生成的T+1日报,不改变原业务短信成功率口径。
|
||||
- 企业签名列表的签名悬停信息补齐完整签名、企业、应用、用途、审核状态及创建/更新时间;导航分组和菜单项纵向间距收紧,保留滚动、折叠、角标和响应式逻辑。
|
||||
- 签名质量详情的通道×运营商矩阵仅调整前端展示:表头和通道列吸附、单元指标分层、成功率颜色提示更清晰;整体/引流切分、所有原字段和现有后端接口均保留。
|
||||
- 报备字段定义新增真实编辑接口和操作日志。未引用字段可调整代码、名称、类型和说明;已被通道或通用配置引用时只允许改名称和说明,前后端同时保护映射关键字段。通用字段可在签名/引流各自范围内原子调整顺序,集合变化时拒绝部分更新;未新增数据库字段或migration。
|
||||
- 通道报备明细单条导出的500根因定位为Blob POST传JSON字符串时未设置`Content-Type: application/json`,Nest未解析请求体。前端请求头已修复,后端为空请求增加可读400保护;导出仍读取真实字段、资料和文件,不改变报备状态,不发送、补发、重投或重新入队短信。
|
||||
- 测试环境`100.93.204.60`当前ICMP和22端口可达,但约642ms且现有密钥认证失败;本轮没有部署授权,也没有把登录后的测试环境页面冒充已验收。
|
||||
- 定向API 3套64项、定向前端3文件23项、全量API 52套605项、前端12文件61项通过;前后端TypeScript、Vite生产构建、依赖安全、部署契约、结构质量、包体积和`git diff --check`通过。Vite仅保留既有Chart分块超过500kB提示,入口gzip 107.68KiB,符合250KiB预算。
|
||||
- Browser插件本轮不可用,按前端调试流程使用工作区Playwright Chromium验证本地生产构建。1600×1000运营看板和质量矩阵、390×844运营看板页面身份、非空、错误层、控制台及横向溢出检查通过;菜单项上下内边距实测7px,矩阵完整保留通道提交、成功率、平均到达和提交失败信息。截图数据只用于布局验证,不冒充真实业务数据。
|
||||
- 本地真实PostgreSQL启动后,新看板聚合代码直接执行成功,返回当日零短信下分片数、到达率、计收、利润和利润率均为0,确认SQL语法、表关联和零分母处理可运行;本地API健康HTTP 200。Redis未启动时API持续输出连接拒绝,故未将该不完整本地栈作为页面功能验收,验证后已关闭本地API、预览和PostgreSQL。
|
||||
- 本轮只做本地提交,不推送、不部署,不访问或修改测试/预生产业务数据;不发送、补发、重投或重新入队短信,不修改余额、通道或客户配置。本节与源码、测试用例一并纳入本轮本地提交。
|
||||
|
||||
@@ -194,6 +194,8 @@ export const adminGovernanceApi = {
|
||||
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||
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) }),
|
||||
updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) =>
|
||||
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
|
||||
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
|
||||
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
|
||||
@@ -208,6 +210,11 @@ export const adminGovernanceApi = {
|
||||
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) }),
|
||||
reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) =>
|
||||
request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
|
||||
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
|
||||
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -140,6 +140,23 @@ describe('request tenant and error boundaries', () => {
|
||||
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
|
||||
});
|
||||
|
||||
it('marks string-body blob requests as JSON so download endpoints receive their payload', async () => {
|
||||
let receivedContentType = '';
|
||||
let receivedBody: unknown;
|
||||
server.use(
|
||||
http.post('http://localhost/api/admin/report-export', async ({ request: incoming }) => {
|
||||
receivedContentType = incoming.headers.get('content-type') ?? '';
|
||||
receivedBody = await incoming.json();
|
||||
return new HttpResponse('xlsx-data', { status: 200 });
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
(await requestBlob('/admin/report-export', { method: 'POST', body: JSON.stringify({ reportType: 'signature' }) })).text(),
|
||||
).resolves.toBe('xlsx-data');
|
||||
expect(receivedContentType).toContain('application/json');
|
||||
expect(receivedBody).toEqual({ reportType: 'signature' });
|
||||
});
|
||||
|
||||
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
|
||||
writeSession({
|
||||
portal: 'admin',
|
||||
|
||||
@@ -114,6 +114,9 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
||||
|
||||
export async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (typeof options.body === 'string' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
|
||||
@@ -152,6 +152,12 @@ export type DashboardResponse = {
|
||||
spendCents: number;
|
||||
returnedCents: number;
|
||||
billingUnits: number;
|
||||
segmentCount: number;
|
||||
deliveredSegmentCount: number;
|
||||
arrivalRate: number;
|
||||
billedCents: number;
|
||||
profitCents: number;
|
||||
profitRate: number;
|
||||
};
|
||||
uplinkCount: number;
|
||||
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
|
||||
|
||||
@@ -607,12 +607,10 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
|
||||
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
|
||||
|
||||
return (
|
||||
<div className="signature-quality-matrix__metric">
|
||||
<strong>{total.toLocaleString('zh-CN')} 次</strong>
|
||||
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
|
||||
{successRate.toFixed(1)}%
|
||||
</span>
|
||||
<small>{formatDuration(metric?.averageArrivalMs)}</small>
|
||||
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}>
|
||||
<div><small>通道提交</small><strong>{total.toLocaleString('zh-CN')} 次</strong></div>
|
||||
<div><small>成功率</small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div>
|
||||
<div><small>平均到达</small><span>{formatDuration(metric?.averageArrivalMs)}</span></div>
|
||||
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
|
||||
|
||||
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } }));
|
||||
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), reorderCommonReportFields: vi.fn(), updateCommonReportField: vi.fn(), updateDrainageField: vi.fn(), createDrainageField: vi.fn(), deleteDrainageField: vi.fn() } }));
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi }));
|
||||
|
||||
describe('common reporting configuration', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(adminApi).forEach((method) => method.mockReset());
|
||||
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
|
||||
adminApi.listDrainageFields.mockResolvedValue([field]);
|
||||
adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]);
|
||||
const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' };
|
||||
adminApi.listDrainageFields.mockResolvedValue([field, secondField]);
|
||||
adminApi.listCommonReportFields.mockResolvedValue([
|
||||
{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, sortOrder: 10, drainageField: field },
|
||||
{ id: 'common-2', drainageFieldId: 'field-2', reportType: 'signature', required: false, sortOrder: 20, drainageField: secondField },
|
||||
]);
|
||||
adminApi.reorderCommonReportFields.mockResolvedValue([]);
|
||||
adminApi.updateCommonReportField.mockResolvedValue({});
|
||||
adminApi.updateDrainageField.mockResolvedValue({});
|
||||
});
|
||||
it('opens existing values and saves the edited requirement with PUT API', async () => {
|
||||
render(<AdminDrainageFieldsPage />);
|
||||
@@ -24,4 +30,31 @@ describe('common reporting configuration', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('moves a common field within its own material type through the reorder API', async () => {
|
||||
render(<AdminDrainageFieldsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' }));
|
||||
await waitFor(() => expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({
|
||||
reportType: 'signature',
|
||||
ids: ['common-2', 'common-1'],
|
||||
}));
|
||||
await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('edits a field definition and locks mapping keys for a referenced field', async () => {
|
||||
adminApi.listDrainageFields.mockResolvedValueOnce([
|
||||
{ id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active', usageCount: 1, description: '旧说明' },
|
||||
]);
|
||||
render(<AdminDrainageFieldsPage />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' }));
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('编辑报备字段');
|
||||
expect(screen.getByLabelText('字段代码')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: '字段类型' })).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } });
|
||||
fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
await waitFor(() => expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', {
|
||||
code: 'license', name: '企业主体证明', fieldType: 'file', description: '新说明',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
||||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
@@ -32,6 +32,8 @@ export function AdminDrainageFieldsPage() {
|
||||
const [type, setType] = useState('all');
|
||||
const [appliedType, setAppliedType] = useState('all');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editingField, setEditingField] = useState<DrainageField | null>(null);
|
||||
const [fieldSaving, setFieldSaving] = useState(false);
|
||||
const [code, setCode] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
||||
@@ -45,6 +47,7 @@ export function AdminDrainageFieldsPage() {
|
||||
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
|
||||
const [commonRequired, setCommonRequired] = useState(false);
|
||||
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
|
||||
const [commonOrderingId, setCommonOrderingId] = useState<string>();
|
||||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||||
|
||||
function loadData() {
|
||||
@@ -70,17 +73,38 @@ export function AdminDrainageFieldsPage() {
|
||||
[appliedKeyword, appliedType, fields],
|
||||
);
|
||||
|
||||
function createField() {
|
||||
adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' })
|
||||
function closeFieldModal() {
|
||||
setCreating(false);
|
||||
setEditingField(null);
|
||||
setCode('');
|
||||
setName('');
|
||||
setFieldType('string');
|
||||
setDescription('');
|
||||
}
|
||||
|
||||
function openFieldModal(field?: DrainageField) {
|
||||
setEditingField(field ?? null);
|
||||
setCode(field?.code ?? '');
|
||||
setName(field?.name ?? '');
|
||||
setFieldType((field?.fieldType as ReportFieldType | undefined) ?? 'string');
|
||||
setDescription(field?.description ?? '');
|
||||
setError('');
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
function saveField() {
|
||||
if (fieldSaving) return;
|
||||
setFieldSaving(true);
|
||||
const request = editingField
|
||||
? adminApi.updateDrainageField(editingField.id, { code, name, fieldType, description })
|
||||
: adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' });
|
||||
request
|
||||
.then(() => {
|
||||
setCode('');
|
||||
setName('');
|
||||
setFieldType('string');
|
||||
setDescription('');
|
||||
setCreating(false);
|
||||
closeFieldModal();
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
|
||||
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'))
|
||||
.finally(() => setFieldSaving(false));
|
||||
}
|
||||
|
||||
function deleteField() {
|
||||
@@ -130,6 +154,26 @@ export function AdminDrainageFieldsPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
||||
}
|
||||
|
||||
async function moveCommonField(field: CommonReportField, direction: -1 | 1) {
|
||||
if (commonOrderingId) return;
|
||||
const group = commonFields.filter((item) => item.reportType === field.reportType);
|
||||
const currentIndex = group.findIndex((item) => item.id === field.id);
|
||||
const targetIndex = currentIndex + direction;
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= group.length) return;
|
||||
const ids = group.map((item) => item.id);
|
||||
[ids[currentIndex], ids[targetIndex]] = [ids[targetIndex], ids[currentIndex]];
|
||||
setCommonOrderingId(field.id);
|
||||
setError('');
|
||||
try {
|
||||
await adminApi.reorderCommonReportFields({ reportType: field.reportType, ids });
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '通用字段顺序调整失败');
|
||||
} finally {
|
||||
setCommonOrderingId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
|
||||
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
|
||||
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
|
||||
@@ -142,7 +186,7 @@ export function AdminDrainageFieldsPage() {
|
||||
<h1>报备字段库</h1>
|
||||
<p>统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)} size="sm">添加字段</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm">添加字段</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
@@ -170,8 +214,8 @@ export function AdminDrainageFieldsPage() {
|
||||
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary">配置通用字段</Button>
|
||||
</div>
|
||||
<div className="admin-drainage-common-grid">
|
||||
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="info" />
|
||||
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="warning" />
|
||||
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="info" />
|
||||
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="warning" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -180,7 +224,7 @@ export function AdminDrainageFieldsPage() {
|
||||
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
|
||||
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
|
||||
return <article className="admin-drainage-field-card" key={field.id}>
|
||||
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost">删除</Button></div>
|
||||
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><div className="admin-drainage-field-card__actions"><Button aria-label={`编辑${field.name}`} icon={<Edit3 size={14} />} iconOnly onClick={() => openFieldModal(field)} size="sm" variant="ghost">编辑</Button><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost">删除</Button></div></div>
|
||||
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
|
||||
<div className="admin-drainage-field-card__meta"><span>通道引用 <strong>{field.usageCount ?? 0}</strong></span><span>通用配置 <strong>{field.commonUsageCount ?? 0}</strong></span></div>
|
||||
</article>;
|
||||
@@ -205,23 +249,25 @@ export function AdminDrainageFieldsPage() {
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setCreating(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!code || !name || Boolean(codeError)} onClick={createField}>保存</Button>
|
||||
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost">取消</Button>
|
||||
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>{fieldSaving ? '保存中...' : '保存'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setCreating(false)}
|
||||
onClose={closeFieldModal}
|
||||
open={creating}
|
||||
title="添加报备字段"
|
||||
title={editingField ? '编辑报备字段' : '添加报备字段'}
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
|
||||
<Input disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))} error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
|
||||
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||
<Select
|
||||
disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))}
|
||||
label="字段类型"
|
||||
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
|
||||
options={typeOptions.filter((option) => option.value !== 'all')}
|
||||
value={fieldType}
|
||||
/>
|
||||
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? <p className="admin-system-modal-form__wide">该字段已被引用,为保护现有通道映射和历史资料,只能修改字段名称和描述。</p> : null}
|
||||
<Textarea
|
||||
className="admin-system-modal-form__wide"
|
||||
label="描述"
|
||||
@@ -255,6 +301,6 @@ export function AdminDrainageFieldsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function CommonFieldGroup({ fields, label, onEdit, onDelete, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
|
||||
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost">修改</Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
||||
function CommonFieldGroup({ fields, label, onEdit, onDelete, onMove, orderingId, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; onMove: (field: CommonReportField, direction: -1 | 1) => void; orderingId?: string; tone: 'info' | 'warning' }) {
|
||||
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field, index) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><div className="admin-drainage-common-order"><Button aria-label={`上移通用字段${field.drainageField.name}`} disabled={index === 0 || Boolean(orderingId)} icon={<ArrowUp size={14} />} iconOnly onClick={() => onMove(field, -1)} size="sm" variant="ghost">上移</Button><Button aria-label={`下移通用字段${field.drainageField.name}`} disabled={index === fields.length - 1 || Boolean(orderingId)} icon={<ArrowDown size={14} />} iconOnly onClick={() => onMove(field, 1)} size="sm" variant="ghost">下移</Button></div><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost">修改</Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ export function AdminHome() {
|
||||
const totalSend = dashboard?.today.sent ?? 0;
|
||||
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
||||
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
|
||||
const todayReturned = moneyUnitsToYuan(dashboard?.today.returnedCents);
|
||||
const todayBilled = moneyUnitsToYuan(dashboard?.today.billedCents);
|
||||
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
|
||||
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
|
||||
@@ -175,6 +178,36 @@ export function AdminHome() {
|
||||
<strong>{activeSignatureCount}</strong>
|
||||
<small>当天有真实发送记录的签名</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日消息分片数</span>
|
||||
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} 片</strong>
|
||||
<small>来自真实分片审计记录</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日到达率</span>
|
||||
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
|
||||
<small>到达分片 / 发送总分片</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日返还金额</span>
|
||||
<strong>¥{formatCurrency(todayReturned)}</strong>
|
||||
<small>来自今日返还流水</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日计收金额</span>
|
||||
<strong>¥{formatCurrency(todayBilled)}</strong>
|
||||
<small>成功短信计费条数 × 客户价</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日利润</span>
|
||||
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>¥{formatCurrency(todayProfit)}</strong>
|
||||
<small>计收金额 - 成功分片通道成本</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日利润率</span>
|
||||
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>{(dashboard?.today.profitRate ?? 0).toFixed(1)}%</strong>
|
||||
<small>今日利润 / 今日计收金额</small>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
||||
|
||||
|
||||
@@ -91,4 +91,10 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
|
||||
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the complete signature context in the signature hover text', () => {
|
||||
renderTable({ expandedSignatureId: '' });
|
||||
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('企业:深圳市聆界科技有限公司'));
|
||||
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('应用:营销通知应用'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,15 @@ export function EnterpriseSignaturesTable({
|
||||
: payload.links;
|
||||
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
|
||||
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
|
||||
const signatureTooltip = [
|
||||
`签名:${formatSignatureName(signature.name)}`,
|
||||
`企业:${signature.tenant?.name ?? signature.tenantId}`,
|
||||
`应用:${signature.application?.name ?? '未绑定应用'}`,
|
||||
`用途:${signature.purpose || '未填写'}`,
|
||||
`审核状态:${signature.auditStatus}`,
|
||||
`创建时间:${signature.createdAt ? new Date(signature.createdAt).toLocaleString('zh-CN') : '未知'}`,
|
||||
`更新时间:${signature.updatedAt ? new Date(signature.updatedAt).toLocaleString('zh-CN') : '未知'}`,
|
||||
].join('\n');
|
||||
return (
|
||||
<article
|
||||
aria-label={`签名总体状态:${cardVisual.label}`}
|
||||
@@ -86,7 +95,7 @@ export function EnterpriseSignaturesTable({
|
||||
>
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
<div className="enterprise-signature-table__signature" data-label="签名">
|
||||
<div aria-label={signatureTooltip} className="enterprise-signature-table__signature" data-label="签名" title={signatureTooltip}>
|
||||
<strong>{formatSignatureName(signature.name)}</strong>
|
||||
</div>
|
||||
<div data-label="企业">
|
||||
|
||||
+44
-7
@@ -4302,7 +4302,17 @@
|
||||
}
|
||||
|
||||
.admin-metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric-card__value--danger {
|
||||
color: var(--color-danger) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1380px) {
|
||||
.admin-metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.admin-workload-grid {
|
||||
@@ -7273,8 +7283,9 @@
|
||||
}
|
||||
|
||||
.signature-quality-matrix {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-primary));
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
@@ -7290,7 +7301,7 @@
|
||||
.signature-quality-matrix td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: var(--space-3);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -7306,19 +7317,42 @@
|
||||
}
|
||||
|
||||
.signature-quality-matrix thead th {
|
||||
background: var(--color-surface-muted);
|
||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.signature-quality-matrix tbody th {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-strong);
|
||||
min-width: 210px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__metric {
|
||||
background: var(--color-surface);
|
||||
border-left: 3px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
gap: 7px;
|
||||
min-width: 150px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__metric--success { border-left-color: var(--color-success); }
|
||||
.signature-quality-matrix__metric--warning { border-left-color: var(--color-warning); }
|
||||
.signature-quality-matrix__metric--danger { border-left-color: var(--color-danger); }
|
||||
|
||||
.signature-quality-matrix__metric > div {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__metric strong {
|
||||
@@ -7327,6 +7361,7 @@
|
||||
|
||||
.signature-quality-matrix__metric small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.signature-quality-matrix__metric em {
|
||||
@@ -8741,12 +8776,14 @@
|
||||
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
|
||||
.admin-drainage-common-list { display: grid; gap: 8px; }
|
||||
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto; padding: 11px 12px; }
|
||||
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto auto; padding: 11px 12px; }
|
||||
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
|
||||
.admin-drainage-common-order { display: inline-flex; gap: 2px; }
|
||||
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||||
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
|
||||
.admin-drainage-field-card__actions { display: inline-flex; gap: 4px; }
|
||||
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
|
||||
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
|
||||
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
gap: var(--space-4);
|
||||
height: 100vh;
|
||||
padding: var(--space-6) var(--space-4);
|
||||
padding: var(--space-4) var(--space-4);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -86,7 +86,7 @@
|
||||
align-content: start;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: var(--space-5);
|
||||
gap: var(--space-3);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-right: calc(var(--space-4) * -1);
|
||||
@@ -129,7 +129,7 @@
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
@@ -145,8 +145,8 @@
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
min-height: 42px;
|
||||
padding: 10px var(--space-3);
|
||||
min-height: 36px;
|
||||
padding: 7px var(--space-3);
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
width: 100%;
|
||||
@@ -190,7 +190,7 @@
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: 10px var(--space-3);
|
||||
padding: 7px var(--space-3);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user