From 35de17a2d44702d3ec3e839362a37d6f2bf26c89 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 9 Aug 2026 22:54:32 +0800 Subject: [PATCH] feat: add signature quality period trends --- api/src/operations/operations.service.spec.ts | 24 ++- api/src/operations/queries/quality.queries.ts | 63 ++++++- docs/contracts/operations-r2-methods.json | 2 +- .../first-version-development-requirements.md | 7 + docs/system-functional-test-cases.md | 6 + docs/testing-progress.md | 9 + src/api/types/operations.ts | 13 ++ src/apps/admin/AdminAnalyticsPage.tsx | 170 +++++++++++++++--- src/layouts/AdminLayout.tsx | 2 +- src/styles/global.css | 95 ++++++++++ src/theme/chartOptions.ts | 24 +++ 11 files changed, 385 insertions(+), 30 deletions(-) diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 978a4e6..15eec1a 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -677,6 +677,7 @@ describe('OperationsService', () => { averageArrivalMs: 1200, rowCount: 12, }]) + .mockResolvedValueOnce([{ count: 4 }]) .mockResolvedValueOnce([ { signatureId: 'signature-1', @@ -727,6 +728,18 @@ describe('OperationsService', () => { averageArrivalMs: 1800, }, ]); + prisma.$queryRaw.mockResolvedValueOnce([ + { + signatureId: 'signature-1', + channelId: 'channel-1', + channelName: '通道一', + carrier: 'mobile', + date: '2026-07-24', + businessMessageCount: 3, + deliveredMessageCount: 2, + deliveryRate: 66.7, + }, + ]); const service = new OperationsService(prisma as never); await expect(service.signatureQuality({ @@ -736,6 +749,7 @@ describe('OperationsService', () => { pageSize: 5, })).resolves.toEqual({ date: '2026-07-24', + selectedDaySignatureCount: 4, items: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', @@ -763,27 +777,31 @@ describe('OperationsService', () => { expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', drainageState: 'with', total: 4 }), expect.objectContaining({ channelId: 'channel-2', carrier: 'telecom', drainageState: 'without', total: 2 }), ], + dailyBreakdowns: [ + expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', date: '2026-07-24', businessMessageCount: 3 }), + ], })], total: 12, page: 2, pageSize: 5, }); - expect(prisma.$queryRaw).toHaveBeenCalledTimes(3); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(5); }); it('does not query channel details when the selected date has no registered signatures', async () => { const prisma = createPrismaMock(); - prisma.$queryRaw.mockResolvedValueOnce([]); + prisma.$queryRaw.mockResolvedValueOnce([]).mockResolvedValueOnce([{ count: 0 }]); const service = new OperationsService(prisma as never); await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({ date: '2026-07-24', + selectedDaySignatureCount: 0, items: [], total: 0, page: 1, pageSize: 10, }); - expect(prisma.$queryRaw).toHaveBeenCalledTimes(1); + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); }); it('returns trace details and reconciliation diffs', async () => { diff --git a/api/src/operations/queries/quality.queries.ts b/api/src/operations/queries/quality.queries.ts index 4352543..41f9797 100644 --- a/api/src/operations/queries/quality.queries.ts +++ b/api/src/operations/queries/quality.queries.ts @@ -316,11 +316,13 @@ async sendQuality(date?: string) { } async signatureQuality(query: SignatureQualityQuery) { const day = qualityBusinessDay(query.date); + // The selected date is an inclusive business cutoff, so a 30-day window starts 29 full days earlier. + const thirtyDayStartAt = new Date(day.startAt.getTime() - 29 * 24 * 60 * 60 * 1000); const page = positiveInteger(query.page, 1); const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); const keyword = query.keyword?.trim() || null; const keywordPattern = keyword ? `%${keyword}%` : null; - const summaries = await this.prisma.$queryRaw= ${day.startAt} + AND message."queuedAt" >= ${thirtyDayStartAt} AND message."queuedAt" < ${day.endAt} ) SELECT @@ -413,7 +415,13 @@ async signatureQuality(query: SignatureQualityQuery) { ORDER BY total DESC, signature.name LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize} - `); + `), this.prisma.$queryRaw>(Prisma.sql` + SELECT COUNT(DISTINCT message."signatureId")::integer AS count + FROM "SmsMessageRecord" message + WHERE message."signatureId" IS NOT NULL + AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" < ${day.endAt} + `)]); const signatureIds = summaries.map((item) => item.signatureId); const drainageBreakdowns = signatureIds.length === 0 ? [] @@ -486,7 +494,7 @@ async signatureQuality(query: SignatureQualityQuery) { ) failed_receipt ON TRUE WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) AND submit."submitStatus" IN ('accepted', 'rejected', 'timeout') - AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${thirtyDayStartAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} ), classified AS ( SELECT @@ -564,11 +572,54 @@ async signatureQuality(query: SignatureQualityQuery) { ))::integer AS "averageArrivalMs" FROM "SmsMessageRecord" message WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) - AND message."queuedAt" >= ${day.startAt} + AND message."queuedAt" >= ${thirtyDayStartAt} AND message."queuedAt" < ${day.endAt} GROUP BY message."signatureId", COALESCE(NULLIF(message.carrier, ''), 'unknown') ORDER BY message."signatureId", COUNT(*) DESC, carrier `); + const dailyBreakdowns = signatureIds.length === 0 + ? [] + : await this.prisma.$queryRaw>(Prisma.sql` + SELECT + message."signatureId" AS "signatureId", + COALESCE(message."channelId", 'unassigned') AS "channelId", + COALESCE(channel.name, '未分配通道') AS "channelName", + COALESCE(NULLIF(message.carrier, ''), 'unknown') AS carrier, + TO_CHAR((message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, + COUNT(*)::integer AS "businessMessageCount", + COUNT(*) FILTER ( + WHERE message.status = 'delivered' + OR message."receiptStatus" = 'delivered' + )::integer AS "deliveredMessageCount", + ROUND( + COUNT(*) FILTER ( + WHERE message.status = 'delivered' + OR message."receiptStatus" = 'delivered' + ) * 100.0 / COUNT(*), + 1 + )::double precision AS "deliveryRate" + FROM "SmsMessageRecord" message + LEFT JOIN "SmsChannel" channel ON channel.id = message."channelId" + WHERE message."signatureId" IN (${Prisma.join(signatureIds)}) + AND message."queuedAt" >= ${thirtyDayStartAt} + AND message."queuedAt" < ${day.endAt} + GROUP BY + message."signatureId", + COALESCE(message."channelId", 'unassigned'), + COALESCE(channel.name, '未分配通道'), + COALESCE(NULLIF(message.carrier, ''), 'unknown'), + TO_CHAR((message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') + ORDER BY message."signatureId", date, "channelId", carrier + `); const items = summaries.map(({ rowCount: _rowCount, ...summary }) => { const signatureDrainageBreakdowns = drainageBreakdowns.filter((item) => item.signatureId === summary.signatureId); const signatureBreakdowns = aggregateChannelCarrierRows(signatureDrainageBreakdowns); @@ -578,10 +629,12 @@ async signatureQuality(query: SignatureQualityQuery) { carrierOverview: carrierOverview.filter((item) => item.signatureId === summary.signatureId), breakdowns: signatureBreakdowns, drainageBreakdowns: signatureDrainageBreakdowns, + dailyBreakdowns: dailyBreakdowns.filter((item) => item.signatureId === summary.signatureId), }; }); return { date: day.key, + selectedDaySignatureCount: selectedDaySignatureRows[0]?.count ?? 0, items, total: summaries[0]?.rowCount ?? 0, page, diff --git a/docs/contracts/operations-r2-methods.json b/docs/contracts/operations-r2-methods.json index cfdc737..8827568 100644 --- a/docs/contracts/operations-r2-methods.json +++ b/docs/contracts/operations-r2-methods.json @@ -132,7 +132,7 @@ "group": "quality", "isPrivate": false, "signatureSha256": "6b9c3761674cb0d01000caca5e12cf91a6e526757592ffb783fc673c178b204c", - "bodySha256": "a7203deb5a8c7c3b6f6afaf83a55bb1ff45a0bf198f86a2158868c4b8c759b2d" + "bodySha256": "d07df712d2adadd05462914715f284e0215afbd0309fe276f78d2ac90a5aa0b7" }, { "name": "auditLogs", diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index da50c53..2d239b4 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1998,3 +1998,10 @@ - 运营端“短信通道组管理”在通道组名称条件之外增加“通道”筛选。选定一个通道后,只展示成员配置中真实包含该`channelId`的未删除通道组;与通道组名称同时输入时按两个条件取交集。 - 通道选项必须来自真实通道API,不使用静态列表、Mock或localStorage。页面首次加载时通道组与通道两个独立请求并行执行;选项同时展示通道名称和编码,已删除通道明确标记“已删除”。未加入任何通道组的真实通道仍可选择,选中后结果为零而不得隐藏该选项。 - 通道选择控件必须为通用下拉可搜索控件,支持按通道名称或编码搜索;“全部通道”表示不按通道限制,点击“重置”必须同时清空通道组名称和通道条件并回到第一页。 + +## 签名发送质量多周期趋势(2026-08-09) + +- 运营端原“数据统计”菜单更名为“签名发送质量”,路由保持`/admin/analytics`兼容。日期控件保留并明确为“统计截止日”;近3日、近7日、近30日均包含所选截止日当天,例如截止日为8月6日时,近3日为8月4日至8月6日。 +- 页面增加“当天发送签名数量”指标,按所选截止日内存在真实`SmsMessageRecord`且关联已登记`signatureId`的不同签名去重统计;搜索关键字不改变该全局当天指标。 +- 签名列表候选范围为截至所选日期的近30日,截止日当天没有发送但此前29日有发送的签名仍须出现。签名详情按“签名 × 消息最终归属通道 × 号码运营商”展示近3/7/30日的发送业务条数、到达业务条数和到达率。业务条数以`SmsMessageRecord`为唯一计数单位,长短信分片和同一短信的通道补发尝试不得重复放大;到达率为到达业务条数除以发送业务条数,分母为0时显示0%。 +- 点击任一通道、运营商和周期组合的“查看趋势”,必须展示该周期每个自然日的发送业务条数、到达业务条数和到达率折线;无数据日期补0,首尾日期与所选周期严格一致并包含截止日当天。数据必须来自真实后端和PostgreSQL,不得使用Mock、静态数据或localStorage。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 5ffa592..2ea79b2 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4000,6 +4000,12 @@ npm run verify:phase8 | TC-ANALYTICS-SIGNATURE-011 | 点击“查看明细” | 打开右侧详情抽屉,展示运营商概览及通道×运营商矩阵;Esc、关闭按钮和遮罩均可关闭 | | TC-ANALYTICS-SIGNATURE-012 | 所选日期没有已登记签名发送 | 返回真实空状态,不显示演示或历史日期数据 | | TC-ANALYTICS-SIGNATURE-013 | 同一运营商的一条业务短信首通道失败后切换通道并最终送达 | 运营商概览计1条业务短信、最终成功率为100%;通道矩阵仍分别记录两次真实提交 | +| TC-ANALYTICS-SIGNATURE-014 | 统计截止日选择8月6日,查看近3/7/30日 | 统计范围分别为8月4日至6日、7月31日至8月6日、7月8日至8月6日,三段均包含8月6日当天 | +| TC-ANALYTICS-SIGNATURE-015 | 同一签名同一天存在长短信分片和跨通道补发,再查看通道×运营商多周期质量 | 每个业务消息按最终归属通道只计1条,不按分片或提交尝试重复计数;发送数、到达数和到达率与真实消息记录一致 | +| TC-ANALYTICS-SIGNATURE-016 | 点击任一通道、运营商的近3日“查看趋势” | 折线固定展示含截止日在内的3个自然日,逐日包含发送业务条数、到达业务条数和到达率,无数据日期显示0 | +| TC-ANALYTICS-SIGNATURE-017 | 当天多个消息使用同一签名、另有消息使用第二个签名,并输入签名搜索条件 | “当天发送签名数量”为2且不受搜索条件影响;未关联`signatureId`的消息不计入 | +| TC-ANALYTICS-SIGNATURE-018 | 检查运营端导航、面包屑和日期控件 | 菜单与面包屑显示“签名发送质量”,日期控件语义为“统计截止日”,原`/admin/analytics`路由仍可访问 | +| TC-ANALYTICS-SIGNATURE-019 | 某签名在截止日当天无发送、但此前29日内有发送 | 该签名仍出现在近30日列表并可查看对应周期数据;“当天发送签名数量”不计入该签名 | # 列表后端分页与响应性能测试(2026-07-28) ## TC-LIST-PERF-001 短信记录真实后端分页 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 55f57d6..9abd7c9 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3372,3 +3372,12 @@ git diff --check - 从预生产服务器公网复核:运营登录、客户登录、API health和客户Swagger均为HTTP 200;API独立域名根路径、管理页面和管理通道组接口均为404,主站未认证通道组接口为401。生产前端包已包含“输入通道名称或编码搜索”,运行API源码已包含模板任务快照继续处理逻辑。 - 9条active供应商通道发布重启后6条为`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”3条仍返回`connect response status: auth failed`。本轮未修改其账号、密码、启停状态或连接参数,只保留并报告供应商真实返回。 - 发布依赖审计仍报告根项目3项high、API项目3项moderate和4项high;专用安全缓解门禁通过,未执行可能破坏兼容性的自动升级。本次没有执行任何真实模板、签名、通道或通道组删除,未发送、补发或重投短信,未修改企业余额、客户连接或供应商通道配置。 + +## 2026-08-09 签名发送质量多周期趋势(本地验证完成) + +- 运营端菜单及面包屑更名为“签名发送质量”,日期控件明确为统计截止日。近3/7/30日全部包含截止日当天;前端以自然日补齐趋势日期,不因数据库无记录而省略日期点。 +- `GET /api/admin/operations/signature-quality`将签名列表候选及列表概览扩展为截至所选日期的近30日,并增加当天发送签名去重数,以及当前分页签名近30日按最终通道、运营商、自然日聚合的真实业务短信统计。截止日当天无发送但此前29日有发送的签名仍可查询;统计直接读取`SmsMessageRecord`,不按长短信分片或`SmsSubmitRecord`补发尝试重复计数。 +- 签名详情新增“通道 × 运营商业务质量”表,逐组合展示近3/7/30日发送业务条数、到达业务条数和到达率;点击周期打开双轴折线图,展示逐日发送、到达和到达率。原单日运营商概览、通道提交矩阵和按引流切分继续保留,避免改变既有提交尝试诊断口径。 +- 页面新增“当天发送签名数量”指标,按截止日内关联已登记签名的不同`signatureId`去重,且不受列表关键字筛选影响。未增加数据库字段或migration。 +- Node.js v24下运营统计专项1 suite / 28 tests通过;API正式TypeScript构建、前端TypeScript及Vite v8.1.5生产构建通过(2535 modules,仅保留既有约2.04MB单chunk提示);`operations-r2`结构契约已按本次明确修改的`signatureQuality`行为更新并重新通过。发布与预生产只读验收结果待完成后补记。 +- 发布前通过SSH对预生产PostgreSQL执行同口径只读探测:北京时间2026-08-09当天共有3个已登记签名、218条业务消息;近30日按签名、最终通道、运营商和北京时间自然日分组SQL成功执行并返回真实数据,未修改数据库或触发短信发送。 diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index 919aac9..bb9f250 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -65,6 +65,17 @@ export type SignatureCarrierBusinessQualityStat = { averageArrivalMs?: number | null; }; +export type SignatureChannelCarrierDailyQualityStat = { + signatureId: string; + channelId: string; + channelName: string; + carrier: string; + date: string; + businessMessageCount: number; + deliveredMessageCount: number; + deliveryRate: number; +}; + export type SignatureChannelQualityItem = { signatureId: string; signatureName: string; @@ -83,10 +94,12 @@ export type SignatureChannelQualityItem = { carrierOverview: SignatureCarrierBusinessQualityStat[]; breakdowns: SignatureChannelCarrierQualityStat[]; drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[]; + dailyBreakdowns: SignatureChannelCarrierDailyQualityStat[]; }; export type SignatureChannelQualityResponse = { date: string; + selectedDaySignatureCount: number; items: SignatureChannelQualityItem[]; total: number; page: number; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 7a1d68e..d37f96c 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -4,11 +4,12 @@ import { adminApi, type SendQualityResponse, type SignatureChannelCarrierQualityStat, + type SignatureChannelCarrierDailyQualityStat, type SignatureChannelQualityItem, type SignatureChannelQualityResponse, } from '@/api/adminApi'; import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; -import { createBarOption, createPieOption } from '@/theme/chartOptions'; +import { createBarOption, createPieOption, createQualityTrendOption } from '@/theme/chartOptions'; import { successRateClassName } from '@/utils/successRate'; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; @@ -25,6 +26,13 @@ const carrierLabels: Record = { unknown: '未知', }; +type TrendSelection = { + channelId: string; + channelName: string; + carrier: string; + days: 3 | 7 | 30; +}; + export function AdminAnalyticsPage() { const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); const [quality, setQuality] = useState(null); @@ -104,48 +112,48 @@ export function AdminAnalyticsPage() { }, { key: 'businessTotal', - title: '业务短信', + title: '近30日业务短信', width: '110px', align: 'right', render: (record) => record.total.toLocaleString('zh-CN'), }, { key: 'channelSubmitTotal', - title: '通道提交', + title: '近30日通道提交', width: '110px', align: 'right', render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'), }, { key: 'successCount', - title: '送达成功', + title: '近30日送达', width: '110px', align: 'right', render: (record) => {record.successCount.toLocaleString('zh-CN')}, }, { key: 'failureCount', - title: '送达失败', + title: '近30日送达失败', width: '110px', align: 'right', render: (record) => {record.failureCount.toLocaleString('zh-CN')}, }, { key: 'submitFailureCount', - title: '提交失败', + title: '近30日提交失败', width: '110px', align: 'right', render: (record) => {record.submitFailureCount.toLocaleString('zh-CN')}, }, { key: 'successRate', - title: '成功率', + title: '近30日成功率', width: '170px', render: (record) => , }, { key: 'averageArrivalMs', - title: '平均到达时间', + title: '近30日平均到达', width: '140px', align: 'right', render: (record) => formatDuration(record.averageArrivalMs), @@ -175,11 +183,11 @@ export function AdminAnalyticsPage() {
- +
setStatisticsDate(event.target.value)} type="date" @@ -198,6 +206,11 @@ export function AdminAnalyticsPage() { {quality?.summary.total.toLocaleString('zh-CN') ?? 0} 所选日期真实消息记录
+
+ 当天发送签名数量 + {signatureQuality?.selectedDaySignatureCount.toLocaleString('zh-CN') ?? 0} + {effectiveDate} 有业务短信记录的已登记签名去重数 +
{effectiveDate} 成功率 {(quality?.summary.successRate ?? 0).toFixed(1)}% @@ -232,11 +245,11 @@ export function AdminAnalyticsPage() {
-

签名通道发送质量

+

签名发送质量

已登记签名

- {signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。 + 统计截止日为 {signatureQuality?.date ?? effectiveDate},近 3/7/30 日均包含截止日当天;明细按业务短信最终归属通道与运营商统计。

@@ -254,12 +267,12 @@ export function AdminAnalyticsPage() {
统计说明: - 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。 + 业务短信按消息记录去重;近 3 日、近 7 日、近 30 日分别覆盖截止日及之前 2、6、29 个自然日。
@@ -298,6 +311,7 @@ function SignatureQualityDrawer({ onClose: () => void; }) { const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall'); + const [trendSelection, setTrendSelection] = useState(null); const carriers = item.carrierOverview .map((carrier) => ({ ...carrier, @@ -318,6 +332,10 @@ function SignatureQualityDrawer({ .map((entry) => [entry.channelId, entry.channelName])).entries()] .map(([channelId, channelName]) => ({ channelId, channelName })); const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier)); + const businessQualityRows = [...new Map(item.dailyBreakdowns.map((entry) => [ + `${entry.channelId}:${normalizeCarrier(entry.carrier)}`, + { channelId: entry.channelId, channelName: entry.channelName, carrier: normalizeCarrier(entry.carrier) }, + ])).values()]; return (
{ @@ -335,17 +353,56 @@ function SignatureQualityDrawer({
- - - - + + + +
+
+
+
+

通道 × 运营商业务质量

+

发送和到达均按业务短信去重;点击任一周期查看包含统计截止日的逐日趋势。

+
+
+
+
+ {([3, 7, 30] as const).map((days) => )} + + {businessQualityRows.map((row) => ( + + + + {([3, 7, 30] as const).map((days) => { + const metric = aggregateBusinessQuality(item.dailyBreakdowns, row.channelId, row.carrier, date, days); + return ( + + ); + })} + + ))} + {businessQualityRows.length === 0 ? : null} + +
通道运营商近 {days} 日
{row.channelName}{carrierLabel(row.carrier)} + +
近 30 日暂无业务短信数据
+
+
+

运营商概览

-

按真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。

+

按近30日真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。

@@ -370,7 +427,7 @@ function SignatureQualityDrawer({

通道 × 运营商矩阵

{matrixMode === 'overall' - ? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。' + ? '整体口径展示近30日该组合全部真实提交;“—”表示该期间没有真实提交。' : '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}

@@ -431,11 +488,84 @@ function SignatureQualityDrawer({ 平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。

+ {trendSelection ? ( + setTrendSelection(null)} + selection={trendSelection} + signatureName={item.signatureName} + /> + ) : null} ); } +function SignatureQualityTrend({ + date, + entries, + onClose, + selection, + signatureName, +}: { + date: string; + entries: SignatureChannelCarrierDailyQualityStat[]; + onClose: () => void; + selection: TrendSelection; + signatureName: string; +}) { + const dates = inclusiveDateKeys(date, selection.days); + const points = dates.map((dateKey) => entries.find((entry) => ( + entry.date === dateKey + && entry.channelId === selection.channelId + && normalizeCarrier(entry.carrier) === selection.carrier + ))); + const option = createQualityTrendOption({ + labels: dates.map((dateKey) => dateKey.slice(5)), + sent: points.map((point) => point?.businessMessageCount ?? 0), + delivered: points.map((point) => point?.deliveredMessageCount ?? 0), + rates: points.map((point) => point?.deliveryRate ?? 0), + }); + return ( +
+
+

{signatureName} · 近 {selection.days} 日趋势

{selection.channelName} · {carrierLabel(selection.carrier)} · {dates[0]} 至 {date}(含截止日)

+ +
+ +

折线分别展示每日发送业务条数、到达业务条数和到达率;无发送时到达率按 0% 展示。

+
+ ); +} + +function aggregateBusinessQuality( + entries: SignatureChannelCarrierDailyQualityStat[], + channelId: string, + carrier: string, + date: string, + days: 3 | 7 | 30, +) { + const includedDates = new Set(inclusiveDateKeys(date, days)); + const relevant = entries.filter((entry) => ( + entry.channelId === channelId + && normalizeCarrier(entry.carrier) === carrier + && includedDates.has(entry.date) + )); + const sent = relevant.reduce((sum, entry) => sum + entry.businessMessageCount, 0); + const delivered = relevant.reduce((sum, entry) => sum + entry.deliveredMessageCount, 0); + return { sent, delivered, rate: sent === 0 ? 0 : delivered * 100 / sent }; +} + +function inclusiveDateKeys(endDate: string, days: number) { + const [year, month, day] = endDate.split('-').map(Number); + const end = new Date(Date.UTC(year, month - 1, day)); + return Array.from({ length: days }, (_, index) => { + const current = new Date(end.getTime() - (days - index - 1) * 24 * 60 * 60 * 1000); + return current.toISOString().slice(0, 10); + }); +} + function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) { return (
diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index e053707..859fc7b 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -105,7 +105,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { { label: '运营看板', to: '/admin', icon: Gauge }, { label: '发送监控', to: '/admin/monitor', icon: Activity }, { label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle }, - { label: '数据统计', to: '/admin/analytics', icon: BarChart3 }, + { label: '签名发送质量', to: '/admin/analytics', icon: BarChart3 }, ], }, { diff --git a/src/styles/global.css b/src/styles/global.css index 4a9b26c..eb0b8ed 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -6767,6 +6767,101 @@ font-size: 13px; } +.signature-business-quality-table { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: auto; +} + +.signature-business-quality-table table { + border-collapse: collapse; + min-width: 820px; + width: 100%; +} + +.signature-business-quality-table th, +.signature-business-quality-table td { + border-bottom: 1px solid var(--color-border); + padding: var(--space-3); + text-align: left; + vertical-align: middle; +} + +.signature-business-quality-table thead th { + background: var(--color-surface-muted); + color: var(--color-text-muted); + font-size: 13px; +} + +.signature-business-quality-table tbody tr:last-child > * { + border-bottom: 0; +} + +.signature-business-quality-metric { + background: transparent; + border: 0; + cursor: pointer; + display: grid; + gap: 3px; + padding: 0; + text-align: left; +} + +.signature-business-quality-metric strong { + color: var(--color-text-strong); +} + +.signature-business-quality-metric span { + font-weight: 600; +} + +.signature-business-quality-metric small { + color: var(--color-primary); +} + +.signature-business-quality-metric:hover small { + text-decoration: underline; +} + +.signature-quality-trend { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-lg); + left: 50%; + max-width: 880px; + padding: var(--space-5); + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: min(88%, 880px); + z-index: 2; +} + +.signature-quality-trend__heading { + align-items: flex-start; + display: flex; + justify-content: space-between; +} + +.signature-quality-trend__heading h3, +.signature-quality-trend__heading p { + margin: 0; +} + +.signature-quality-trend__heading p { + color: var(--color-text-muted); + margin-top: var(--space-1); +} + +.signature-quality-trend__heading button { + background: transparent; + border: 0; + color: var(--color-text-muted); + cursor: pointer; + padding: var(--space-2); +} + .signature-carrier-grid { display: grid; gap: var(--space-3); diff --git a/src/theme/chartOptions.ts b/src/theme/chartOptions.ts index ee82d7f..9faf103 100644 --- a/src/theme/chartOptions.ts +++ b/src/theme/chartOptions.ts @@ -31,6 +31,30 @@ export function createLineOption(params: { }; } +export function createQualityTrendOption(params: { + labels: string[]; + sent: number[]; + delivered: number[]; + rates: number[]; +}): EChartsOption { + return { + color: [...chartPalette], + grid: { left: 12, right: 18, top: 42, bottom: 8, containLabel: true }, + legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } }, + tooltip: { trigger: 'axis' }, + xAxis: { type: 'category', data: params.labels, ...axisStyle }, + yAxis: [ + { type: 'value', name: '业务条数', minInterval: 1, ...axisStyle }, + { type: 'value', name: '到达率', min: 0, max: 100, ...axisStyle, axisLabel: { formatter: '{value}%', color: themeColors.textMuted } }, + ], + series: [ + { name: '发送业务条数', data: params.sent, type: 'line', smooth: true, symbolSize: 6, lineStyle: { width: 3 } }, + { name: '到达业务条数', data: params.delivered, type: 'line', smooth: true, symbolSize: 6, lineStyle: { width: 3 } }, + { name: '到达率', data: params.rates, type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 6, lineStyle: { width: 3 } }, + ], + }; +} + export function createBarOption(params: { labels: string[]; series: Array<{ name: string; data: number[] }>;