feat: refine operations UI and transport limits
This commit is contained in:
@@ -7,6 +7,9 @@ function createPrismaMock() {
|
||||
tenant: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
@@ -181,9 +184,10 @@ describe('BillingService', () => {
|
||||
it('returns the historical balance after each manual recharge', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.rechargeOrder.findMany.mockResolvedValue([
|
||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
|
||||
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000, operatorId: 'admin-1' },
|
||||
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
|
||||
]);
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', displayName: '运营人员张三', username: 'admin' }]);
|
||||
prisma.accountTransaction.findMany.mockResolvedValue([
|
||||
{ relatedId: 'order-1', balanceAfter: 3000 },
|
||||
{ relatedId: 'order-2', balanceAfter: 2700 },
|
||||
@@ -191,8 +195,8 @@ describe('BillingService', () => {
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
await expect(service.listManualRechargeRecords()).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
|
||||
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000, operatorName: '运营人员张三' }),
|
||||
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700, operatorName: null }),
|
||||
]);
|
||||
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
@@ -201,6 +205,10 @@ describe('BillingService', () => {
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['admin-1'] } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('allows negative manual recharge amounts for balance correction', async () => {
|
||||
|
||||
@@ -163,18 +163,27 @@ export class BillingService {
|
||||
return orders;
|
||||
}
|
||||
|
||||
const transactions = await this.prisma.accountTransaction.findMany({
|
||||
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
this.prisma.accountTransaction.findMany({
|
||||
where: {
|
||||
relatedType: 'recharge_order',
|
||||
relatedId: { in: orderIds },
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
}),
|
||||
operatorIds.length ? this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
}) : [],
|
||||
]);
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||
const operatorNameById = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
|
||||
operatorName: order.operatorId ? operatorNameById.get(order.operatorId) ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -195,13 +204,25 @@ export class BillingService {
|
||||
this.prisma.rechargeOrder.count({ where }),
|
||||
]);
|
||||
const orderIds = orders.map((order) => order.id);
|
||||
const transactions = orderIds.length ? await this.prisma.accountTransaction.findMany({
|
||||
const operatorIds = [...new Set(orders.map((order) => order.operatorId).filter((id): id is string => Boolean(id)))];
|
||||
const [transactions, operators] = await Promise.all([
|
||||
orderIds.length ? this.prisma.accountTransaction.findMany({
|
||||
where: { relatedType: 'recharge_order', relatedId: { in: orderIds } },
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
}) : [];
|
||||
}) : [],
|
||||
operatorIds.length ? this.prisma.user.findMany({
|
||||
where: { id: { in: operatorIds } },
|
||||
select: { id: true, displayName: true, username: true },
|
||||
}) : [],
|
||||
]);
|
||||
const balances = new Map(transactions.map((item) => [item.relatedId, moneyToNumber(item.balanceAfter)]));
|
||||
const operatorNames = new Map(operators.map((operator) => [operator.id, operator.displayName || operator.username]));
|
||||
return {
|
||||
items: orders.map((order) => ({ ...order, balanceAfterCents: balances.get(order.id) ?? null })),
|
||||
items: orders.map((order) => ({
|
||||
...order,
|
||||
balanceAfterCents: balances.get(order.id) ?? null,
|
||||
operatorName: order.operatorId ? operatorNames.get(order.operatorId) ?? null : null,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { configureHttpBodyParsers, DEFAULT_JSON_BODY_LIMIT, IMPORT_JSON_BODY_LIMIT } from './http-body-limits';
|
||||
|
||||
const express = require('express') as () => {
|
||||
use(...args: unknown[]): void;
|
||||
post(path: string, handler: (request: { body?: unknown; rawBody?: Buffer }, response: { json(body: unknown): void }) => void): void;
|
||||
listen(port: number, host: string, callback: () => void): { close(callback: (error?: Error) => void): void; address(): { port: number } | string | null };
|
||||
};
|
||||
const expressModule = require('express') as { json(options: { limit: string }): (...args: unknown[]) => unknown; urlencoded(options: { limit: string; extended: boolean }): (...args: unknown[]) => unknown };
|
||||
const http = require('node:http') as typeof import('node:http');
|
||||
|
||||
describe('configureHttpBodyParsers', () => {
|
||||
it('keeps ordinary JSON bounded while granting only import routes a larger limit', () => {
|
||||
const use = jest.fn();
|
||||
const useBodyParser = jest.fn();
|
||||
|
||||
configureHttpBodyParsers({ use, useBodyParser } as never);
|
||||
|
||||
expect(DEFAULT_JSON_BODY_LIMIT).toBe('2mb');
|
||||
expect(IMPORT_JSON_BODY_LIMIT).toBe('25mb');
|
||||
expect(use).toHaveBeenCalledTimes(1);
|
||||
expect(use).toHaveBeenCalledWith('/api/client/send/imports', expect.any(Function));
|
||||
expect(useBodyParser).toHaveBeenNthCalledWith(1, 'json', { limit: '2mb' });
|
||||
expect(useBodyParser).toHaveBeenNthCalledWith(2, 'urlencoded', { limit: '2mb', extended: true });
|
||||
});
|
||||
|
||||
it('accepts a 3 MiB import JSON body but rejects the same ordinary JSON body', async () => {
|
||||
const serverApp = express();
|
||||
configureHttpBodyParsers({
|
||||
use: serverApp.use.bind(serverApp),
|
||||
useBodyParser(type: 'json' | 'urlencoded', options: { limit: string; extended?: boolean }) {
|
||||
serverApp.use(type === 'json'
|
||||
? expressModule.json({ limit: options.limit })
|
||||
: expressModule.urlencoded({ limit: options.limit, extended: options.extended ?? true }));
|
||||
},
|
||||
} as never);
|
||||
serverApp.post('/api/client/send/imports/preview', (request, response) => response.json({ size: request.rawBody?.length ?? 0 }));
|
||||
serverApp.post('/api/ordinary', (_request, response) => response.json({ accepted: true }));
|
||||
|
||||
const server = await new Promise<ReturnType<typeof serverApp.listen>>((resolve) => {
|
||||
const listening = serverApp.listen(0, '127.0.0.1', () => resolve(listening));
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('test server did not expose a TCP port');
|
||||
const body = JSON.stringify({ content: 'x'.repeat(3 * 1024 * 1024) });
|
||||
const importResponse = await postJSON(address.port, '/api/client/send/imports/preview', body);
|
||||
expect(importResponse.status).toBe(200);
|
||||
expect(JSON.parse(importResponse.body)).toEqual({ size: Buffer.byteLength(body) });
|
||||
await expect(postJSON(address.port, '/api/ordinary', body)).resolves.toMatchObject({ status: 413 });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function postJSON(port: number, path: string, body: string) {
|
||||
return new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const request = http.request({ hostname: '127.0.0.1', port, path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } }, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.once('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
|
||||
});
|
||||
request.once('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
const express = require('express') as {
|
||||
json(options: {
|
||||
limit: string;
|
||||
verify(request: { rawBody?: Buffer }, response: unknown, buffer: Buffer): void;
|
||||
}): (...args: unknown[]) => unknown;
|
||||
};
|
||||
|
||||
export const DEFAULT_JSON_BODY_LIMIT = '2mb';
|
||||
export const IMPORT_JSON_BODY_LIMIT = '25mb';
|
||||
|
||||
export function configureHttpBodyParsers(app: NestExpressApplication) {
|
||||
// Import preview/confirmation temporarily carries the source CSV/TSV in
|
||||
// JSON. Give only these endpoints the larger boundary; keeping ordinary
|
||||
// JSON at 2 MiB limits the duplicate raw-buffer + parsed-object footprint.
|
||||
app.use('/api/client/send/imports', express.json({
|
||||
limit: IMPORT_JSON_BODY_LIMIT,
|
||||
verify(request, _response, buffer) {
|
||||
request.rawBody = buffer;
|
||||
},
|
||||
}));
|
||||
app.useBodyParser('json', { limit: DEFAULT_JSON_BODY_LIMIT });
|
||||
app.useBodyParser('urlencoded', { limit: DEFAULT_JSON_BODY_LIMIT, extended: true });
|
||||
}
|
||||
+4
-1
@@ -1,8 +1,10 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
import { configureHttpBodyParsers } from './http-body-limits';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
@@ -16,8 +18,9 @@ Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true, bodyParser: false });
|
||||
app.setGlobalPrefix('api');
|
||||
configureHttpBodyParsers(app);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('CMPP Platform API')
|
||||
|
||||
@@ -65,15 +65,15 @@
|
||||
},
|
||||
{
|
||||
"name": "SignatureReportModal",
|
||||
"canonicalSha256": "7439104e171e7d9eeb8449f497696b6d3736b881195c0b96a1a096079ba0789b"
|
||||
"canonicalSha256": "4cfec954f564c998af70da441971bede16915f4bb5ac9c10e45da15101957de5"
|
||||
},
|
||||
{
|
||||
"name": "ChannelReportStatusModal",
|
||||
"canonicalSha256": "d32f8d7d3025ee286ed7898ed41a22404db3d684b80d23819965b3332e0e8310"
|
||||
"canonicalSha256": "e0717a89302e477fd1e57f18e3f97b993ebe3c7c42ccbfcd2761110054f6abcb"
|
||||
},
|
||||
{
|
||||
"name": "DrainageReportModal",
|
||||
"canonicalSha256": "716a2d480a4b652ee951de0306854f1d8f6d72759d7b2fbffbfbb600abc8a789"
|
||||
"canonicalSha256": "d2b41c26eb3992bb1ff3b50ee5ef6c06e7f84d89832e3ec7446b35178520431f"
|
||||
},
|
||||
{
|
||||
"name": "DrainageReportStatusModal",
|
||||
@@ -84,7 +84,7 @@
|
||||
"canonicalSha256": "2f71bdc2f8044ca9b403affbb0cb3f31587e4c004a90e9e0836aa6143033b6da"
|
||||
}
|
||||
],
|
||||
"tableJsxSha256": "203ec16e0ca67f0dafea08d659e84db02dd8346d06a52f41378625e39a43def7",
|
||||
"tableJsxSha256": "5cae4926ab10a762b26d77d0a0633b85de68fa204b7d5732ba0942e7fc276234",
|
||||
"apiCalls": [
|
||||
"listEnterpriseSignaturesPage",
|
||||
"listTenants",
|
||||
|
||||
@@ -2034,3 +2034,17 @@
|
||||
5. 跳过只表示未调用 Gateway,第一版原因包括:状态已变化、已被客户确认、已被其他任务处理、本任务已成功处理、不属于任务快照、应用或投递能力已停用、投递数据不完整、缺少原 Submit 映射。
|
||||
6. 任务必须支持列表、详情、暂停、继续和终止;终止只影响尚未发送的记录。任务项以 `taskId + deliveryId` 幂等,执行前原子认领并复核状态,API 重启后可继续,成功 ACK 的项目不得再次发送。
|
||||
7. 所有创建、暂停、继续、终止和自动暂停均写操作日志;任务使用真实 PostgreSQL、Gateway 和客户 ACK,不得使用 mock、静态数据或 localStorage。
|
||||
# 2026-08-13 HTTP 请求与 Gateway API 响应容量边界
|
||||
|
||||
1. Go Gateway 调用 NestJS API 时必须完整读取最多 `4 MiB` 的响应体;超过边界必须返回包含实际容量边界的明确传输错误,禁止静默截断后以 `unexpected end of JSON input` 等语法错误掩盖容量问题。该边界覆盖待投递回执恢复批次等内部响应,不改变单次恢复查询的业务分页口径。
|
||||
2. NestJS 普通 JSON 与 URL-encoded 请求体统一限制为 `2 MiB`。客户号码文件导入预览和确认接口因现阶段仍在 JSON 中携带 CSV/TSV 正文,单独限制为 `25 MiB`;业务层继续限制原始导入正文不超过 `20 MiB`,为 JSON 字段、转义字符和其他参数保留协议开销。
|
||||
3. 大容量解析器只能挂载到 `/api/client/send/imports/*`,不得把所有管理、客户和公网 HTTP API 全局放宽到 25 MiB。`rawBody` 必须在两类解析器中继续保留,确保公网 HTTP API 的验签与幂等正文哈希语义不变。
|
||||
4. 客户导入接口经 `sms.lisglo.com` 私有 API 访问,该入口的 Nginx 请求体上限必须不低于 `30 MiB`,当前标准配置 `50 MiB` 满足要求。`api.lisglo.com` 只开放单条客户 HTTP API、Swagger 和健康检查,不承载文件导入,不得为导入需求扩大其暴露路由。
|
||||
5. 容量边界必须由自动化测试覆盖:大于旧 `64 KiB` 且小于 `4 MiB` 的合法 Gateway 响应完整解析;超过 `4 MiB` 明确拒绝;同一份大于 `2 MiB` 的 JSON 仅在导入路由可被解析,普通路由返回 HTTP 413。
|
||||
|
||||
# 2026-08-13 运营端信息密度与运营商标签统一
|
||||
|
||||
1. 运营端充值记录回执必须展示该笔人工充值的操作人员姓名。姓名由充值单已保存的`operatorId`关联真实用户记录取得,优先展示姓名、姓名缺失时回退用户名;历史无操作人的系统记录展示“系统”,不得用前端静态映射伪造。
|
||||
2. 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个明细模块;“今日活跃签名”指标仍使用当天真实发送聚合。今日消费金额的主数字必须与今日发送总量使用相同字号、字重和深色层级。
|
||||
3. 企业应用管理列表的状态、到达率、单价列在现有基础上缩窄约20%,提升大屏一次展示完整表格的概率;不得通过隐藏真实字段实现。
|
||||
4. 移动、联通、电信数据展示统一使用全局`CarrierTag`低饱和标签,包括通道与通道组、监控、报备、签名、短信审核、任务号码、发送记录及客户端发送详情等页面。筛选/表单控件的选项文案、图表图例、导出文本和业务说明仍使用纯文本,避免破坏交互、可访问性和机器可读输出;可取得运营商集合的三网通道按三个运营商标签展示,只有历史通道级字段时使用中性的“三网”标签。
|
||||
|
||||
@@ -70,6 +70,8 @@ PROD_ADMIN_PASSWORD='change-me'
|
||||
|
||||
`API_ENABLE_SEND_WORKER=true` 是生产发送链路必填项。后续发布脚本会在构建和迁移前校验该开关以及正整数 `API_SEND_WORKER_CONCURRENCY`;缺失时直接终止发布,防止 API/Gateway 健康但 BullMQ 短信队列无人消费。
|
||||
|
||||
HTTP容量边界固定为:NestJS普通JSON/URL-encoded请求体`2 MiB`,仅`/api/client/send/imports/*`使用`25 MiB` JSON解析上限,原始CSV/TSV正文继续由业务层限制为`20 MiB`;Gateway读取NestJS API响应最多`4 MiB`且超限必须明确报错。客户文件导入走`sms.lisglo.com`私有API,因此该虚拟主机的`client_max_body_size`必须不低于`30m`,标准bootstrap配置为`50m`。`api.lisglo.com`只承载单条公网HTTP API、Swagger和健康检查,不承载客户文件导入;不要为导入需求开放私有路由或把NestJS所有JSON接口统一放宽到25MiB。发布前使用`nginx -T`确认最终生效值,不能只检查仓库模板。
|
||||
|
||||
Gateway 的最终 TPS 防线依赖与 API 相同的 Redis。通道连接时会写入 `rate:gateway:channel:config:<channelId>` 权威上限,实际预约使用 `rate:gateway:channel:<channelId>`;这些 key 不应在正常发布时清理。多 Gateway 实例必须指向同一 Redis,才能共享单通道额度。超速的 `gateway.submit.commands` 消息会保持在 consumer group pending 中等待,不应通过手工 `XACK` 或删除 Stream 处理积压;先检查通道配置、Redis key、consumer group 和 Gateway 日志。
|
||||
|
||||
服务重启顺序必须是 Gateway 在前、API 在后。API 启动后等待 `GATEWAY_STARTUP_RECONNECT_DELAY_MS`(默认 1 秒),从 PostgreSQL 读取全部 active 通道并重新下发真实连接命令,同时恢复 Gateway 内存连接池和 Redis 权威 TPS key;禁止沿用数据库中重启前的 connected 状态冒充当前连接。
|
||||
|
||||
@@ -4596,3 +4596,21 @@ npm run verify:phase8
|
||||
| TC-DOWNSTREAM-REQUEUE-TASK-007 | 任务控制 | 待执行/执行中任务可暂停、继续、终止;终止不撤回已写出消息。 |
|
||||
| TC-DOWNSTREAM-REQUEUE-TASK-008 | 审计 | 创建、暂停、继续、终止和自动暂停记录操作人、筛选快照、原因和结果。 |
|
||||
| TC-DOWNSTREAM-PAGE-SIZE-001 | 分页数量 | 可选 10/25/50;切换回第一页,后端返回对应条数,总数和筛选条件保持一致。 |
|
||||
# 2026-08-13 HTTP 与 Gateway 报文容量专项用例
|
||||
|
||||
| 用例编号 | 优先级 | 验证内容 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| TC-TRANSPORT-SIZE-001 | P0 | 模拟 NestJS 返回约128KiB的合法待投递回执JSON,由Gateway通用API传输方法读取并反序列化 | 响应完整解析,字段长度与服务端输出一致,不出现64KiB截断或JSON语法错误 |
|
||||
| TC-TRANSPORT-SIZE-002 | P0 | 模拟NestJS返回超过4MiB的响应 | Gateway停止读取并返回`api response exceeds 4194304-byte limit`容量错误,不返回`unexpected end of JSON input`,不把不完整数据当作成功结果 |
|
||||
| TC-HTTP-BODY-001 | P0 | 分别向普通JSON接口和`/api/client/send/imports/preview`提交约3MiB合法JSON | 普通接口在Controller前返回413;导入接口成功解析且保留rawBody,证明25MiB解析器只对导入路由生效 |
|
||||
| TC-HTTP-BODY-002 | P1 | 分别测试普通JSON 2MiB边界、导入JSON 25MiB边界及业务层原始正文20MiB边界 | 边界内请求正常进入业务校验;超过解析器边界返回413;超过20MiB原始导入正文返回明确业务错误且不创建发送任务 |
|
||||
| TC-NGINX-BODY-001 | P1 | 检查预生产有效Nginx配置及域名路由 | `sms.lisglo.com`请求体上限不低于30MiB并承载私有导入接口;`api.lisglo.com`不暴露私有导入路由且其现有上限不影响单条公网HTTP API |
|
||||
|
||||
# 2026-08-13 运营端信息密度与运营商标签统一专项用例
|
||||
|
||||
| 编号 | 场景 | 步骤 | 预期结果 |
|
||||
| --- | --- | --- | --- |
|
||||
| TC-BILLING-RECEIPT-OPERATOR-001 | 人工充值回执展示操作人员姓名 | 使用有`operatorId`的真实人工充值记录查询充值列表并打开回执 | API按用户表返回`operatorName`,回执显示姓名而非用户ID;无操作人的历史记录显示“系统”,关联用户确已不存在时显示“未知操作人员” |
|
||||
| TC-DASHBOARD-SIGNATURE-REMOVE-001 | 删除看板签名统计模块并统一金额样式 | 打开运营看板,检查指标卡与后续模块 | 两个“今日签名发送统计”模块均不存在;今日消费金额与今日发送总量主数字字号、字重和颜色一致;今日活跃签名仍来自真实接口 |
|
||||
| TC-ENTERPRISE-APP-WIDTH-001 | 企业应用关键列缩窄 | 在桌面大屏打开企业应用管理并读取表头列宽 | 状态、到达率、单价列宽均约为原宽度80%,字段与操作均未隐藏,横向滚动需求减少 |
|
||||
| TC-UI-CARRIER-TAG-002 | 全局运营商数据展示复用通用标签 | 抽查通道/通道组、监控、报备、企业签名、短信审核、批次号码、发送记录和客户端发送详情 | 移动、联通、电信均使用全局低饱和胶囊及统一色值;有运营商集合的三网通道显示三个标签,历史通道级字段显示中性“三网”;筛选选项、图表图例与导出文本保持纯文本 |
|
||||
|
||||
@@ -3541,3 +3541,19 @@ git diff --check
|
||||
- 最小修复为将原始企业、应用字段加入`GROUP BY`,保持“正文签名 × 实际企业应用”业务口径、搜索、计数和分页不变;同步新增`TC-SIGNATURE-RETIREMENT-029`,防止测试桩只验证返回映射而遗漏真实PostgreSQL语法约束。
|
||||
- 签名清退专项9/9、API TypeScript正式构建和`git diff --check`通过。热修复提交`4c70978da4e3bd22ea159313c95b36ca18d150d6`已推送,并采用API最小热发布:备份位于`/opt/cmpp-platform-backups/releases/20260812-175436-before-hotfix-4c70978d`,只替换本次API源码/编译产物并重启`cmpp-api`,未重启Gateway、Nginx或短信通道。
|
||||
- 发布后运行标识已核对为`4c70978d`,API和Gateway健康通过。使用生产真实PostgreSQL执行与接口相同的完整聚合SQL成功返回1组、266条短信,未再出现`42803`;热发布后的API日志没有新增`ExceptionsHandler`或Prisma聚合异常。现有浏览器没有可接管的登录页,因此未伪造账号会话;页面可由用户直接刷新验收。
|
||||
# 2026-08-13 Gateway响应截断与NestJS请求体容量修复(本地未提交、未发布)
|
||||
|
||||
- 只读复核确认Gateway通用API传输方法使用`io.LimitReader(resp.Body, 64*1024)`静默截断响应;待投递回执恢复查询单批100条时,生产真实JSON已可超过该边界并形成`unexpected end of JSON input`。本轮将完整响应上限调整为4MiB,并额外读取1字节识别超限:超过边界返回明确容量错误,不再把传输截断伪装成JSON语法错误。
|
||||
- NestJS关闭框架自动注册的默认100KiB body parser,显式注册普通JSON/URL-encoded 2MiB解析器;仅`/api/client/send/imports/*`先注册25MiB JSON解析器。两类JSON解析器继续保存`rawBody`,公网HTTP API验签和幂等正文哈希语义不变;导入业务层原始正文20MiB限制保持不变。
|
||||
- 域名链路重新核对:客户导入使用`sms.lisglo.com`私有API,其标准Nginx上限50MiB已覆盖25MiB解析需求;`api.lisglo.com`只开放单条HTTP API、客户Swagger和健康检查,不承载文件导入,因此本轮不错误扩大API专用域名或开放私有路由。
|
||||
- 新增专项自动化:Gateway可完整解析约128KiB合法响应,超过4MiB返回`api response exceeds 4194304-byte limit`且不出现截断JSON错误;同一份约3MiB JSON在导入路由返回200、保留完整rawBody,普通路由返回413。API容量专项2/2、API全量37套/460项、API TypeScript正式构建、Gateway inbound专项、Gateway全量`go test ./... -count=1`及`go vet ./...`、4份Gateway队列契约和`git diff --check`均通过。
|
||||
- 19份既有结构门禁中10份通过、9份失败;失败均来自当前`HEAD`在本轮开始前已经存在的契约漂移(下游重投/异常处置API、通道排序、签名弹窗、签名质量查询、报备导出、运营商集合、定时调度、长文本样式和签名查询),与本轮新增/修改文件无交集。本轮不为通过门禁而顺手刷新或改写其他会话业务契约,保留为既有基线问题单独治理。
|
||||
- 本轮没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额、客户连接或生产数据;代码保持未提交、未推送、未部署。受保护的`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`不删除、不提交、不归因。
|
||||
|
||||
# 2026-08-13 充值回执操作人员、看板精简与运营商标签统一(待提交、未发布)
|
||||
|
||||
- 充值记录列表接口使用充值单既有`operatorId`批量查询真实用户,返回`displayName`(缺失时回退`username`)作为`operatorName`;充值回执新增“操作人员”,历史无操作人的系统记录展示“系统”。未新增字段或migration,避免复制姓名造成历史数据与用户资料不一致。
|
||||
- 运营看板删除“今日签名发送统计”和“今日签名发送统计 - 含引流”两个模块及其分页状态;今日活跃签名继续使用真实发送质量接口聚合。指标文案调整为“今日消费金额”,主数字直接复用与今日发送总量相同的`metric-card strong`样式。
|
||||
- 企业应用管理的状态、到达率、单价列宽均从130px缩至104px,字段和操作未隐藏。全局审计运营商数据展示后,监控、通道/通道组、应用路由、签名和报备、清退、短信审核、批次号码、发送记录及客户端发送详情统一复用`CarrierTag`;筛选选项、图表图例、导出和说明文字保留纯文本,三网通道展开为三个标签。
|
||||
- API全量37套/460项、充值和HTTP容量专项14/14、前后端TypeScript、Vite 8.1.5生产构建、Gateway全量测试与`go vet`、4份Gateway队列契约、企业应用R11、短信记录R11、企业签名R4结构契约和`git diff --check`均通过;Vite仅保留既有约2.07MiB单chunk提示。企业签名R4哈希只按本次经验证的运营商标签JSX同步更新,未放宽模块边界。浏览器连接本地页面时既有登录会话已失效,未输入账号密码或验证码,因此页面级视觉验收未完成。
|
||||
- 本轮与此前未提交的HTTP/Gateway容量修复一并进入待提交范围,未发送、补发或重投短信,未修改通道配置、余额、客户连接或生产数据;受保护缓存、`outputs/`和空文件`=`不删除、不提交、不归因。
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxAPIResponseBodyBytes int64 = 4 * 1024 * 1024
|
||||
|
||||
func (s Server) post(ctx context.Context, path string, payload any, result any) error {
|
||||
client := s.HTTPClient
|
||||
if client == nil {
|
||||
@@ -31,10 +33,17 @@ func (s Server) post(ctx context.Context, path string, payload any, result any)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
// Read one byte beyond the supported boundary so an oversized upstream
|
||||
// response is reported explicitly. Silently cutting JSON at the boundary
|
||||
// turns a transport-capacity problem into a misleading syntax error and can
|
||||
// leave recoverable downstream receipts stuck indefinitely.
|
||||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBodyBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read api response: %w", err)
|
||||
}
|
||||
if int64(len(responseBody)) > maxAPIResponseBodyBytes {
|
||||
return fmt.Errorf("api response exceeds %d-byte limit", maxAPIResponseBodyBytes)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
detail := strings.TrimSpace(string(responseBody))
|
||||
if detail == "" {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package inbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPostAcceptsAPIResponseLargerThanLegacy64KiB(t *testing.T) {
|
||||
payload := strings.Repeat("x", 128*1024)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"payload": payload})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var result struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/large", map[string]string{"request": "ok"}, &result); err != nil {
|
||||
t.Fatalf("post response larger than 64KiB: %v", err)
|
||||
}
|
||||
if result.Payload != payload {
|
||||
t.Fatalf("payload length = %d, want %d", len(result.Payload), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostRejectsAPIResponseBeyondFourMiBWithoutTruncatedJSONError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"payload":"` + strings.Repeat("x", int(maxAPIResponseBodyBytes)) + `"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var result map[string]any
|
||||
err := (Server{APIBaseURL: server.URL}).post(context.Background(), "/too-large", map[string]string{"request": "ok"}, &result)
|
||||
if err == nil || !strings.Contains(err.Error(), "api response exceeds 4194304-byte limit") {
|
||||
t.Fatalf("error = %v, want explicit response-size error", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "unexpected end of JSON input") {
|
||||
t.Fatalf("oversized response must not surface as truncated JSON: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,7 @@ export type RechargeOrder = {
|
||||
payMethod?: string | null;
|
||||
paidAt?: string | null;
|
||||
operatorId?: string | null;
|
||||
operatorName?: string | null;
|
||||
remark?: string | null;
|
||||
balanceAfterCents?: number | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -537,13 +537,13 @@ function SignatureQualityDrawer({
|
||||
{matrixMode === 'overall' ? (
|
||||
<tr>
|
||||
<th>通道名称</th>
|
||||
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
|
||||
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<th>通道名称</th>
|
||||
<th>引流类型</th>
|
||||
{majorCarrierOrder.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
|
||||
{majorCarrierOrder.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></th>)}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
|
||||
import { adminApi, type AdminChannel, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi';
|
||||
|
||||
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
const carrierLabels: Record<GroupCarrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
};
|
||||
|
||||
function formatRetryLimit(group: ChannelGroup) {
|
||||
if (group.retryEnabled === false) return '已关闭';
|
||||
const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
|
||||
@@ -154,7 +146,7 @@ export function AdminChannelGroupsPage() {
|
||||
<span className="channel-group-config-item__icon"><Layers3 size={18} /></span>
|
||||
<div>
|
||||
<strong>{group.name}</strong>
|
||||
<span>{carrierLabels[group.carrier] ?? group.carrier}通道组</span>
|
||||
<span><CarrierTag carrier={group.carrier} /> 通道组</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { successRateClassName } from '@/utils/successRate';
|
||||
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
|
||||
@@ -185,7 +185,7 @@ export function AdminChannelReportPage() {
|
||||
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
|
||||
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
|
||||
<span />
|
||||
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : `${signature?.tenant?.name ?? task.tenantId} · ${task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}`}</small></span></div>
|
||||
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
|
||||
<ReportStatus value={task.status} />
|
||||
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
|
||||
<DateTime value={reportedAt} />
|
||||
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
Chart,
|
||||
Modal,
|
||||
MoneyText,
|
||||
Pagination,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
|
||||
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
||||
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
@@ -29,12 +28,6 @@ type EnterpriseSpendRank = {
|
||||
availableBalance: number;
|
||||
};
|
||||
|
||||
type RankedSignatureQualityStat = SignatureQualityStat & {
|
||||
rank: number;
|
||||
};
|
||||
|
||||
const SIGNATURE_PAGE_SIZE = 10;
|
||||
|
||||
const balanceTone = {
|
||||
充足: 'success',
|
||||
紧张: 'warning',
|
||||
@@ -55,8 +48,6 @@ export function AdminHome() {
|
||||
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||
const [plainSignaturePage, setPlainSignaturePage] = useState(1);
|
||||
const [drainageSignaturePage, setDrainageSignaturePage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
|
||||
@@ -146,36 +137,6 @@ export function AdminHome() {
|
||||
},
|
||||
];
|
||||
|
||||
const signatureColumns: Array<TableColumn<RankedSignatureQualityStat>> = [
|
||||
{ key: 'rank', title: '排名', width: '72px', render: (record) => record.rank },
|
||||
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
|
||||
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
|
||||
{ key: 'submitFailureCount', title: '提交失败', align: 'right', render: (record) => formatCount(record.submitFailureCount) },
|
||||
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
|
||||
{ key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) },
|
||||
{ key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) },
|
||||
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
|
||||
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}秒` },
|
||||
];
|
||||
const plainSignatureQuality = quality?.signatures ?? [];
|
||||
const drainageSignatureQuality = quality?.drainageSignatures ?? [];
|
||||
const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||
const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE));
|
||||
const pagedPlainSignatureQuality = plainSignatureQuality
|
||||
.slice((plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE, plainSignaturePage * SIGNATURE_PAGE_SIZE)
|
||||
.map((item, index) => ({ ...item, rank: (plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
|
||||
const pagedDrainageSignatureQuality = drainageSignatureQuality
|
||||
.slice((drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE, drainageSignaturePage * SIGNATURE_PAGE_SIZE)
|
||||
.map((item, index) => ({ ...item, rank: (drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
|
||||
|
||||
useEffect(() => {
|
||||
setPlainSignaturePage((page) => Math.min(page, plainSignatureTotalPages));
|
||||
}, [plainSignatureTotalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setDrainageSignaturePage((page) => Math.min(page, drainageSignatureTotalPages));
|
||||
}, [drainageSignatureTotalPages]);
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-dashboard">
|
||||
<div className="overview-hero admin-dashboard-hero">
|
||||
@@ -205,8 +166,8 @@ export function AdminHome() {
|
||||
<small>delivered / 今日总量</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>今日消费</span>
|
||||
<strong><MoneyText>¥{formatCurrency(todaySpend)}</MoneyText></strong>
|
||||
<span>今日消费金额</span>
|
||||
<strong>¥{formatCurrency(todaySpend)}</strong>
|
||||
<small>来自今日消息金额聚合</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
@@ -230,49 +191,6 @@ export function AdminHome() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-grid admin-signature-rank-grid">
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计</h2>
|
||||
<p className="muted">按签名汇总当天全部真实发送,不区分是否包含引流信息。</p>
|
||||
</div>
|
||||
<Tag tone="info">{plainSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无签名发送记录" rowKey="id" />
|
||||
<Pagination
|
||||
total={plainSignatureQuality.length}
|
||||
page={plainSignaturePage}
|
||||
totalPages={plainSignatureTotalPages}
|
||||
previousDisabled={plainSignaturePage <= 1}
|
||||
nextDisabled={plainSignaturePage >= plainSignatureTotalPages}
|
||||
onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))}
|
||||
onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))}
|
||||
onPageChange={setPlainSignaturePage}
|
||||
/>
|
||||
</div>
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>今日签名发送统计 - 含引流</h2>
|
||||
<p className="muted">只统计短信内容识别为含引流信息的发送效果。</p>
|
||||
</div>
|
||||
<Tag tone="accent">{drainageSignatureQuality.length} 个签名</Tag>
|
||||
</div>
|
||||
<Table columns={signatureColumns} data={pagedDrainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
|
||||
<Pagination
|
||||
total={drainageSignatureQuality.length}
|
||||
page={drainageSignaturePage}
|
||||
totalPages={drainageSignatureTotalPages}
|
||||
previousDisabled={drainageSignaturePage <= 1}
|
||||
nextDisabled={drainageSignaturePage >= drainageSignatureTotalPages}
|
||||
onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))}
|
||||
onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))}
|
||||
onPageChange={setDrainageSignaturePage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||
|
||||
const columns: Array<TableColumn<AdminChannel>> = [
|
||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||
{ key: 'carrier', title: '运营商', render: (record) => carrierLabel(record.carrier) },
|
||||
{ key: 'carrier', title: '运营商', render: (record) => {
|
||||
const carriers = record.carriers?.length ? record.carriers : record.carrier === 'all' ? ['mobile', 'unicom', 'telecom'] : record.carrier ? [record.carrier] : [];
|
||||
return carriers.length ? <span className="ui-carrier-tags">{carriers.map((carrier) => <CarrierTag carrier={carrier} key={carrier} />)}</span> : '-';
|
||||
} },
|
||||
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
||||
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
||||
{
|
||||
@@ -16,22 +19,6 @@ const columns: Array<TableColumn<AdminChannel>> = [
|
||||
},
|
||||
];
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
mobile: '移动',
|
||||
cmcc: '移动',
|
||||
unicom: '联通',
|
||||
cucc: '联通',
|
||||
telecom: '电信',
|
||||
ctcc: '电信',
|
||||
all: '三网',
|
||||
unknown: '未识别',
|
||||
};
|
||||
|
||||
function carrierLabel(value?: string | null) {
|
||||
if (!value) return '-';
|
||||
return carrierLabels[value.trim().toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
export function AdminMonitorPage() {
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
CarrierTag,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -263,7 +264,7 @@ export function AdminReportMaterialsPage() {
|
||||
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}>关闭</Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">取消</Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
|
||||
<div className="report-batch-preflight">
|
||||
{preflightBusy ? <p role="status">正在核对资料版本、应用路由、通道字段与历史批次…</p> : null}
|
||||
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} />可生成 {preflight.eligibleTargetCount} 个资料通道组合</span><span><AlertTriangle size={17} />跳过 {preflight.skippedTargetCount} 个组合</span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join(';')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join(';')}</p>}</article>)}</> : null}
|
||||
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} />可生成 {preflight.eligibleTargetCount} 个资料通道组合</span><span><AlertTriangle size={17} />跳过 {preflight.skippedTargetCount} 个组合</span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · <CarrierTag carrier={target.carrier} /></span><small>{target.eligible ? '资格通过' : target.blockedReasons.join(';')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join(';')}</p>}</article>)}</> : null}
|
||||
{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>报备批次 {batchResult.batchNo} 已处理</strong><span>成功 {batchResult.result.successCount} · 跳过 {batchResult.result.skippedCount} · 失败 {batchResult.result.failedCount}</span><span>操作单号:{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Eye, Search } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
|
||||
@@ -39,7 +39,7 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
|
||||
<div><span>企业</span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
|
||||
<div><span>企业应用</span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
|
||||
<div><span>通道</span><strong>{task.channel?.name ?? task.channelId}</strong></div>
|
||||
{task.reportType !== 'drainage' ? <div><span>运营商</span><strong>{task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}</strong></div> : null}
|
||||
{task.reportType !== 'drainage' ? <div><span>运营商</span>{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong>历史通道级(未拆分)</strong>}</div> : null}
|
||||
{task.reportType !== 'drainage' ? <div><span>当前通过时间</span><strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong></div> : null}
|
||||
<div><span>当前状态</span><Tag tone={status.tone}>{status.label}</Tag></div>
|
||||
<div><span>创建时间</span><strong>{formatDateTime(task.createdAt)}</strong></div>
|
||||
@@ -130,7 +130,7 @@ export function AdminReportTasksPage() {
|
||||
const columns: Array<TableColumn<ReportTask>> = [
|
||||
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
|
||||
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
|
||||
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[record.carrier] : '历史通道级(未拆分)'}</div> : null}</div> },
|
||||
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}</div> },
|
||||
{ key: 'batch', title: '批次/版本', render: (record) => {
|
||||
const source = record.exportItems?.[0];
|
||||
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · 第{source.rowNumber}行</div></div> : '-';
|
||||
|
||||
@@ -119,7 +119,7 @@ export function AdminSignatureRetirementPage() {
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) => <div className="page-actions">{!item.isRead ? <Button onClick={() => void readMessage(item.id)} size="sm" variant="ghost">标为已读</Button> : null}{!item.suppressed ? <Button onClick={() => openSuppression(item.id)} size="sm" variant="ghost">抑制</Button> : null}</div> },
|
||||
];
|
||||
const suppressionColumns: Array<TableColumn<SignatureRetirementSuppression>> = [
|
||||
{ key: 'dimension', title: '维度', render: (item) => `${item.dimensionType === 'enterprise' ? '企业' : '通道'} / ${carrierLabels[item.carrier]}` },
|
||||
{ key: 'dimension', title: '维度', render: (item) => <span>{item.dimensionType === 'enterprise' ? '企业' : '通道'} / <CarrierTag carrier={item.carrier} /></span> },
|
||||
{ key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` },
|
||||
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<ShieldOff size={15} />} onClick={() => { setActionError(''); setCancelSuppressionDraft({ id: item.id, reason: '' }); }} size="sm" variant="ghost">取消抑制</Button> },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui';
|
||||
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { createRandomHex } from '@/utils/randomId';
|
||||
|
||||
@@ -450,7 +450,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<header>
|
||||
<span><RadioTower size={18} /></span>
|
||||
<div>
|
||||
<strong>{meta.label}通道组</strong>
|
||||
<strong><CarrierTag carrier={card.carrier} /> 通道组</strong>
|
||||
<small>{meta.description}</small>
|
||||
</div>
|
||||
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type RiskReviewTask, type RiskTaskMessagePage } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
@@ -263,7 +263,7 @@ export function AdminSmsAuditPage() {
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier ? <CarrierTag carrier={item.carrier} /> : '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'pending_review' ? 'warning' : item.status === 'failed' || item.status === 'submit_failed' ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={phoneData.items}
|
||||
|
||||
@@ -55,8 +55,8 @@ export function EnterpriseApplicationTable({
|
||||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => <strong className="application-unit-price">{formatAmount(record.unitPrice)} 元</strong> },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '104px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '104px', render: (record) => <strong className="application-unit-price">{formatAmount(record.unitPrice)} 元</strong> },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: '客户连接状态',
|
||||
@@ -89,7 +89,7 @@ export function EnterpriseApplicationTable({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: applicationStatusTag },
|
||||
{ key: 'enabled', title: '状态', width: '104px', render: applicationStatusTag },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Plus } from 'lucide-react';
|
||||
import type { ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
|
||||
import { Button, CarrierTag, DeleteRiskAction, Pagination } from '@/components/ui';
|
||||
import { AuditStatusTag, CarrierReportTag, readDrainagePayload, signatureCardVisual } from './signature.helpers';
|
||||
import type { DrainageInfo } from './signature.types';
|
||||
|
||||
@@ -63,9 +63,9 @@ export function EnterpriseSignaturesTable({
|
||||
<div><span>企业</span><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
|
||||
<div><span>应用</span><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
|
||||
<div><span>签名审核</span><AuditStatusTag status={signature.auditStatus} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
|
||||
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
|
||||
<div><span>引流信息</span><strong>{payload.links.length} 条</strong></div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
@@ -82,9 +82,9 @@ export function EnterpriseSignaturesTable({
|
||||
<div className="drainage-table__head">
|
||||
<span>引流url或号码</span>
|
||||
<span>审核状态</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<CarrierTag carrier="mobile" />
|
||||
<CarrierTag carrier="unicom" />
|
||||
<CarrierTag carrier="telecom" />
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{visibleDrainageLinks.map((item) => {
|
||||
|
||||
@@ -23,11 +23,11 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
|
||||
<div><span>更新时间</span><strong>{formatDate(item.updatedAt)}</strong></div>
|
||||
</div>
|
||||
<div className="admin-report-tabs">
|
||||
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
<button className="admin-report-carrier--mobile active" type="button"><CarrierTag carrier="mobile" /><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><CarrierTag carrier="unicom" /><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><CarrierTag carrier="telecom" /><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name} <CarrierTag carrier={target.carrier} /></span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -64,9 +64,9 @@ export function DrainageReportModal({ item, onClose, signature }: { item: Draina
|
||||
<div className="detail-grid">
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>引流url或号码</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={summary?.mobile} /></div>
|
||||
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={summary?.unicom} /></div>
|
||||
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { AlertTriangle, Info, MessageSquare } from 'lucide-react';
|
||||
import type { SmsMessageRecord, SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
import { Button, CarrierTag, Modal, Tag } from '@/components/ui';
|
||||
import {
|
||||
buildRouteRows,
|
||||
getCarrierLabel,
|
||||
getReceiptNotice,
|
||||
getRecordStatus,
|
||||
getRecordStatusLabel,
|
||||
@@ -52,7 +51,7 @@ export function SendDetailModal({
|
||||
<div><span>回执状态</span><strong>{record.receiptStatus ?? '-'}</strong></div>
|
||||
<div><span>提交时间</span><strong>{getTime(record.queuedAt)}</strong></div>
|
||||
<div><span>发送号码</span><strong>{record.phoneNumber || '-'}</strong></div>
|
||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong></div>
|
||||
<div><span>号码归属</span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
|
||||
<div><span>通道组</span><strong>{channelGroupNames.join(' / ') || '-'}</strong></div>
|
||||
<div><span>收到的接入号</span><strong>{record.clientSrcId || '-'}</strong></div>
|
||||
<div><span>发送的接入号</span><strong>{sentAccessNumber || '-'}</strong></div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SmsMessageRecord } from '@/api/adminApi';
|
||||
import { Button, MoneyText, Pagination } from '@/components/ui';
|
||||
import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
import {
|
||||
getCarrierLabel,
|
||||
getClock,
|
||||
getDate,
|
||||
getRecordStatus,
|
||||
@@ -84,7 +83,7 @@ export function SmsRecordList({
|
||||
</span>
|
||||
</p>
|
||||
<div className="admin-sms-record-card__meta">
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} · {getCarrierLabel(record.carrier)}</small></div>
|
||||
<div><span>接收号码</span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</small></div>
|
||||
<div><span>计费</span><strong>{record.billingUnits} 条 / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} 字</small></div>
|
||||
<div><span>发送通道</span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small>回执 {getTime(record.deliveredAt)}</small></div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type BatchTaskMessagePage } from '@/api/adminApi';
|
||||
import { Button, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui';
|
||||
import { carrierLabels, messageStatusLabel } from './taskModel';
|
||||
import { Button, CarrierTag, Input, Modal, Pagination, Select, Table, Tag } from '@/components/ui';
|
||||
import { messageStatusLabel } from './taskModel';
|
||||
import type { SmsTask } from './taskTypes';
|
||||
|
||||
type TaskPhoneListModalProps = {
|
||||
@@ -55,7 +55,7 @@ export function TaskPhoneListModal({ task, onClose, onError }: TaskPhoneListModa
|
||||
columns={[
|
||||
{ key: 'phoneNumber', title: '手机号码', render: (item) => <strong>{item.phoneNumber}</strong> },
|
||||
{ key: 'province', title: '号码归属地', render: (item) => item.province || '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => carrierLabels[item.carrier ?? '']?.label ?? item.carrier ?? '-' },
|
||||
{ key: 'carrier', title: '运营商', render: (item) => item.carrier ? <CarrierTag carrier={item.carrier} /> : '-' },
|
||||
{ key: 'status', title: '短信记录状态', render: (item) => <Tag tone={item.status === 'delivered' ? 'success' : ['failed', 'submit_failed', 'rejected', 'timeout'].includes(item.status) ? 'danger' : 'info'}>{messageStatusLabel(item.status)}</Tag> },
|
||||
]}
|
||||
data={data.items}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileText, Search, Smartphone } from 'lucide-react';
|
||||
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import {
|
||||
DateRangeInput,
|
||||
CarrierTag,
|
||||
Input,
|
||||
Pagination,
|
||||
QueryPanel,
|
||||
@@ -33,13 +34,6 @@ const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> =
|
||||
timeout: 'danger',
|
||||
};
|
||||
|
||||
const carrierLabelMap: Record<string, string> = {
|
||||
mobile: '中国移动',
|
||||
unicom: '中国联通',
|
||||
telecom: '中国电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
function getDate(value?: string | null) {
|
||||
return value ? value.slice(0, 10) : '';
|
||||
}
|
||||
@@ -180,7 +174,6 @@ export function ClientSendDetailPage() {
|
||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||
) : visibleRows.map((record) => {
|
||||
const receipt = getReceipt(record);
|
||||
const carrier = record.carrier ? carrierLabelMap[record.carrier] ?? record.carrier : '-';
|
||||
const region = record.province ?? '-';
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
@@ -199,7 +192,7 @@ export function ClientSendDetailPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{record.phoneNumber}</strong></td>
|
||||
<td><strong className="send-detail-carrier">{carrier}</strong></td>
|
||||
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
|
||||
<td><span className="send-detail-region">{region}</span></td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag></td>
|
||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
|
||||
|
||||
@@ -18,7 +18,10 @@ export function normalizeCarrierTag(value?: string | null): CarrierTagValue | nu
|
||||
|
||||
export function CarrierTag({ carrier, className = '', ...props }: HTMLAttributes<HTMLSpanElement> & { carrier: string }) {
|
||||
const normalized = normalizeCarrierTag(carrier);
|
||||
if (!normalized) return <span className={['ui-carrier-tag', 'ui-carrier-tag--neutral', className].filter(Boolean).join(' ')} {...props}>{carrier || '未知'}</span>;
|
||||
if (!normalized) {
|
||||
const fallbackLabel = ['all', '三网'].includes(String(carrier ?? '').trim().toLowerCase()) ? '三网' : carrier || '未知';
|
||||
return <span className={['ui-carrier-tag', 'ui-carrier-tag--neutral', className].filter(Boolean).join(' ')} {...props}>{fallbackLabel}</span>;
|
||||
}
|
||||
const meta = carrierMeta[normalized];
|
||||
return <span className={['ui-carrier-tag', meta.className, className].filter(Boolean).join(' ')} {...props}>{meta.label}</span>;
|
||||
}
|
||||
|
||||
@@ -93,6 +93,10 @@ export function RechargeReceiptDialog({
|
||||
<dt>交易状态</dt>
|
||||
<dd>{isCorrection ? '冲正完成' : '充值完成'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>操作人员</dt>
|
||||
<dd>{record.operatorName || (record.operatorId ? '未知操作人员' : '系统')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="recharge-receipt__remark">
|
||||
|
||||
@@ -1094,6 +1094,12 @@
|
||||
.ui-carrier-tag--telecom { background: #f0ecf7; border-color: #ddd1ea; color: #73538f; }
|
||||
.ui-carrier-tag--neutral { background: #eceff2; color: #596474; }
|
||||
|
||||
.ui-carrier-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ui-tabs {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
|
||||
@@ -4231,10 +4231,6 @@
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-signature-rank-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-workload-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
@@ -9088,7 +9084,6 @@
|
||||
|
||||
.admin-dashboard-main-grid,
|
||||
.admin-metric-grid,
|
||||
.admin-signature-rank-grid,
|
||||
.admin-workload-grid,
|
||||
.enterprise-summary-grid,
|
||||
.enterprise-query-grid,
|
||||
|
||||
Reference in New Issue
Block a user