From 2ecbb920a67fc75c5af3a470ca6494c2c03cf556 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sat, 11 Jul 2026 18:20:38 +0800 Subject: [PATCH] fix: close documented platform polish gaps --- api/src/channels/channels.service.spec.ts | 13 +++++- api/src/channels/channels.service.ts | 4 +- .../dictionaries/dictionaries.controller.ts | 4 +- .../dictionaries/dictionaries.service.spec.ts | 18 ++++---- api/src/dictionaries/dictionaries.service.ts | 43 +++++++------------ api/src/operations/operations.service.spec.ts | 7 +++ api/src/operations/operations.service.ts | 13 ++++-- docs/testing-progress.md | 11 +++++ src/api/adminApi.ts | 5 ++- src/apps/LoginPage.tsx | 2 +- src/apps/admin/AdminChannelsPage.tsx | 14 ++---- src/apps/admin/AdminEnterpriseAuditPage.tsx | 3 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 2 +- src/apps/admin/AdminHome.tsx | 33 +++++++++----- src/apps/admin/AdminPhoneSegmentsPage.tsx | 29 +++++-------- src/apps/admin/AdminRechargeRecordsPage.tsx | 2 - src/apps/admin/AdminSmsRecordsPage.tsx | 19 +++++++- src/apps/admin/AdminSmsTaskProgressPage.tsx | 7 +-- src/apps/admin/AdminTemplateAuditPage.tsx | 3 +- src/apps/admin/AdminUsersPage.tsx | 41 ++++++++++++++++-- src/apps/client/ClientSendPage.tsx | 7 ++- src/components/ui/Table.tsx | 2 + src/layouts/AdminLayout.tsx | 11 +++-- src/layouts/ClientLayout.tsx | 2 +- src/styles/components.css | 8 ++-- 25 files changed, 193 insertions(+), 110 deletions(-) diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 73218d2..5d44c51 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -279,7 +279,7 @@ describe('ChannelsService', () => { }; await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000'); - await expect(service.createChannel({ ...channel, config: { extensionDigits: 3 } })).rejects.toThrow('extensionDigits must be one of 0, 2, 4, or 6'); + await expect(service.createChannel({ ...channel, config: { extensionDigits: 21 } })).rejects.toThrow('extensionDigits must be an integer between 0 and 20'); }); it('updates CMPP channel configuration without requiring password changes', async () => { @@ -326,6 +326,17 @@ describe('ChannelsService', () => { }); }); + it('persists an arbitrary integer extension digit count within the supported range', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.updateChannel('channel-1', { config: { extensionDigits: 15 } }); + + expect(prisma.smsChannel.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ config: expect.objectContaining({ extensionDigits: 15 }) }), + })); + }); + it('rejects invalid channel update ports', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 9449942..0697871 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -1417,8 +1417,8 @@ function normalizeExtensionDigits(value: unknown) { return 0; } const normalized = Number(value); - if (![0, 2, 4, 6].includes(normalized)) { - throw new BadRequestException('extensionDigits must be one of 0, 2, 4, or 6'); + if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) { + throw new BadRequestException('extensionDigits must be an integer between 0 and 20'); } return normalized; } diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 6c9b37f..723b220 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -18,10 +18,10 @@ export class DictionariesController { @Get('phone-segments') listPhoneSegments( @Query('keyword') keyword?: string, - @Query('cursor') cursor?: string, + @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { - return this.dictionaries.listPhoneSegments({ keyword, cursor, pageSize: Number(pageSize) || undefined }); + return this.dictionaries.listPhoneSegments({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined }); } @Post('phone-segments') diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 8e62cf4..61a793f 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -4,6 +4,7 @@ function createPrismaMock() { return { phoneSegment: { findMany: jest.fn(), + count: jest.fn().mockResolvedValue(3), }, phoneCarrierRule: { findMany: jest.fn().mockResolvedValue([]), @@ -34,7 +35,7 @@ function createPrismaMock() { } describe('DictionariesService', () => { - it('paginates phone segments without counting the full table', async () => { + it('paginates phone segments with a real database count', async () => { const prisma = createPrismaMock(); prisma.phoneSegment.findMany.mockResolvedValue([ { id: 'segment-1', prefix: '1300001', carrier: '中国联通', province: '江苏', city: '常州' }, @@ -43,25 +44,24 @@ describe('DictionariesService', () => { ]); const service = new DictionariesService(prisma as never); - await expect(service.listPhoneSegments({ keyword: '中国联通', cursor: '1300000', pageSize: 2 })).resolves.toEqual({ + await expect(service.listPhoneSegments({ keyword: '中国联通', page: 2, pageSize: 2 })).resolves.toEqual({ items: expect.arrayContaining([ expect.objectContaining({ prefix: '1300001' }), expect.objectContaining({ prefix: '1300002' }), ]), pageSize: 2, - hasMore: true, - nextCursor: '1300002', + page: 2, + total: 3, }); expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({ where: { - AND: [ - { prefix: { gt: '1300000' } }, - { OR: expect.any(Array) }, - ], + OR: expect.any(Array), }, orderBy: { prefix: 'asc' }, - take: 3, + skip: 2, + take: 2, }); + expect(prisma.phoneSegment.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } }); }); it('searches security control dictionaries with keyword and status filters', async () => { diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 3535eca..ba95c62 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -11,7 +11,7 @@ export interface CreatePhoneSegmentDto { export interface PhoneSegmentListQuery { keyword?: string; - cursor?: string; + page?: number; pageSize?: number; } @@ -71,33 +71,22 @@ export class DictionariesService { constructor(private readonly prisma: PrismaService) {} async listPhoneSegments(query: PhoneSegmentListQuery = {}) { - const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 20))); + const page = Math.max(1, Number(query.page ?? 1)); + const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); const keyword = query.keyword?.trim(); - const items = await this.prisma.phoneSegment.findMany({ - where: { - AND: [ - query.cursor ? { prefix: { gt: query.cursor } } : {}, - keyword ? { - OR: [ - { prefix: { startsWith: keyword } }, - { carrier: { contains: keyword } }, - { province: { contains: keyword } }, - { city: { contains: keyword } }, - ], - } : {}, - ], - }, - orderBy: { prefix: 'asc' }, - take: pageSize + 1, - }); - const hasMore = items.length > pageSize; - const pageItems = hasMore ? items.slice(0, pageSize) : items; - return { - items: pageItems, - pageSize, - hasMore, - nextCursor: hasMore ? pageItems.at(-1)?.prefix ?? null : null, - }; + const where = keyword ? { + OR: [ + { prefix: { startsWith: keyword } }, + { carrier: { contains: keyword } }, + { province: { contains: keyword } }, + { city: { contains: keyword } }, + ], + } : undefined; + const [items, total] = await Promise.all([ + this.prisma.phoneSegment.findMany({ where, orderBy: { prefix: 'asc' }, skip: (page - 1) * pageSize, take: pageSize }), + this.prisma.phoneSegment.count({ where }), + ]); + return { items, total, page, pageSize }; } createPhoneSegment(data: CreatePhoneSegmentDto) { diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 1e869c4..12db554 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -248,6 +248,13 @@ describe('OperationsService', () => { taskCount: 3, uplinkCount: 1, pendingAuditCount: 5, + pendingAudits: { + enterpriseCertifications: 1, + smsAudits: 2, + signatures: 1, + templates: 1, + total: 5, + }, gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], downstreamDeliverySummary: expect.objectContaining({ pending: 3, diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 1a51f9b..d817456 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -153,7 +153,7 @@ export class OperationsService { billingAggregate, transactionAggregate, connectionGroups, - pendingAuditCount, + pendingAudits, tenantAccounts, recentTasks, recentRecharges, @@ -257,7 +257,8 @@ export class OperationsService { billing: billingAggregate, transactions: transactionAggregate, gatewayConnections: connectionGroups, - pendingAuditCount, + pendingAuditCount: pendingAudits.total, + pendingAudits, downstreamDeliverySummary: { pending: downstreamPendingCount, failed: downstreamFailedCount, @@ -736,7 +737,13 @@ export class OperationsService { this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }), this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }), this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }), - ]).then((counts) => counts.reduce((sum, value) => sum + value, 0)); + ]).then(([templates, signatures, enterpriseCertifications, smsAudits]) => ({ + templates, + signatures, + enterpriseCertifications, + smsAudits, + total: templates + signatures + enterpriseCertifications + smsAudits, + })); } private gatewayDownstreamRecoveryStatusDelegate() { diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 7e61083..237b49a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1547,3 +1547,14 @@ git diff --check - 2026-07-11 回归发现此前金额展示验收不充分:充值记录和多处金额页面仍混用整数、两位或四位小数。现统一金额展示为人民币元三位小数,并新增输入框聚焦底色与文本选中高亮;需求和 `TC-BILLING-006` 已同步。 - 已执行 FilesService 定向单测(3 项通过)、API build、前端 build 和 `git diff --check`;生产部署后四个服务均为 active,API/Gateway health 正常。通过真实 `POST /api/admin/files/upload` 上传 `营业执照-编码回归.png`,响应和 `FileObject` 持久化文件名均为正常中文。 - Batch 6 的 6.1、6.2、7.1、8.1 仍为待修,不得因之前的前端构建通过而标记完成;其余 8.x 与客户端菜单顺序将继续按原始文档逐项复核。 + +## 2026-07-11 文档瑕疵二次闭环 + +- 基于本地《短信平台第一版瑕疵.docx》重新逐项复查,撤销“代码已改即已验收”的旧口径;本轮必须以源码、真实 NestJS API 返回、测试和生产页面复核共同作为完成条件。 +- 通道扩展位数改为真实 API 与页面共同约束 `0-20` 的整数;流速仍由 NestJS 约束为 `1-2000 TPS` 并继续下发 Gateway。 +- Dashboard API 新增四类真实待审明细:企业认证、短信审核、模板、签名;运营首页和右上角通知分别展示并跳转到各自真实审核入口。 +- 手机号段接口从 cursor-only 响应升级为带 `total/page/pageSize` 的 PostgreSQL 分页,页面获得总页数、首页、末页和跳转能力;短信记录与通用 Table 同步补齐完整分页控制。 +- 统一修复充值记录不展示操作人、客户端标题、引流详情名称、用户初始密码显示/隐藏与随机生成、时间秒级格式、输入框无填充焦点状态,以及发送页真实应用单价三位小数显示。 +- 已执行:`channels.service.spec.ts`(24 项通过)、`dictionaries.service.spec.ts`(4 项通过)、`operations.service.spec.ts`(12 项通过)、API build、前端 build 与 `git diff --check` 均通过;前端仍仅有既有 chunk size warning。 +- 已部署生产验证:`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,API/Gateway health 均通过。真实认证 API 返回四类待审明细并与总数一致;手机号段第 2 页返回 25 条、总数 516217、`page=2/pageSize=25`,证明页面分页不再依赖 cursor 猜测总页数。 +- 浏览器自动化在登录页连接阶段超时,未使用 CAPTCHA 绕过或修改生产数据;登录后页面视觉验收需在下一轮以人工登录或可用浏览器会话补充截图。其余项目以源码、真实 API 和构建结果验收,不能将该未完成的视觉截图记录成已完成。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 525714a..68d6f28 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -210,6 +210,7 @@ export type DashboardResponse = { transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } }; gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>; pendingAuditCount: number; + pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; total: number }; downstreamDeliverySummary?: { pending: number; failed: number; @@ -981,8 +982,8 @@ export const adminApi = { createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), - listPhoneSegments: (query: { keyword?: string; cursor?: string; pageSize?: number } = {}) => - request>(withQuery('/admin/dictionaries/phone-segments', query)), + listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => + request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx index d43bebe..9978437 100644 --- a/src/apps/LoginPage.tsx +++ b/src/apps/LoginPage.tsx @@ -73,7 +73,7 @@ export function LoginPage({ portal }: LoginPageProps) {
{isAdmin
-

短信平台

+

短信服务平台

{isAdmin ? '运营端登录' : '客户端登录'}

diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 66e8103..bff21b0 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Po import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; +import { formatDateTime } from '@/utils/dateTime'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; @@ -97,13 +98,6 @@ const regionOptions = [ ...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })), ]; -const extensionOptions = [ - { label: '0', value: '0' }, - { label: '2', value: '2' }, - { label: '4', value: '4' }, - { label: '6', value: '6' }, -]; - const carrierLabelMap: Record = { mobile: '移动', unicom: '联通', @@ -321,7 +315,7 @@ function ChannelFormModal({ />
setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> - setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} /> @@ -732,7 +726,7 @@ export function AdminChannelsPage() {
最近心跳 - {connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'} + {formatDateTime(connection.lastHeartbeatAt)}
{connection.lastError ?

{connection.lastError}

: null} @@ -749,7 +743,7 @@ export function AdminChannelsPage() {
{log.event} - {new Date(log.time).toLocaleString('zh-CN', { hour12: false })} + {formatDateTime(log.time)}
{log.resourceId} diff --git a/src/apps/admin/AdminEnterpriseAuditPage.tsx b/src/apps/admin/AdminEnterpriseAuditPage.tsx index 82ecc10..3093d33 100644 --- a/src/apps/admin/AdminEnterpriseAuditPage.tsx +++ b/src/apps/admin/AdminEnterpriseAuditPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Check, FileSearch, Search, X } from 'lucide-react'; import { adminApi, type EnterpriseCertification } from '@/api/adminApi'; +import { formatDateTime } from '@/utils/dateTime'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected'; @@ -59,7 +60,7 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor contactName: record.contactName ?? '', contactPhone: record.contactPhone ?? '', contactEmail: String(materials.contactEmail ?? ''), - submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }), + submittedAt: formatDateTime(record.submittedAt), reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''), status: record.status as EnterpriseAuditStatus, }; diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index e8f6199..f00af9b 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -455,7 +455,7 @@ function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: ( 关闭} onClose={onClose} open title="引流信息报备详情">
站名称{item.siteName}
-
网站链接{item.url}
+
引流信息{item.url}
移动
联通
电信
diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index 751c7e2..6040864 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -85,6 +85,7 @@ export function AdminHome() { const todaySpend = (dashboard?.today.spendCents ?? 0) / 100; const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0; const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0; + const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 }; const sendTrendOption = useMemo( () => createLineOption({ @@ -99,12 +100,12 @@ export function AdminHome() { const auditTrendOption = useMemo( () => createBarOption({ - labels: ['待审核'], + labels: ['企业认证', '短信审核', '模板', '签名'], series: [ - { name: '待审', data: [dashboard?.pendingAuditCount ?? 0] }, + { name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] }, ], }), - [dashboard], + [pendingAudits], ); const enterpriseColumns: Array> = [ @@ -230,14 +231,26 @@ export function AdminHome() {
-
+
+ 企业认证待审 + {pendingAudits.enterpriseCertifications} 条 + + + +
diff --git a/src/apps/admin/AdminPhoneSegmentsPage.tsx b/src/apps/admin/AdminPhoneSegmentsPage.tsx index 6772c3b..aa190f4 100644 --- a/src/apps/admin/AdminPhoneSegmentsPage.tsx +++ b/src/apps/admin/AdminPhoneSegmentsPage.tsx @@ -20,6 +20,7 @@ type CarrierRule = DictionaryItem & { export function AdminPhoneSegmentsPage() { const pageSize = 25; const [segments, setSegments] = useState([]); + const [segmentTotal, setSegmentTotal] = useState(0); const [rules, setRules] = useState([]); const [ruleTotal, setRuleTotal] = useState(0); const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments'); @@ -39,16 +40,12 @@ export function AdminPhoneSegmentsPage() { const [segmentQuery, setSegmentQuery] = useState(''); const [page, setPage] = useState(1); const [rulePage, setRulePage] = useState(1); - const [pageCursors, setPageCursors] = useState>([undefined]); - const [hasMore, setHasMore] = useState(false); - const [nextCursor, setNextCursor] = useState(null); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { const timer = window.setTimeout(() => { setSegmentQuery(keyword.trim()); setPage(1); - setPageCursors([undefined]); }, 300); return () => window.clearTimeout(timer); }, [keyword]); @@ -57,14 +54,13 @@ export function AdminPhoneSegmentsPage() { let cancelled = false; setLoading(true); Promise.all([ - adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }), + adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }), adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }), ]) .then(([segmentPage, ruleResponse]) => { if (cancelled) return; setSegments(segmentPage.items as PhoneSegment[]); - setHasMore(segmentPage.hasMore); - setNextCursor(segmentPage.nextCursor); + setSegmentTotal(segmentPage.total); setRules(ruleResponse.items as CarrierRule[]); setRuleTotal(ruleResponse.total); setError(''); @@ -78,8 +74,9 @@ export function AdminPhoneSegmentsPage() { return () => { cancelled = true; }; - }, [activeTab, page, pageCursors, reloadKey, rulePage, segmentQuery]); + }, [activeTab, page, reloadKey, rulePage, segmentQuery]); + const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize)); const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize)); function createSegment() { @@ -90,7 +87,6 @@ export function AdminPhoneSegmentsPage() { setCity(''); setCreating(false); setPage(1); - setPageCursors([undefined]); setReloadKey((current) => current + 1); }) .catch((failure: Error) => setError(failure.message || '手机号段新增失败')); @@ -157,17 +153,12 @@ export function AdminPhoneSegmentsPage() { = segmentTotalPages || loading} onPrevious={() => setPage((current) => Math.max(1, current - 1))} - onNext={() => { - if (!nextCursor) return; - setPageCursors((current) => { - const updated = [...current]; - updated[page] = nextCursor; - return updated; - }); - setPage((current) => current + 1); - }} + onNext={() => setPage((current) => Math.min(segmentTotalPages, current + 1))} + onPageChange={setPage} + total={segmentTotal} + totalPages={segmentTotalPages} /> ), diff --git a/src/apps/admin/AdminRechargeRecordsPage.tsx b/src/apps/admin/AdminRechargeRecordsPage.tsx index 770cc1a..0c6e4b7 100644 --- a/src/apps/admin/AdminRechargeRecordsPage.tsx +++ b/src/apps/admin/AdminRechargeRecordsPage.tsx @@ -146,7 +146,6 @@ export function AdminRechargeRecordsPage() { 充值金额 充值后余额 充值类型 - 操作人 备注 @@ -166,7 +165,6 @@ export function AdminRechargeRecordsPage() { ¥{formatAmount(record.amountCents / 100)} {record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`} 人工充值 - {record.operatorId || '运营'} ); diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index f8ad0a2..b5fcdb8 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -267,6 +267,7 @@ function SendDetailModal({ } export function AdminSmsRecordsPage() { + const pageSize = 25; const [records, setRecords] = useState([]); const [enterprise, setEnterprise] = useState('all'); const [application, setApplication] = useState('all'); @@ -279,6 +280,7 @@ export function AdminSmsRecordsPage() { const [segmentAudits, setSegmentAudits] = useState([]); const [segmentLoading, setSegmentLoading] = useState(false); const [error, setError] = useState(''); + const [page, setPage] = useState(1); function loadData() { adminApi.listOperationMessages({ @@ -293,6 +295,7 @@ export function AdminSmsRecordsPage() { }) .then((items) => { setRecords(items); + setPage(1); setError(''); }) .catch((failure: Error) => setError(failure.message || '短信记录加载失败')); @@ -336,6 +339,9 @@ export function AdminSmsRecordsPage() { }, [enterprise, records]); const filteredRows = records; + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize); const segmentColumns: Array> = [ { key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` }, @@ -421,7 +427,7 @@ export function AdminSmsRecordsPage() { 暂无短信记录 - ) : filteredRows.map((record) => ( + ) : visibleRows.map((record) => (
@@ -453,7 +459,16 @@ export function AdminSmsRecordsPage() {
- + = totalPages} + onNext={() => setPage((current) => Math.min(totalPages, current + 1))} + onPageChange={setPage} + onPrevious={() => setPage((current) => Math.max(1, current - 1))} + page={currentPage} + previousDisabled={currentPage <= 1} + total={filteredRows.length} + totalPages={totalPages} + />
{selectedRecord ? ( diff --git a/src/apps/admin/AdminSmsTaskProgressPage.tsx b/src/apps/admin/AdminSmsTaskProgressPage.tsx index 2e06f09..8d28a66 100644 --- a/src/apps/admin/AdminSmsTaskProgressPage.tsx +++ b/src/apps/admin/AdminSmsTaskProgressPage.tsx @@ -1,6 +1,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react'; import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi'; +import { formatDateTime } from '@/utils/dateTime'; import { Breadcrumb, Button, @@ -85,7 +86,7 @@ function formatNumber(value: number) { } function formatTime(value?: string | null) { - return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-'; + return formatDateTime(value); } function normalizeTaskStatus(status: string): TaskStatus { @@ -468,7 +469,7 @@ export function AdminSmsTaskProgressPage() { {record.application}
- {record.submittedAt.slice(0, 10)}
{record.submittedAt.slice(11, 16)}
+ {formatTime(record.submittedAt)}
{formatNumber(record.phoneCount)} @@ -481,7 +482,7 @@ export function AdminSmsTaskProgressPage() { {record.sendType === 'scheduled' ? : null} {sendTypeLabels[record.sendType]} - {record.scheduledAt ? {record.scheduledAt.slice(0, 10)}
{record.scheduledAt.slice(11, 16)}
: null} + {record.scheduledAt ? {formatTime(record.scheduledAt)} : null}
diff --git a/src/apps/admin/AdminTemplateAuditPage.tsx b/src/apps/admin/AdminTemplateAuditPage.tsx index ba358dd..3e2e0f9 100644 --- a/src/apps/admin/AdminTemplateAuditPage.tsx +++ b/src/apps/admin/AdminTemplateAuditPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Check, Search, X } from 'lucide-react'; import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui'; import { adminApi, type SmsTemplateAudit } from '@/api/adminApi'; +import { formatDateTime } from '@/utils/dateTime'; const auditStatusLabelMap: Record = { pending: '待审核', @@ -41,7 +42,7 @@ export function AdminTemplateAuditPage() { { key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId }, { key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId }, { key: 'content', title: '短信模板内容', render: (record) => record.content }, - { key: 'submittedAt', title: '提交时间', render: (record) => new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) }, + { key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.createdAt) }, { key: 'status', title: '状态', diff --git a/src/apps/admin/AdminUsersPage.tsx b/src/apps/admin/AdminUsersPage.tsx index bd4056d..c6a14f8 100644 --- a/src/apps/admin/AdminUsersPage.tsx +++ b/src/apps/admin/AdminUsersPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { KeyRound, Plus, Search } from 'lucide-react'; +import { Eye, EyeOff, KeyRound, Plus, RefreshCw, Search } from 'lucide-react'; import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { readSession } from '@/api/session'; @@ -51,6 +51,13 @@ function toForm(user?: ManagedUser): UserForm { } : emptyForm; } +function generateInitialPassword() { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%'; + const values = new Uint32Array(14); + crypto.getRandomValues(values); + return Array.from(values, (value) => alphabet[value % alphabet.length]).join(''); +} + export function AdminUsersPage() { const session = readSession(); const [users, setUsers] = useState([]); @@ -61,6 +68,7 @@ export function AdminUsersPage() { const [form, setForm] = useState(emptyForm); const [passwordUser, setPasswordUser] = useState(null); const [newPassword, setNewPassword] = useState(''); + const [showInitialPassword, setShowInitialPassword] = useState(false); const [confirmAction, setConfirmAction] = useState(null); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); @@ -84,7 +92,8 @@ export function AdminUsersPage() { }, [keyword, users]); function openCreate() { - setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' }); + setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' }); + setShowInitialPassword(false); setCreating(true); } @@ -223,8 +232,32 @@ export function AdminUsersPage() { value={form.tenantId} /> ) : null} - {creating ? updateField('password', event.target.value)} required type="password" value={form.password} /> : null} - updateField('password', event.target.value)} + required + suffix={( + <> + + + + )} + type={showInitialPassword ? 'text' : 'password'} + value={form.password} + /> + ) : null} +
+ 状态 +
+ + +
+
) : null} diff --git a/src/apps/client/ClientSendPage.tsx b/src/apps/client/ClientSendPage.tsx index af988a9..1ee4240 100644 --- a/src/apps/client/ClientSendPage.tsx +++ b/src/apps/client/ClientSendPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react'; import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi'; +import { formatCents } from '@/utils/currency'; type Recipient = { id: string; @@ -59,6 +60,10 @@ export function ClientSendPage() { () => templates.find((item) => item.id === templateId), [templates, templateId], ); + const selectedApplication = useMemo( + () => applications.find((item) => item.id === applicationId), + [applicationId, applications], + ); const filteredTemplates = templates.filter((item) => ( item.name.includes(templateKeyword) || item.content.includes(templateKeyword) )); @@ -378,7 +383,7 @@ export function ClientSendPage() {
单价 - ¥0.05 / 人 + ¥{formatCents(selectedApplication?.customerUnitPrice)} / 人
短信按 70 字/条计费,超出部分按 67 字/条计算
diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx index 131a761..81a27f9 100644 --- a/src/components/ui/Table.tsx +++ b/src/components/ui/Table.tsx @@ -91,10 +91,12 @@ export function Table({ columns, data, rowKey, emptyText = '暂无数据', pa = totalPages} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} + onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={activePage} previousDisabled={activePage <= 1} total={data.length} + totalPages={totalPages} /> ) : null} diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 8cd5e7e..a311a80 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -37,16 +37,16 @@ import { AppShell } from '@/layouts/AppShell'; export function AdminLayout() { const session = readSession(); - const [pendingAuditCount, setPendingAuditCount] = useState(0); + const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 }); const [downstreamAlertCount, setDownstreamAlertCount] = useState(0); const loadPendingAuditCount = useCallback(() => { adminApi.getDashboard() .then((dashboard) => { - setPendingAuditCount(dashboard.pendingAuditCount ?? 0); + setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 }); setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0); }) .catch(() => { - setPendingAuditCount(0); + setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 }); setDownstreamAlertCount(0); }); }, []); @@ -78,7 +78,10 @@ export function AdminLayout() { userName={session.user.displayName} userRole="平台管理员" auditNotifications={[ - { label: '待处理审核', count: pendingAuditCount, to: '/admin/sms-audit' }, + { label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' }, + { label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' }, + { label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' }, + { label: '签名待审', count: pendingAudits.signatures, to: '/admin/enterprise-signatures' }, { label: '下游投递告警', count: downstreamAlertCount, to: '/admin/downstream-deliveries' }, ]} navSections={[ diff --git a/src/layouts/ClientLayout.tsx b/src/layouts/ClientLayout.tsx index ee029c5..f3bb759 100644 --- a/src/layouts/ClientLayout.tsx +++ b/src/layouts/ClientLayout.tsx @@ -22,7 +22,7 @@ export function ClientLayout() { return (