fix: close documented platform polish gaps
This commit is contained in:
@@ -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, 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 () => {
|
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 () => {
|
it('rejects invalid channel update ports', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
const service = new ChannelsService(prisma as never);
|
const service = new ChannelsService(prisma as never);
|
||||||
|
|||||||
@@ -1417,8 +1417,8 @@ function normalizeExtensionDigits(value: unknown) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const normalized = Number(value);
|
const normalized = Number(value);
|
||||||
if (![0, 2, 4, 6].includes(normalized)) {
|
if (!Number.isInteger(normalized) || normalized < 0 || normalized > 20) {
|
||||||
throw new BadRequestException('extensionDigits must be one of 0, 2, 4, or 6');
|
throw new BadRequestException('extensionDigits must be an integer between 0 and 20');
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export class DictionariesController {
|
|||||||
@Get('phone-segments')
|
@Get('phone-segments')
|
||||||
listPhoneSegments(
|
listPhoneSegments(
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('cursor') cursor?: string,
|
@Query('page') page?: string,
|
||||||
@Query('pageSize') pageSize?: 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')
|
@Post('phone-segments')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ function createPrismaMock() {
|
|||||||
return {
|
return {
|
||||||
phoneSegment: {
|
phoneSegment: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
|
count: jest.fn().mockResolvedValue(3),
|
||||||
},
|
},
|
||||||
phoneCarrierRule: {
|
phoneCarrierRule: {
|
||||||
findMany: jest.fn().mockResolvedValue([]),
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
@@ -34,7 +35,7 @@ function createPrismaMock() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('DictionariesService', () => {
|
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();
|
const prisma = createPrismaMock();
|
||||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||||
{ id: 'segment-1', prefix: '1300001', carrier: '中国联通', province: '江苏', city: '常州' },
|
{ id: 'segment-1', prefix: '1300001', carrier: '中国联通', province: '江苏', city: '常州' },
|
||||||
@@ -43,25 +44,24 @@ describe('DictionariesService', () => {
|
|||||||
]);
|
]);
|
||||||
const service = new DictionariesService(prisma as never);
|
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([
|
items: expect.arrayContaining([
|
||||||
expect.objectContaining({ prefix: '1300001' }),
|
expect.objectContaining({ prefix: '1300001' }),
|
||||||
expect.objectContaining({ prefix: '1300002' }),
|
expect.objectContaining({ prefix: '1300002' }),
|
||||||
]),
|
]),
|
||||||
pageSize: 2,
|
pageSize: 2,
|
||||||
hasMore: true,
|
page: 2,
|
||||||
nextCursor: '1300002',
|
total: 3,
|
||||||
});
|
});
|
||||||
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
AND: [
|
OR: expect.any(Array),
|
||||||
{ prefix: { gt: '1300000' } },
|
|
||||||
{ OR: expect.any(Array) },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
orderBy: { prefix: 'asc' },
|
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 () => {
|
it('searches security control dictionaries with keyword and status filters', async () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface CreatePhoneSegmentDto {
|
|||||||
|
|
||||||
export interface PhoneSegmentListQuery {
|
export interface PhoneSegmentListQuery {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
cursor?: string;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,33 +71,22 @@ export class DictionariesService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
|
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 keyword = query.keyword?.trim();
|
||||||
const items = await this.prisma.phoneSegment.findMany({
|
const where = keyword ? {
|
||||||
where: {
|
OR: [
|
||||||
AND: [
|
{ prefix: { startsWith: keyword } },
|
||||||
query.cursor ? { prefix: { gt: query.cursor } } : {},
|
{ carrier: { contains: keyword } },
|
||||||
keyword ? {
|
{ province: { contains: keyword } },
|
||||||
OR: [
|
{ city: { contains: keyword } },
|
||||||
{ prefix: { startsWith: keyword } },
|
],
|
||||||
{ carrier: { contains: keyword } },
|
} : undefined;
|
||||||
{ province: { contains: keyword } },
|
const [items, total] = await Promise.all([
|
||||||
{ city: { contains: keyword } },
|
this.prisma.phoneSegment.findMany({ where, orderBy: { prefix: 'asc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
||||||
],
|
this.prisma.phoneSegment.count({ where }),
|
||||||
} : {},
|
]);
|
||||||
],
|
return { items, total, page, pageSize };
|
||||||
},
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createPhoneSegment(data: CreatePhoneSegmentDto) {
|
createPhoneSegment(data: CreatePhoneSegmentDto) {
|
||||||
|
|||||||
@@ -248,6 +248,13 @@ describe('OperationsService', () => {
|
|||||||
taskCount: 3,
|
taskCount: 3,
|
||||||
uplinkCount: 1,
|
uplinkCount: 1,
|
||||||
pendingAuditCount: 5,
|
pendingAuditCount: 5,
|
||||||
|
pendingAudits: {
|
||||||
|
enterpriseCertifications: 1,
|
||||||
|
smsAudits: 2,
|
||||||
|
signatures: 1,
|
||||||
|
templates: 1,
|
||||||
|
total: 5,
|
||||||
|
},
|
||||||
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||||
downstreamDeliverySummary: expect.objectContaining({
|
downstreamDeliverySummary: expect.objectContaining({
|
||||||
pending: 3,
|
pending: 3,
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export class OperationsService {
|
|||||||
billingAggregate,
|
billingAggregate,
|
||||||
transactionAggregate,
|
transactionAggregate,
|
||||||
connectionGroups,
|
connectionGroups,
|
||||||
pendingAuditCount,
|
pendingAudits,
|
||||||
tenantAccounts,
|
tenantAccounts,
|
||||||
recentTasks,
|
recentTasks,
|
||||||
recentRecharges,
|
recentRecharges,
|
||||||
@@ -257,7 +257,8 @@ export class OperationsService {
|
|||||||
billing: billingAggregate,
|
billing: billingAggregate,
|
||||||
transactions: transactionAggregate,
|
transactions: transactionAggregate,
|
||||||
gatewayConnections: connectionGroups,
|
gatewayConnections: connectionGroups,
|
||||||
pendingAuditCount,
|
pendingAuditCount: pendingAudits.total,
|
||||||
|
pendingAudits,
|
||||||
downstreamDeliverySummary: {
|
downstreamDeliverySummary: {
|
||||||
pending: downstreamPendingCount,
|
pending: downstreamPendingCount,
|
||||||
failed: downstreamFailedCount,
|
failed: downstreamFailedCount,
|
||||||
@@ -736,7 +737,13 @@ export class OperationsService {
|
|||||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||||
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
|
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() {
|
private gatewayDownstreamRecoveryStatusDelegate() {
|
||||||
|
|||||||
@@ -1547,3 +1547,14 @@ git diff --check
|
|||||||
- 2026-07-11 回归发现此前金额展示验收不充分:充值记录和多处金额页面仍混用整数、两位或四位小数。现统一金额展示为人民币元三位小数,并新增输入框聚焦底色与文本选中高亮;需求和 `TC-BILLING-006` 已同步。
|
- 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` 持久化文件名均为正常中文。
|
- 已执行 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 与客户端菜单顺序将继续按原始文档逐项复核。
|
- 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 和构建结果验收,不能将该未完成的视觉截图记录成已完成。
|
||||||
|
|||||||
+3
-2
@@ -210,6 +210,7 @@ export type DashboardResponse = {
|
|||||||
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } };
|
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 } }>;
|
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
|
||||||
pendingAuditCount: number;
|
pendingAuditCount: number;
|
||||||
|
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; total: number };
|
||||||
downstreamDeliverySummary?: {
|
downstreamDeliverySummary?: {
|
||||||
pending: number;
|
pending: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
@@ -981,8 +982,8 @@ export const adminApi = {
|
|||||||
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
|
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
|
||||||
listPhoneSegments: (query: { keyword?: string; cursor?: string; pageSize?: number } = {}) =>
|
listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
request<CursorPage<DictionaryItem>>(withQuery('/admin/dictionaries/phone-segments', query)),
|
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 }) =>
|
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
|
||||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function LoginPage({ portal }: LoginPageProps) {
|
|||||||
<div className="login-brand">
|
<div className="login-brand">
|
||||||
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
|
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
|
||||||
<div>
|
<div>
|
||||||
<h1>短信平台</h1>
|
<h1>短信服务平台</h1>
|
||||||
<p>{isAdmin ? '运营端登录' : '客户端登录'}</p>
|
<p>{isAdmin ? '运营端登录' : '客户端登录'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Po
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
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 { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||||
@@ -97,13 +98,6 @@ const regionOptions = [
|
|||||||
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
|
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.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<Carrier, string> = {
|
const carrierLabelMap: Record<Carrier, string> = {
|
||||||
mobile: '移动',
|
mobile: '移动',
|
||||||
unicom: '联通',
|
unicom: '联通',
|
||||||
@@ -321,7 +315,7 @@ function ChannelFormModal({
|
|||||||
/>
|
/>
|
||||||
<div className="sms-channel-inline-field">
|
<div className="sms-channel-inline-field">
|
||||||
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||||
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
||||||
</div>
|
</div>
|
||||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||||
@@ -732,7 +726,7 @@ export function AdminChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>最近心跳</span>
|
<span>最近心跳</span>
|
||||||
<strong>{connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'}</strong>
|
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||||
</div>
|
</div>
|
||||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||||
</article>
|
</article>
|
||||||
@@ -749,7 +743,7 @@ export function AdminChannelsPage() {
|
|||||||
<article className="channel-log-item" key={log.id}>
|
<article className="channel-log-item" key={log.id}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{log.event}</strong>
|
<strong>{log.event}</strong>
|
||||||
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
|
<span>{formatDateTime(log.time)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>{log.resourceId}</span>
|
<span>{log.resourceId}</span>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||||
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
|
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';
|
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
|
||||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||||
@@ -59,7 +60,7 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
|
|||||||
contactName: record.contactName ?? '',
|
contactName: record.contactName ?? '',
|
||||||
contactPhone: record.contactPhone ?? '',
|
contactPhone: record.contactPhone ?? '',
|
||||||
contactEmail: String(materials.contactEmail ?? ''),
|
contactEmail: String(materials.contactEmail ?? ''),
|
||||||
submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }),
|
submittedAt: formatDateTime(record.submittedAt),
|
||||||
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
|
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
|
||||||
status: record.status as EnterpriseAuditStatus,
|
status: record.status as EnterpriseAuditStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -455,7 +455,7 @@ function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: (
|
|||||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||||
<div><span>网站链接</span><strong>{item.url}</strong></div>
|
<div><span>引流信息</span><strong>{item.url}</strong></div>
|
||||||
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
||||||
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
||||||
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function AdminHome() {
|
|||||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||||
|
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 };
|
||||||
|
|
||||||
const sendTrendOption = useMemo(
|
const sendTrendOption = useMemo(
|
||||||
() => createLineOption({
|
() => createLineOption({
|
||||||
@@ -99,12 +100,12 @@ export function AdminHome() {
|
|||||||
|
|
||||||
const auditTrendOption = useMemo(
|
const auditTrendOption = useMemo(
|
||||||
() => createBarOption({
|
() => createBarOption({
|
||||||
labels: ['待审核'],
|
labels: ['企业认证', '短信审核', '模板', '签名'],
|
||||||
series: [
|
series: [
|
||||||
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
|
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
[dashboard],
|
[pendingAudits],
|
||||||
);
|
);
|
||||||
|
|
||||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||||
@@ -230,14 +231,26 @@ export function AdminHome() {
|
|||||||
<BarChart3 size={20} className="status-info" />
|
<BarChart3 size={20} className="status-info" />
|
||||||
</div>
|
</div>
|
||||||
<div className="overview-grid overview-grid--three">
|
<div className="overview-grid overview-grid--three">
|
||||||
<div className="mini-status-card">
|
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
|
||||||
<FileCheck2 size={22} />
|
<FileCheck2 size={22} />
|
||||||
<div>
|
<span>企业认证待审</span>
|
||||||
<span>待审核</span>
|
<strong>{pendingAudits.enterpriseCertifications} 条</strong>
|
||||||
<strong>{dashboard?.pendingAuditCount ?? 0} 条</strong>
|
</Button>
|
||||||
<small>模板、签名和企业认证。</small>
|
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
|
||||||
</div>
|
<FileCheck2 size={22} />
|
||||||
</div>
|
<span>短信审核待审</span>
|
||||||
|
<strong>{pendingAudits.smsAudits} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>模板待审</span>
|
||||||
|
<strong>{pendingAudits.templates} 条</strong>
|
||||||
|
</Button>
|
||||||
|
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-signatures')} variant="ghost">
|
||||||
|
<FileCheck2 size={22} />
|
||||||
|
<span>签名待审</span>
|
||||||
|
<strong>{pendingAudits.signatures} 条</strong>
|
||||||
|
</Button>
|
||||||
<div className="mini-status-card">
|
<div className="mini-status-card">
|
||||||
<ShieldCheck size={22} />
|
<ShieldCheck size={22} />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type CarrierRule = DictionaryItem & {
|
|||||||
export function AdminPhoneSegmentsPage() {
|
export function AdminPhoneSegmentsPage() {
|
||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||||
|
const [segmentTotal, setSegmentTotal] = useState(0);
|
||||||
const [rules, setRules] = useState<CarrierRule[]>([]);
|
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||||
const [ruleTotal, setRuleTotal] = useState(0);
|
const [ruleTotal, setRuleTotal] = useState(0);
|
||||||
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||||
@@ -39,16 +40,12 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
const [segmentQuery, setSegmentQuery] = useState('');
|
const [segmentQuery, setSegmentQuery] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [rulePage, setRulePage] = useState(1);
|
const [rulePage, setRulePage] = useState(1);
|
||||||
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
|
|
||||||
const [hasMore, setHasMore] = useState(false);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setSegmentQuery(keyword.trim());
|
setSegmentQuery(keyword.trim());
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setPageCursors([undefined]);
|
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [keyword]);
|
}, [keyword]);
|
||||||
@@ -57,14 +54,13 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
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 }),
|
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
|
||||||
])
|
])
|
||||||
.then(([segmentPage, ruleResponse]) => {
|
.then(([segmentPage, ruleResponse]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSegments(segmentPage.items as PhoneSegment[]);
|
setSegments(segmentPage.items as PhoneSegment[]);
|
||||||
setHasMore(segmentPage.hasMore);
|
setSegmentTotal(segmentPage.total);
|
||||||
setNextCursor(segmentPage.nextCursor);
|
|
||||||
setRules(ruleResponse.items as CarrierRule[]);
|
setRules(ruleResponse.items as CarrierRule[]);
|
||||||
setRuleTotal(ruleResponse.total);
|
setRuleTotal(ruleResponse.total);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -78,8 +74,9 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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));
|
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
|
||||||
|
|
||||||
function createSegment() {
|
function createSegment() {
|
||||||
@@ -90,7 +87,6 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
setCity('');
|
setCity('');
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setPageCursors([undefined]);
|
|
||||||
setReloadKey((current) => current + 1);
|
setReloadKey((current) => current + 1);
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
||||||
@@ -157,17 +153,12 @@ export function AdminPhoneSegmentsPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
previousDisabled={page <= 1 || loading}
|
previousDisabled={page <= 1 || loading}
|
||||||
nextDisabled={!hasMore || loading}
|
nextDisabled={page >= segmentTotalPages || loading}
|
||||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
onNext={() => {
|
onNext={() => setPage((current) => Math.min(segmentTotalPages, current + 1))}
|
||||||
if (!nextCursor) return;
|
onPageChange={setPage}
|
||||||
setPageCursors((current) => {
|
total={segmentTotal}
|
||||||
const updated = [...current];
|
totalPages={segmentTotalPages}
|
||||||
updated[page] = nextCursor;
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
setPage((current) => current + 1);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ export function AdminRechargeRecordsPage() {
|
|||||||
<th style={{ width: '130px' }}>充值金额</th>
|
<th style={{ width: '130px' }}>充值金额</th>
|
||||||
<th style={{ width: '140px' }}>充值后余额</th>
|
<th style={{ width: '140px' }}>充值后余额</th>
|
||||||
<th style={{ width: '120px' }}>充值类型</th>
|
<th style={{ width: '120px' }}>充值类型</th>
|
||||||
<th style={{ width: '140px' }}>操作人</th>
|
|
||||||
<th style={{ width: '300px' }}>备注</th>
|
<th style={{ width: '300px' }}>备注</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -166,7 +165,6 @@ export function AdminRechargeRecordsPage() {
|
|||||||
<td>¥{formatAmount(record.amountCents / 100)}</td>
|
<td>¥{formatAmount(record.amountCents / 100)}</td>
|
||||||
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
|
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
|
||||||
<td><Tag tone="warning">人工充值</Tag></td>
|
<td><Tag tone="warning">人工充值</Tag></td>
|
||||||
<td>{record.operatorId || '运营'}</td>
|
|
||||||
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ function SendDetailModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSmsRecordsPage() {
|
export function AdminSmsRecordsPage() {
|
||||||
|
const pageSize = 25;
|
||||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||||
const [enterprise, setEnterprise] = useState('all');
|
const [enterprise, setEnterprise] = useState('all');
|
||||||
const [application, setApplication] = useState('all');
|
const [application, setApplication] = useState('all');
|
||||||
@@ -279,6 +280,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||||
const [segmentLoading, setSegmentLoading] = useState(false);
|
const [segmentLoading, setSegmentLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
adminApi.listOperationMessages({
|
adminApi.listOperationMessages({
|
||||||
@@ -293,6 +295,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
})
|
})
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
setRecords(items);
|
setRecords(items);
|
||||||
|
setPage(1);
|
||||||
setError('');
|
setError('');
|
||||||
})
|
})
|
||||||
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
|
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
|
||||||
@@ -336,6 +339,9 @@ export function AdminSmsRecordsPage() {
|
|||||||
}, [enterprise, records]);
|
}, [enterprise, records]);
|
||||||
|
|
||||||
const filteredRows = 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<TableColumn<SmsMessageSegmentAudit>> = [
|
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
||||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||||
@@ -421,7 +427,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td className="ui-table__empty" colSpan={5}>暂无短信记录</td>
|
<td className="ui-table__empty" colSpan={5}>暂无短信记录</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredRows.map((record) => (
|
) : visibleRows.map((record) => (
|
||||||
<tr key={record.id}>
|
<tr key={record.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="admin-sms-record-sender">
|
<div className="admin-sms-record-sender">
|
||||||
@@ -453,7 +459,16 @@ export function AdminSmsRecordsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination total={filteredRows.length} />
|
<Pagination
|
||||||
|
nextDisabled={currentPage >= 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}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedRecord ? (
|
{selectedRecord ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
||||||
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
|
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
@@ -85,7 +86,7 @@ function formatNumber(value: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value?: string | null) {
|
function formatTime(value?: string | null) {
|
||||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-';
|
return formatDateTime(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTaskStatus(status: string): TaskStatus {
|
function normalizeTaskStatus(status: string): TaskStatus {
|
||||||
@@ -468,7 +469,7 @@ export function AdminSmsTaskProgressPage() {
|
|||||||
<span>{record.application}</span>
|
<span>{record.application}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span></td>
|
<td><span>{formatTime(record.submittedAt)}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="admin-task-counts">
|
<div className="admin-task-counts">
|
||||||
<strong>{formatNumber(record.phoneCount)}</strong>
|
<strong>{formatNumber(record.phoneCount)}</strong>
|
||||||
@@ -481,7 +482,7 @@ export function AdminSmsTaskProgressPage() {
|
|||||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||||
{sendTypeLabels[record.sendType]}
|
{sendTypeLabels[record.sendType]}
|
||||||
</Tag>
|
</Tag>
|
||||||
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
|
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Check, Search, X } from 'lucide-react';
|
import { Check, Search, X } from 'lucide-react';
|
||||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||||
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|
||||||
const auditStatusLabelMap: Record<string, string> = {
|
const auditStatusLabelMap: Record<string, string> = {
|
||||||
pending: '待审核',
|
pending: '待审核',
|
||||||
@@ -41,7 +42,7 @@ export function AdminTemplateAuditPage() {
|
|||||||
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
|
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||||
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
|
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||||
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
|
{ 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',
|
key: 'status',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
import { readSession } from '@/api/session';
|
import { readSession } from '@/api/session';
|
||||||
@@ -51,6 +51,13 @@ function toForm(user?: ManagedUser): UserForm {
|
|||||||
} : emptyForm;
|
} : 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() {
|
export function AdminUsersPage() {
|
||||||
const session = readSession();
|
const session = readSession();
|
||||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||||
@@ -61,6 +68,7 @@ export function AdminUsersPage() {
|
|||||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [showInitialPassword, setShowInitialPassword] = useState(false);
|
||||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -84,7 +92,8 @@ export function AdminUsersPage() {
|
|||||||
}, [keyword, users]);
|
}, [keyword, users]);
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
|
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
|
||||||
|
setShowInitialPassword(false);
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +232,32 @@ export function AdminUsersPage() {
|
|||||||
value={form.tenantId}
|
value={form.tenantId}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} required type="password" value={form.password} /> : null}
|
{creating ? (
|
||||||
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
<Input
|
||||||
|
label="初始密码"
|
||||||
|
onChange={(event) => updateField('password', event.target.value)}
|
||||||
|
required
|
||||||
|
suffix={(
|
||||||
|
<>
|
||||||
|
<button aria-label={showInitialPassword ? '隐藏初始密码' : '显示初始密码'} className="icon-button" onClick={() => setShowInitialPassword((current) => !current)} type="button">
|
||||||
|
{showInitialPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
</button>
|
||||||
|
<button aria-label="随机生成初始密码" className="icon-button" onClick={() => updateField('password', generateInitialPassword())} type="button">
|
||||||
|
<RefreshCw size={15} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
type={showInitialPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||||
|
<span>状态</span>
|
||||||
|
<div className="radio-row">
|
||||||
|
<label><input checked={form.status === 'active'} onChange={() => updateField('status', 'active')} type="radio" />启用</label>
|
||||||
|
<label><input checked={form.status === 'disabled'} onChange={() => updateField('status', 'disabled')} type="radio" />禁用</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
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 { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
|
||||||
|
import { formatCents } from '@/utils/currency';
|
||||||
|
|
||||||
type Recipient = {
|
type Recipient = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,6 +60,10 @@ export function ClientSendPage() {
|
|||||||
() => templates.find((item) => item.id === templateId),
|
() => templates.find((item) => item.id === templateId),
|
||||||
[templates, templateId],
|
[templates, templateId],
|
||||||
);
|
);
|
||||||
|
const selectedApplication = useMemo(
|
||||||
|
() => applications.find((item) => item.id === applicationId),
|
||||||
|
[applicationId, applications],
|
||||||
|
);
|
||||||
const filteredTemplates = templates.filter((item) => (
|
const filteredTemplates = templates.filter((item) => (
|
||||||
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
||||||
));
|
));
|
||||||
@@ -378,7 +383,7 @@ export function ClientSendPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>单价</span>
|
<span>单价</span>
|
||||||
<strong>¥0.05 / 人</strong>
|
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 人</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pa
|
|||||||
<Pagination
|
<Pagination
|
||||||
nextDisabled={activePage >= totalPages}
|
nextDisabled={activePage >= totalPages}
|
||||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||||
|
onPageChange={setPage}
|
||||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||||
page={activePage}
|
page={activePage}
|
||||||
previousDisabled={activePage <= 1}
|
previousDisabled={activePage <= 1}
|
||||||
total={data.length}
|
total={data.length}
|
||||||
|
totalPages={totalPages}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ import { AppShell } from '@/layouts/AppShell';
|
|||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
const session = readSession();
|
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 [downstreamAlertCount, setDownstreamAlertCount] = useState(0);
|
||||||
const loadPendingAuditCount = useCallback(() => {
|
const loadPendingAuditCount = useCallback(() => {
|
||||||
adminApi.getDashboard()
|
adminApi.getDashboard()
|
||||||
.then((dashboard) => {
|
.then((dashboard) => {
|
||||||
setPendingAuditCount(dashboard.pendingAuditCount ?? 0);
|
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
|
||||||
setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0);
|
setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setPendingAuditCount(0);
|
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
|
||||||
setDownstreamAlertCount(0);
|
setDownstreamAlertCount(0);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -78,7 +78,10 @@ export function AdminLayout() {
|
|||||||
userName={session.user.displayName}
|
userName={session.user.displayName}
|
||||||
userRole="平台管理员"
|
userRole="平台管理员"
|
||||||
auditNotifications={[
|
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' },
|
{ label: '下游投递告警', count: downstreamAlertCount, to: '/admin/downstream-deliveries' },
|
||||||
]}
|
]}
|
||||||
navSections={[
|
navSections={[
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function ClientLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="短信平台客户端"
|
title="短信服务平台"
|
||||||
subtitle="短信服务控制台"
|
subtitle="短信服务控制台"
|
||||||
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||||
loginPath="/client/login"
|
loginPath="/client/login"
|
||||||
|
|||||||
@@ -201,9 +201,8 @@
|
|||||||
.ui-input:focus-within,
|
.ui-input:focus-within,
|
||||||
.ui-select:focus-within,
|
.ui-select:focus-within,
|
||||||
.ui-select--open {
|
.ui-select--open {
|
||||||
border-color: var(--color-selected);
|
border-color: var(--color-border-strong);
|
||||||
background: color-mix(in srgb, var(--color-selected) 4%, transparent);
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-border-strong) 18%, transparent);
|
||||||
box-shadow: var(--focus-ring);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input input::selection,
|
.ui-input input::selection,
|
||||||
@@ -213,7 +212,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-textarea:focus {
|
.ui-textarea:focus {
|
||||||
background: color-mix(in srgb, var(--color-selected) 4%, transparent);
|
border-color: var(--color-border-strong);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-border-strong) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input--error,
|
.ui-input--error,
|
||||||
|
|||||||
Reference in New Issue
Block a user