feat: wire admin workflows to real APIs
This commit is contained in:
@@ -25,8 +25,8 @@ export class AdminCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.certifications.list(tenantId, status);
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.certifications.list(tenantId, status, keyword);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -20,16 +20,34 @@ export interface ReviewCertificationDto {
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, status?: string) {
|
||||
list(tenantId?: string, status?: string, keyword?: string) {
|
||||
return this.prisma.enterpriseCertification.findMany({
|
||||
where: { tenantId, status },
|
||||
where: {
|
||||
tenantId,
|
||||
status: status && status !== 'all' ? status : undefined,
|
||||
OR: keyword ? [
|
||||
{ companyName: { contains: keyword } },
|
||||
{ licenseNo: { contains: keyword } },
|
||||
{ contactName: { contains: keyword } },
|
||||
{ contactPhone: { contains: keyword } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
async get(id: string) {
|
||||
const certification = await this.prisma.enterpriseCertification.findUnique({
|
||||
where: { id },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
return certification;
|
||||
}
|
||||
|
||||
async submit(data: SubmitCertificationDto) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
CopyChannelDto,
|
||||
CreateChannelDto,
|
||||
CreateChannelGroupDto,
|
||||
CreateChannelGroupItemDto,
|
||||
@@ -40,11 +41,26 @@ export class ChannelsController {
|
||||
return this.channels.changeChannelStatus(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/copy')
|
||||
copyChannel(@Param('id') channelId: string, @Body() body: CopyChannelDto) {
|
||||
return this.channels.copyChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Delete('channels/:id')
|
||||
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
||||
return this.channels.deleteChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Get('channels/:id/metrics')
|
||||
listChannelMetrics(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelMetrics(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/link-logs')
|
||||
listChannelLinkLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelLinkLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connections')
|
||||
listChannelConnections(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnections(channelId);
|
||||
|
||||
@@ -2,11 +2,42 @@ import { ChannelsService } from './channels.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
id: 'channel-1',
|
||||
code: 'CMPP-A',
|
||||
name: '主通道',
|
||||
carrier: 'mobile',
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 7890,
|
||||
enterpriseCode: 'EC',
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '3.0',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
|
||||
};
|
||||
return {
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
smsChannel: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
})),
|
||||
smsChannel: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', status: 'active' }),
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
},
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
@@ -26,7 +57,8 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
@@ -54,6 +86,7 @@ function createPrismaMock() {
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,6 +194,25 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' });
|
||||
|
||||
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' }));
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('soft deletes channels through status change', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' });
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
|
||||
it('upserts and lists CMPP connection states', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -175,6 +227,7 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
await service.listChannelLinkLogs('channel-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
@@ -192,5 +245,13 @@ describe('ChannelsService', () => {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'cmpp_connection.connected',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: 'channel-1:conn-a',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,12 @@ export interface ChangeChannelStatusDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CopyChannelDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -164,6 +170,91 @@ export class ChannelsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
|
||||
const source = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { reportFields: true },
|
||||
});
|
||||
if (!source) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const nextName = data.name ?? `${source.name}副本`;
|
||||
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
|
||||
|
||||
const copied = await this.prisma.$transaction(async (tx) => {
|
||||
const nextChannel = await tx.smsChannel.create({
|
||||
data: {
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
carrier: source.carrier,
|
||||
protocol: source.protocol,
|
||||
gatewayHost: source.gatewayHost,
|
||||
gatewayPort: source.gatewayPort,
|
||||
enterpriseCode: source.enterpriseCode,
|
||||
account: source.account,
|
||||
passwordCipher: source.passwordCipher,
|
||||
srcId: source.srcId,
|
||||
cmppVersion: source.cmppVersion,
|
||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||
unitPrice: source.unitPrice,
|
||||
status: source.status,
|
||||
config: source.config as Prisma.InputJsonValue | undefined,
|
||||
reportFields: {
|
||||
create: source.reportFields.map((field) => ({
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
sortOrder: field.sortOrder,
|
||||
status: field.status,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { reportFields: true },
|
||||
});
|
||||
|
||||
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
|
||||
if (reportMaterials.length > 0) {
|
||||
await tx.signatureReportMaterial.createMany({
|
||||
data: reportMaterials.map((material) => ({
|
||||
signatureId: material.signatureId,
|
||||
channelId: nextChannel.id,
|
||||
fieldCode: material.fieldCode,
|
||||
fieldValue: material.fieldValue,
|
||||
fileObjectId: material.fileObjectId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.copy',
|
||||
resource: 'sms_channel',
|
||||
resourceId: nextChannel.id,
|
||||
detail: {
|
||||
sourceChannelId: source.id,
|
||||
sourceCode: source.code,
|
||||
copiedReportFields: source.reportFields.length,
|
||||
copiedReportMaterials: reportMaterials.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return nextChannel;
|
||||
});
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
|
||||
return this.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
||||
}
|
||||
|
||||
testChannel(channelId: string) {
|
||||
return {
|
||||
channelId,
|
||||
@@ -188,6 +279,42 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelLinkLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const [connectionStates, logs] = await Promise.all([
|
||||
this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.operationLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ resource: 'sms_channel', resourceId: channelId },
|
||||
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
channelId,
|
||||
connectionStates,
|
||||
logs: logs.map((log) => ({
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
event: normalizeLinkEvent(log.action),
|
||||
action: log.action,
|
||||
resourceId: log.resourceId,
|
||||
detail: log.detail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
listTenantConnections(tenantId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId },
|
||||
@@ -197,7 +324,7 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
@@ -209,7 +336,7 @@ export class ChannelsService {
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
return this.prisma.cmppConnectionState.upsert({
|
||||
const state = await this.prisma.cmppConnectionState.upsert({
|
||||
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
||||
update: payload,
|
||||
create: {
|
||||
@@ -218,6 +345,21 @@ export class ChannelsService {
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status: data.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
@@ -445,3 +587,42 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'connected', 'open'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'heartbeat';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connected')) {
|
||||
return '新建';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
}
|
||||
if (action.includes('reconnecting')) {
|
||||
return '重连';
|
||||
}
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
if (action.includes('deleted')) {
|
||||
return '删除';
|
||||
}
|
||||
return '更新';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CreatePhoneSegmentDto,
|
||||
CreateSensitiveWordDto,
|
||||
DictionariesService,
|
||||
DictionaryStatusDto,
|
||||
} from './dictionaries.service';
|
||||
|
||||
@ApiTags('dictionaries')
|
||||
@@ -25,8 +26,8 @@ export class DictionariesController {
|
||||
}
|
||||
|
||||
@Get('sensitive-words')
|
||||
listSensitiveWords() {
|
||||
return this.dictionaries.listSensitiveWords();
|
||||
listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listSensitiveWords({ keyword, status });
|
||||
}
|
||||
|
||||
@Post('sensitive-words')
|
||||
@@ -34,9 +35,19 @@ export class DictionariesController {
|
||||
return this.dictionaries.createSensitiveWord(body);
|
||||
}
|
||||
|
||||
@Post('sensitive-words/:id/status')
|
||||
changeSensitiveWordStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeSensitiveWordStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('sensitive-words/:id')
|
||||
deleteSensitiveWord(@Param('id') id: string) {
|
||||
return this.dictionaries.changeSensitiveWordStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('blacklists/global')
|
||||
listGlobalBlacklist() {
|
||||
return this.dictionaries.listGlobalBlacklist();
|
||||
listGlobalBlacklist(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listGlobalBlacklist({ keyword, status });
|
||||
}
|
||||
|
||||
@Post('blacklists/global')
|
||||
@@ -44,9 +55,19 @@ export class DictionariesController {
|
||||
return this.dictionaries.createGlobalBlacklist(body);
|
||||
}
|
||||
|
||||
@Post('blacklists/global/:id/status')
|
||||
changeGlobalBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeGlobalBlacklistStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('blacklists/global/:id')
|
||||
deleteGlobalBlacklist(@Param('id') id: string) {
|
||||
return this.dictionaries.changeGlobalBlacklistStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('blacklists/enterprise')
|
||||
listEnterpriseBlacklist(@TenantId() tenantId?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist(tenantId);
|
||||
listEnterpriseBlacklist(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist({ tenantId, keyword, status });
|
||||
}
|
||||
|
||||
@Post('blacklists/enterprise')
|
||||
@@ -54,6 +75,16 @@ export class DictionariesController {
|
||||
return this.dictionaries.createEnterpriseBlacklist(body);
|
||||
}
|
||||
|
||||
@Post('blacklists/enterprise/:id/status')
|
||||
changeEnterpriseBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeEnterpriseBlacklistStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('blacklists/enterprise/:id')
|
||||
deleteEnterpriseBlacklist(@Param('id') id: string) {
|
||||
return this.dictionaries.changeEnterpriseBlacklistStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('drainage-fields')
|
||||
listDrainageFields() {
|
||||
return this.dictionaries.listDrainageFields();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DictionariesService } from './dictionaries.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
sensitiveWord: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('DictionariesService', () => {
|
||||
it('searches security control dictionaries with keyword and status filters', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
|
||||
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
|
||||
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', keyword: '投诉', status: 'active' });
|
||||
|
||||
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
}));
|
||||
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
}));
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', status: 'active', OR: expect.any(Array) }),
|
||||
include: { tenant: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.createSensitiveWord({ word: '高息贷款', level: 'high' });
|
||||
await service.createGlobalBlacklist({ phoneNumber: '13800000000', reason: '投诉', operatorId: 'admin-1' });
|
||||
await service.createEnterpriseBlacklist({ tenantId: 'tenant-1', phoneNumber: '13900000000', reason: '退订', operatorId: 'admin-1' });
|
||||
await service.changeSensitiveWordStatus('word-1', { status: 'deleted' });
|
||||
await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' });
|
||||
await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' });
|
||||
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledTimes(6);
|
||||
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CreateBlacklistDto {
|
||||
phoneNumber: string;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface CreateDrainageFieldDto {
|
||||
@@ -31,6 +32,18 @@ export interface CreateDrainageFieldDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryStatusDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DictionariesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -43,45 +56,94 @@ export class DictionariesService {
|
||||
return this.prisma.phoneSegment.create({ data });
|
||||
}
|
||||
|
||||
listSensitiveWords() {
|
||||
return this.prisma.sensitiveWord.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
|
||||
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.sensitiveWord.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ word: { contains: query.keyword } },
|
||||
{ level: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
createSensitiveWord(data: CreateSensitiveWordDto) {
|
||||
return this.prisma.sensitiveWord.create({
|
||||
async createSensitiveWord(data: CreateSensitiveWordDto) {
|
||||
const created = await this.prisma.sensitiveWord.create({
|
||||
data: {
|
||||
word: data.word,
|
||||
level: data.level ?? 'block',
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
await this.writeOperationLog(undefined, 'sensitive_word.create', 'sensitive_word', created.id, { word: data.word });
|
||||
return created;
|
||||
}
|
||||
|
||||
listGlobalBlacklist() {
|
||||
return this.prisma.globalBlacklist.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
|
||||
async changeSensitiveWordStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.sensitiveWord.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `sensitive_word.${status}`, 'sensitive_word', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
createGlobalBlacklist(data: CreateBlacklistDto) {
|
||||
return this.prisma.globalBlacklist.create({
|
||||
listGlobalBlacklist(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.globalBlacklist.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async createGlobalBlacklist(data: CreateBlacklistDto) {
|
||||
const created = await this.prisma.globalBlacklist.create({
|
||||
data: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
await this.writeOperationLog(data.operatorId, 'global_blacklist.create', 'global_blacklist', created.id, {
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
listEnterpriseBlacklist(tenantId?: string) {
|
||||
async changeGlobalBlacklistStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.globalBlacklist.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `global_blacklist.${status}`, 'global_blacklist', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
listEnterpriseBlacklist(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.enterpriseBlacklist.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
createEnterpriseBlacklist(data: CreateBlacklistDto) {
|
||||
async createEnterpriseBlacklist(data: CreateBlacklistDto) {
|
||||
if (!data.tenantId) {
|
||||
throw new Error('tenantId is required for enterprise blacklist');
|
||||
throw new BadRequestException('tenantId is required for enterprise blacklist');
|
||||
}
|
||||
const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
@@ -89,7 +151,20 @@ export class DictionariesService {
|
||||
reason: data.reason,
|
||||
status: data.status ?? 'active',
|
||||
};
|
||||
return this.prisma.enterpriseBlacklist.create({ data: createData });
|
||||
const created = await this.prisma.enterpriseBlacklist.create({ data: createData });
|
||||
await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, {
|
||||
tenantId: data.tenantId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async changeEnterpriseBlacklistStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.enterpriseBlacklist.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `enterprise_blacklist.${status}`, 'enterprise_blacklist', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
listDrainageFields() {
|
||||
@@ -108,4 +183,16 @@ export class DictionariesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
listTemplates(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword });
|
||||
}
|
||||
|
||||
@Get('audit-records')
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface StatusChangeDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TemplateListQuery {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -169,10 +175,21 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listTemplates(tenantId?: string) {
|
||||
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { variables: true },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
@@ -55,9 +55,10 @@
|
||||
- 账单流水
|
||||
- 企业认证
|
||||
- 用户管理
|
||||
- 账号设置
|
||||
- 系统日志
|
||||
|
||||
第一版不展示独立“账号设置”菜单,退出登录、修改密码统一放在右上角用户头像下拉菜单。
|
||||
|
||||
暂不开发:
|
||||
|
||||
- 彩信服务分组下全部菜单
|
||||
@@ -241,26 +242,41 @@
|
||||
- 展示总发送量、成功率、通道健康度、待处理审核数。
|
||||
- 发送监控展示通道状态、发送趋势、失败率、积压队列。
|
||||
- 数据统计支持按企业、应用、通道、日期统计。
|
||||
- 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。
|
||||
- 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。
|
||||
- 新审核任务进入时,运营端应触发浏览器通知或站内提醒;提醒数据必须来自真实待审核数量接口,不得只写死前端数字。
|
||||
|
||||
### 5.11 运营端客户与企业
|
||||
|
||||
- 支持企业列表、企业详情、新增、编辑、启用、停用。
|
||||
- 支持企业认证资料审核。
|
||||
- 支持查看企业下应用、签名、模板、发送记录、报备记录。
|
||||
- 企业管理列表不提供无业务意义的“详情”按钮;需要查看明细时从企业应用、签名、模板、发送记录等业务入口进入。
|
||||
- 企业应用管理编辑保存后返回企业应用管理列表。
|
||||
- 企业应用列表展示 CMPP 连接数;点击连接数打开连接详情弹窗,内容包含连接 id、状态、连接建立时间、最近心跳时间、上次提交时间、窗口占用等。
|
||||
- 企业应用连接详情支持删除连接;删除连接必须调用真实后端接口或 Gateway 回写接口,并写入系统日志。
|
||||
- 企业应用列表提供 CMPP 连接参数查看与一键复制能力,参数来源于真实应用/通道配置,不允许只在前端拼接假数据。
|
||||
|
||||
### 5.12 运营端审核
|
||||
|
||||
- 企业认证审核:通过、驳回、查看材料。
|
||||
- 企业认证详情必须展示客户提交的主体资料、统一社会信用代码、法定代表人、注册地址、营业执照附件、对公账户验证资料、联系人信息、提交时间、审核备注、驳回原因等;通过/驳回必须更新认证记录和租户认证状态,并写操作日志。
|
||||
- 短信模板审核:通过、驳回、敏感词提示、变量检查。
|
||||
- 短信模板审核必须支持按客户、应用、模板内容、审核编号和审核状态搜索;搜索应由真实后端 API 支持,前端只做展示和交互。
|
||||
- 短信审核:查看短信内容、号码量、计费条数、进入审核原因、命中规则、风险原因;支持通过、驳回。
|
||||
- 审核动作集中在审核中心;企业应用/签名/模板管理页面只做查询、维护、停用、备注和查看审核记录。
|
||||
|
||||
### 5.13 运营端通道管理
|
||||
|
||||
- 支持新增、编辑、删除、启用、停用短信通道。
|
||||
- 删除通道采用软删除或停用归档,不能破坏历史发送、报备、日志外键;删除、启用、停用、复制等高影响操作必须二次确认并写系统日志。
|
||||
- 支持复制通道:复制后新建一个通道,除 id/code 自动生成外,通道配置、CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段配置均需从源通道复制;名称默认追加“副本”。
|
||||
- 支持发送测试短信。
|
||||
- 支持查看通道成功率、未知率、失败率、累计发送量。
|
||||
- 支持进入通道报备详情。
|
||||
- 通道报备详情页中,签名下的引流信息默认收起,用户点击后展开;展开/收起只影响页面展示,不改变报备数据。
|
||||
- 通道列表状态区域展示“链接日志”入口;点击后弹窗展示真实连接日志,包括新建连接、断开、心跳、重连、异常等事件,日志来源于 Gateway 回写或 OperationLog。
|
||||
- 通道操作按钮应保持一致的两列布局,报备详情、编辑、复制、发送测试、启停、删除等操作文案清晰。
|
||||
|
||||
### 5.14 运营端通道组管理
|
||||
|
||||
@@ -288,6 +304,9 @@
|
||||
- 敏感词管理:发送前和审核时命中提示或拦截。
|
||||
- 手机号段库:用于运营商识别和路由。
|
||||
- 引流信息字段库:用于签名/报备资料结构化采集。
|
||||
- 企业黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
|
||||
- 企业黑名单支持按企业、应用、手机号、入库原因、状态搜索;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
|
||||
- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。
|
||||
|
||||
### 5.18 风控规则闭环
|
||||
|
||||
@@ -318,8 +337,11 @@
|
||||
|
||||
### 5.21 系统管理与审计
|
||||
|
||||
- 用户管理支持角色、权限、启停、重置密码。
|
||||
- 系统日志记录登录、配置变更、审核、发送、导入导出、密钥重置等操作。
|
||||
- 用户管理支持角色、权限、启停、重置密码;客户端用户角色第一版限定一个企业管理员,避免多个企业管理员导致租户管理边界不清。
|
||||
- 用户管理删除按钮使用统一危险操作样式;删除、启停、重置密码必须二次确认并写操作日志。
|
||||
- 客户端和运营端右上角用户头像提供下拉菜单,支持退出登录、修改密码;账号设置独立菜单第一版不展示。
|
||||
- 客户端和运营端系统日志均支持分页、搜索和详情展示;详情列内容较长时使用详情卡/弹窗展示,不能被表格窄列截断。
|
||||
- 系统日志记录登录、退出、配置变更、审核、发送、导入导出、密钥重置、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等操作。
|
||||
|
||||
## 6. 非功能需求
|
||||
|
||||
@@ -550,6 +572,12 @@
|
||||
- `GET /api/client/billing/transactions`
|
||||
- `POST /api/client/enterprise-certification`
|
||||
- `GET /api/client/users`
|
||||
- `POST /api/client/users`
|
||||
- `PUT /api/client/users/{id}`
|
||||
- `DELETE /api/client/users/{id}`
|
||||
- `POST /api/client/users/{id}/status`
|
||||
- `POST /api/client/users/{id}/password/reset`
|
||||
- `POST /api/client/auth/password/change`
|
||||
- `GET /api/client/system-logs`
|
||||
|
||||
### 10.2 运营端 API
|
||||
@@ -561,15 +589,29 @@
|
||||
- `POST /api/admin/enterprises`
|
||||
- `PUT /api/admin/enterprises/{id}`
|
||||
- `GET /api/admin/enterprise-applications`
|
||||
- `PUT /api/admin/enterprise-applications/{id}`
|
||||
- `GET /api/admin/enterprise-applications/{id}/connections`
|
||||
- `DELETE /api/admin/enterprise-applications/{id}/connections/{connectionId}`
|
||||
- `GET /api/admin/enterprise-applications/{id}/cmpp-params`
|
||||
- `GET /api/admin/enterprise-signatures`
|
||||
- `GET /api/admin/enterprise-templates`
|
||||
- `GET /api/admin/enterprise-certifications`
|
||||
- `GET /api/admin/enterprise-certifications/{id}`
|
||||
- `POST /api/admin/enterprise-certifications/{id}/approve`
|
||||
- `POST /api/admin/enterprise-certifications/{id}/reject`
|
||||
- `GET /api/admin/audit-summary`
|
||||
- `POST /api/admin/audits/{id}/approve`
|
||||
- `POST /api/admin/audits/{id}/reject`
|
||||
- `GET /api/admin/channels`
|
||||
- `POST /api/admin/channels`
|
||||
- `PUT /api/admin/channels/{id}`
|
||||
- `POST /api/admin/channels/{id}/status`
|
||||
- `DELETE /api/admin/channels/{id}`
|
||||
- `POST /api/admin/channels/{id}/copy`
|
||||
- `POST /api/admin/channels/{id}/test`
|
||||
- `GET /api/admin/channels/{id}/reports`
|
||||
- `GET /api/admin/channels/{id}/connections`
|
||||
- `GET /api/admin/channels/{id}/link-logs`
|
||||
- `POST /api/admin/channel-groups`
|
||||
- `GET /api/admin/report-tasks`
|
||||
- `POST /api/admin/report-tasks/generate`
|
||||
@@ -579,9 +621,18 @@
|
||||
- `GET /api/admin/sms/tasks`
|
||||
- `GET /api/admin/sms/records`
|
||||
- `GET /api/admin/sms/uplinks`
|
||||
- `GET /api/admin/blacklists/enterprise`
|
||||
- `GET /api/admin/blacklists/global`
|
||||
- `GET /api/admin/sensitive-words`
|
||||
- `GET /api/admin/dictionaries/blacklists/enterprise`
|
||||
- `POST /api/admin/dictionaries/blacklists/enterprise`
|
||||
- `POST /api/admin/dictionaries/blacklists/enterprise/{id}/status`
|
||||
- `DELETE /api/admin/dictionaries/blacklists/enterprise/{id}`
|
||||
- `GET /api/admin/dictionaries/blacklists/global`
|
||||
- `POST /api/admin/dictionaries/blacklists/global`
|
||||
- `POST /api/admin/dictionaries/blacklists/global/{id}/status`
|
||||
- `DELETE /api/admin/dictionaries/blacklists/global/{id}`
|
||||
- `GET /api/admin/dictionaries/sensitive-words`
|
||||
- `POST /api/admin/dictionaries/sensitive-words`
|
||||
- `POST /api/admin/dictionaries/sensitive-words/{id}/status`
|
||||
- `DELETE /api/admin/dictionaries/sensitive-words/{id}`
|
||||
- `GET /api/admin/risk-rules`
|
||||
- `POST /api/admin/risk-rules`
|
||||
- `PUT /api/admin/risk-rules/{id}`
|
||||
@@ -593,7 +644,17 @@
|
||||
- `POST /api/admin/billing/recharges`
|
||||
- `GET /api/admin/phone-segments`
|
||||
- `GET /api/admin/drainage-fields`
|
||||
- `POST /api/admin/drainage-fields`
|
||||
- `PUT /api/admin/drainage-fields/{id}`
|
||||
- `DELETE /api/admin/drainage-fields/{id}`
|
||||
- `GET /api/admin/users`
|
||||
- `POST /api/admin/users`
|
||||
- `PUT /api/admin/users/{id}`
|
||||
- `DELETE /api/admin/users/{id}`
|
||||
- `POST /api/admin/users/{id}/status`
|
||||
- `POST /api/admin/users/{id}/password/reset`
|
||||
- `POST /api/admin/auth/password/change`
|
||||
- `GET /api/admin/system-logs`
|
||||
|
||||
### 10.3 通道回调 API
|
||||
|
||||
@@ -774,8 +835,9 @@
|
||||
2. NestJS 后端实现 Prisma schema、migration、Service、Controller、DTO、单元测试。
|
||||
3. Go Gateway 实现配置、连接管理、CMPP submit/deliver/active test、回执事件发布、单元测试。
|
||||
4. 前端将对应 mock 数据替换为 API 调用,保留现有视觉样式。
|
||||
5. 补充错误处理、权限校验、操作日志。
|
||||
6. 运行构建和相关测试。
|
||||
5. 原型阶段的 mock、localStorage 或静态数据只能作为开发临时兜底,不得作为真实开发完成标准;审核、通道、连接、日志、黑名单、敏感词、报备字段、用户、账务等闭环必须接入真实 API、数据库或 Gateway 回写。
|
||||
6. 补充错误处理、权限校验、操作日志。
|
||||
7. 运行构建和相关测试,并更新测试进度文档。
|
||||
```
|
||||
|
||||
## 14. 已确认关键决策
|
||||
@@ -1096,6 +1158,7 @@
|
||||
- 不从零手写整个 CMPP 协议栈,也不直接照搬完整开源网关;协议层可复用,服务层按本项目自研。
|
||||
- NestJS 负责业务审核、风控、计费、报备、路由和发送编排。
|
||||
- Go Gateway 只负责 CMPP 连接、协议提交、submit resp、回执、上行事件回传。
|
||||
- 当前项目已进入真实开发阶段;前端 mock、localStorage 或静态数据只能临时兜底,不能作为功能完成标准。涉及审核、通道、连接、日志、安全控制、用户、账务等闭环时,必须接入真实 API、数据库或 Gateway 回写,并补测试。
|
||||
|
||||
请从 docs/first-version-development-requirements.md 的“阶段 0:技术 Spike”开始执行。
|
||||
|
||||
|
||||
@@ -174,6 +174,36 @@
|
||||
- 可展示匹配到的下发 messageId。
|
||||
- 未匹配上行仍可查询,状态或关联为空。
|
||||
|
||||
### TC-CLIENT-010 用户管理与企业管理员唯一性
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:企业管理员已登录,租户下已有一个企业管理员和一个普通用户。
|
||||
- 步骤:
|
||||
1. 打开客户端用户管理页面。
|
||||
2. 新增普通用户并保存。
|
||||
3. 编辑普通用户,尝试将角色改为企业管理员。
|
||||
4. 删除普通用户并确认。
|
||||
- 预期结果:
|
||||
- 新增、编辑、删除均调用真实客户端用户 API。
|
||||
- 第一版同一租户只允许一个企业管理员;重复设置时返回明确错误或前端禁用该角色选项。
|
||||
- 删除按钮使用统一危险操作样式,删除前二次确认。
|
||||
- 用户创建、编辑、删除均写入系统日志。
|
||||
|
||||
### TC-CLIENT-011 头像菜单、密码修改与系统日志分页
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:客户端用户已登录,系统日志超过一页。
|
||||
- 步骤:
|
||||
1. 点击右上角用户头像。
|
||||
2. 执行修改密码并重新登录。
|
||||
3. 再次点击头像执行退出登录。
|
||||
4. 打开客户端系统日志,切换分页并查看长详情。
|
||||
- 预期结果:
|
||||
- 头像下拉展示退出登录、修改密码,不展示独立账号设置菜单。
|
||||
- 修改密码调用真实 API,旧密码失效,新密码可登录。
|
||||
- 退出登录清理会话并写日志。
|
||||
- 系统日志分页来自真实 API,长详情使用详情卡或弹窗展示,不被表格窄列截断。
|
||||
|
||||
## 5. 运营端功能用例
|
||||
|
||||
### TC-ADMIN-001 签名审核通过
|
||||
@@ -348,6 +378,141 @@
|
||||
- 若人为构造缺失流水,diff 展示差额。
|
||||
- 查询结果支持定位 taskId/messageId。
|
||||
|
||||
### TC-ADMIN-014 短信模板审核搜索与审核
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:存在不同客户、应用、内容、状态的短信模板审核记录。
|
||||
- 步骤:
|
||||
1. 打开运营端短信模板审核页面。
|
||||
2. 分别按客户、应用、模板内容、审核编号、审核状态搜索。
|
||||
3. 对一条待审核模板执行通过。
|
||||
4. 对另一条待审核模板执行驳回并填写原因。
|
||||
- 预期结果:
|
||||
- 搜索条件由真实后端 API 处理,结果只包含匹配记录。
|
||||
- 通过后模板状态变为 approved,驳回后模板状态变为 rejected。
|
||||
- 审核记录、操作者、审核时间和驳回原因可追溯。
|
||||
- 客户端模板列表同步展示最新状态。
|
||||
|
||||
### TC-ADMIN-015 企业认证详情与审核闭环
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:客户已提交企业认证资料,包含主体信息、营业执照、对公账户验证和联系人信息。
|
||||
- 步骤:
|
||||
1. 打开运营端企业认证审核列表并按客户名称搜索。
|
||||
2. 进入认证详情,核对统一社会信用代码、法定代表人、注册地址、营业执照附件、银行验证资料、联系人、提交时间。
|
||||
3. 审核通过一条认证。
|
||||
4. 驳回另一条认证并填写原因,客户修改资料后重新提交。
|
||||
- 预期结果:
|
||||
- 详情页展示客户真实提交资料,不使用前端静态内容。
|
||||
- 通过后认证记录和租户认证状态同步为 approved。
|
||||
- 驳回后客户可见驳回原因,可重新提交。
|
||||
- 审核动作写入系统日志,并影响立即发送和定时到点发送准入。
|
||||
|
||||
### TC-ADMIN-016 通道复制真实闭环
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:存在一个 active CMPP 通道,已配置 CMPP 参数、通道报备字段、签名/引流报备材料和个性化字段。
|
||||
- 步骤:
|
||||
1. 在通道列表点击复制通道并确认。
|
||||
2. 查询新通道详情。
|
||||
3. 打开新通道报备详情。
|
||||
4. 查询操作日志。
|
||||
- 预期结果:
|
||||
- 后端创建新通道,新通道 id/code 与源通道不同,名称默认追加“副本”。
|
||||
- 通道配置、CMPP 参数、报备字段、签名/引流报备材料和个性化字段与源通道一致。
|
||||
- 复制动作写入系统日志。
|
||||
- 复制后新通道可继续编辑、启停、删除,不影响源通道。
|
||||
|
||||
### TC-ADMIN-017 通道启停、删除确认与软删除
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:存在 active 通道,且通道关联历史发送、报备和日志记录。
|
||||
- 步骤:
|
||||
1. 点击停用通道,取消确认。
|
||||
2. 再次点击停用并确认。
|
||||
3. 点击启用并确认。
|
||||
4. 点击删除并确认。
|
||||
5. 查询历史发送、报备和日志。
|
||||
- 预期结果:
|
||||
- 取消确认不改变数据库状态。
|
||||
- 启用、停用、删除均调用真实后端 API 并写系统日志。
|
||||
- 删除采用软删除或停用归档,不破坏历史发送、报备、日志外键。
|
||||
- 软删除后的通道不再参与路由和新任务发送。
|
||||
|
||||
### TC-ADMIN-018 企业应用 CMPP 连接数、连接详情与参数复制
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:Gateway 或测试替身已向 NestJS 回写企业应用连接状态,应用已配置 CMPP 接入参数。
|
||||
- 步骤:
|
||||
1. 打开企业应用管理列表。
|
||||
2. 查看 CMPP 状态列连接数。
|
||||
3. 点击连接数打开连接详情。
|
||||
4. 删除一个连接并确认。
|
||||
5. 点击 CMPP 连接参数按钮并一键复制。
|
||||
- 预期结果:
|
||||
- 连接数来自真实连接状态 API。
|
||||
- 连接详情展示连接 id、状态、建立时间、最近心跳时间、上次提交时间、窗口占用等要素。
|
||||
- 删除连接调用真实后端或 Gateway 接口,连接状态刷新并写系统日志。
|
||||
- CMPP 参数来源于真实应用/通道配置,一键复制内容与 API 返回一致。
|
||||
|
||||
### TC-ADMIN-019 通道链接日志展示
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:Gateway 或测试替身已产生新建连接、心跳、断开、重连、异常事件。
|
||||
- 步骤:
|
||||
1. 打开短信通道管理页面。
|
||||
2. 在状态区域点击链接日志。
|
||||
3. 查看日志时间、事件类型、连接 id、详情。
|
||||
- 预期结果:
|
||||
- 链接日志由真实后端 API 返回,不使用前端硬编码数据。
|
||||
- 日志包含新建连接、断开、心跳、重连、异常等事件。
|
||||
- 日志按时间倒序展示,并可定位到对应通道或连接。
|
||||
|
||||
### TC-ADMIN-020 安全控制搜索、添加、启停与删除
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:运营管理员已登录,存在企业、应用和若干黑名单/敏感词数据。
|
||||
- 步骤:
|
||||
1. 在企业黑名单中按企业、应用、手机号、原因、状态搜索。
|
||||
2. 新增企业黑名单,停用后再删除。
|
||||
3. 在全局黑名单中按手机号、原因、状态搜索,并新增、停用、删除。
|
||||
4. 在敏感词管理中按词、分类/级别、状态搜索,并新增、停用、删除。
|
||||
- 预期结果:
|
||||
- 搜索、添加、启停、删除均调用真实字典 API。
|
||||
- 启停/删除后发送前风控只使用 active 数据。
|
||||
- 所有安全控制变更写入系统日志。
|
||||
- 操作按钮使用统一编辑、删除、启停样式。
|
||||
|
||||
### TC-ADMIN-021 运营端消息铃铛审核提醒
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:浏览器允许通知,存在企业认证、短信审核、模板审核、签名审核待处理任务。
|
||||
- 步骤:
|
||||
1. 打开运营端任意页面,查看右上角消息铃铛数字。
|
||||
2. 点击铃铛查看分类数量。
|
||||
3. 点击某个分类。
|
||||
4. 新增一条待审核任务。
|
||||
- 预期结果:
|
||||
- 铃铛数字等于所有待审核任务总数,分类数量来自真实待审核统计 API。
|
||||
- 点击分类跳转到对应审核页面并带入筛选状态。
|
||||
- 新审核任务进入时触发浏览器通知或站内提醒。
|
||||
- 审核完成后总数和分类数量刷新。
|
||||
|
||||
### TC-ADMIN-022 运营端系统日志分页与详情展示
|
||||
|
||||
- 优先级:P1
|
||||
- 前置条件:存在超过一页的运营端系统日志,且部分日志详情较长。
|
||||
- 步骤:
|
||||
1. 打开运营端系统日志页面。
|
||||
2. 按操作者、操作类型、资源、时间搜索。
|
||||
3. 切换分页。
|
||||
4. 查看长详情日志。
|
||||
- 预期结果:
|
||||
- 分页、搜索由真实后端 API 处理。
|
||||
- 页面只保留一个标题和一个图标。
|
||||
- 长详情使用详情卡或弹窗展示,不被表格窄列截断。
|
||||
- 能查询到登录、退出、审核、通道复制、通道启停、通道删除、连接状态变化、安全控制变更等日志。
|
||||
|
||||
## 6. 风控规则专项用例
|
||||
|
||||
### TC-RISK-001 单任务最大号码数直接拒绝
|
||||
@@ -2196,3 +2361,124 @@ npm run verify:phase8
|
||||
```
|
||||
|
||||
若测试环境具备 PostgreSQL、Redis、MinIO,再补充执行 E2E smoke 和真实 API HTTP 测试。
|
||||
|
||||
## 17. 新增和更新用例细化执行清单
|
||||
|
||||
本节用于细化 2026-07-02 新增的页面真实后端、Dashboard、人工充值、系统日志、客户管理和 CMPP 连接状态用例。执行时应优先使用真实 NestJS API、Prisma/PostgreSQL、Redis/BullMQ 和 Gateway 测试替身;mock、localStorage 或前端静态数组只能作为单元测试替身,不能作为业务验收通过依据。
|
||||
|
||||
### 17.1 通用断言规则
|
||||
|
||||
| 断言类型 | 检查点 |
|
||||
| --- | --- |
|
||||
| 数据来源 | 页面列表、详情、统计卡片、弹窗和下拉选项均必须来自真实 API 响应;网络失败时可有兜底展示,但兜底不能计入通过。 |
|
||||
| 租户隔离 | 客户端接口必须以当前租户为边界;通过 URL、查询参数或资源 id 访问其他租户数据时,应返回无权限、无数据或明确错误。 |
|
||||
| 状态联动 | 客户、应用、签名、模板、引流信息、通道、连接状态变化后,立即发送、定时到点、导入确认发送都必须重新校验。 |
|
||||
| 日志证据 | 创建、编辑、删除、启停、复制、审核、导入、导出、充值、冲正、发送阻断、连接状态变化、失败动作都必须写系统日志。 |
|
||||
| 历史数据 | 软删除、停用、归档不得破坏历史发送、报备、计费、trace 和对账记录。 |
|
||||
| 计费口径 | Dashboard、账单流水、短信计费记录、账户交易和 reconciliation 的金额、条数、状态口径必须一致。 |
|
||||
| Gateway 边界 | 不依赖真实运营商 SMSC;CMPP 登录、心跳、断线、重连、慢响应使用 Go Gateway 本地模拟器或连接状态回写 API。 |
|
||||
|
||||
### 17.2 客户端用户和系统日志细化
|
||||
|
||||
| 用例 | 细化执行点 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-CLIENT-010 | 新增普通用户,检查请求体包含 tenantId、手机号、角色、状态;编辑用户基础信息;尝试把第二个用户设为企业管理员;删除普通用户。 | 新增后用户列表刷新;同租户第二个企业管理员被阻止;删除为软删除或状态不可用;每步写 `operation_logs`,resource 指向 user id。 |
|
||||
| TC-CLIENT-010 | 使用普通用户登录后访问用户管理页面和 API。 | 普通用户无权限或只读;无权限访问也写失败日志。 |
|
||||
| TC-CLIENT-011 | 点击头像下拉,执行修改密码,使用旧密码登录,再使用新密码登录。 | 旧密码失效,新密码有效;修改密码日志不泄露明文密码;退出登录清理 token/session。 |
|
||||
| TC-CLIENT-011 | 客户端系统日志准备至少 2 页数据,按动作、操作者、时间查询,查看长详情。 | 分页参数传给后端;总数、页码、页大小准确;长详情不在表格中截断,详情弹窗/卡片展示完整 JSON 摘要。 |
|
||||
|
||||
### 17.3 运营端真实后端页面细化
|
||||
|
||||
| 用例 | 细化执行点 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-ADMIN-014 | 按客户名称、应用名称、模板内容、审核编号、审核状态分别搜索模板审核列表。 | 每次搜索均发起 API 请求;结果只包含匹配数据;清空条件后恢复默认列表;跨租户/不存在关键字无误展示。 |
|
||||
| TC-ADMIN-014 | 对待审核模板分别执行通过、驳回。 | 审核状态更新;审核记录包含审核人、时间、原因;客户端模板列表同步;驳回模板不能发送。 |
|
||||
| TC-ADMIN-015 | 运营端查看企业认证详情,核对主体信息、执照附件、对公账户验证、联系人。 | 详情字段来自 certification API;附件 id/URL 可追溯;通过/驳回同步 Tenant.certificationStatus;驳回后客户可重提。 |
|
||||
| TC-ADMIN-016 | 复制通道,随后查询新通道详情、报备字段、签名报备材料。 | 新通道 code/id 唯一;CMPP 参数、限速、报备字段、材料被复制;源通道不受影响;复制日志包含 sourceChannelId 和 newChannelId。 |
|
||||
| TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 |
|
||||
| TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 |
|
||||
| TC-ADMIN-019 | 打开通道链接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 |
|
||||
| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 |
|
||||
| TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 |
|
||||
| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 |
|
||||
|
||||
### 17.4 Dashboard 指标细化
|
||||
|
||||
| 用例 | 数据准备 | 指标断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-DASHBOARD-001 | 客户 A 当天 delivered=10、failed=3、unknown=2、timeout=1;客户 B 有干扰数据。 | 客户端总量=16;成功=10;失败按 failed+timeout 为 4;unknown=2;成功率若按 delivered/total 为 62.5%;点击卡片后的明细筛选一致。 |
|
||||
| TC-DASHBOARD-002 | 账户余额 10000 分、套餐 200 条、授信 5000 分,另有冻结、扣费、释放、退款流水。 | 可用余额不重复计算冻结;金额余额和套餐余量分开展示;账单流水余额 after 与 Dashboard 一致。 |
|
||||
| TC-DASHBOARD-003 | 待审核签名 2、模板 3、待报备 1、pending_review 发送任务 4。 | 待处理总数和分类数准确;点击跳转后列表筛选数量一致;只包含当前租户。 |
|
||||
| TC-DASHBOARD-004 | 多客户、多通道、多状态发送和账务流水。 | 运营端统计全平台;活跃客户、今日发送、成功率、待审核、收入均可在明细页复核。 |
|
||||
| TC-DASHBOARD-005 | 客户 A/B 均有发送、账务、审核数据。 | 切换客户后所有卡片、趋势、状态分布、账务汇总同步刷新;跳转明细继承客户筛选。 |
|
||||
| TC-DASHBOARD-006 | 通道 A online 2/2,B disconnected 0/2,C auth_failed。 | 在线连接总数等于各通道 currentConnections 之和;异常通道数分类准确;点击异常通道展示错误原因。 |
|
||||
| TC-DASHBOARD-007 | 准备跨日、跨小时和时区边界数据。 | 今日、近 7 天、近 30 天边界明确;趋势图每个点位与明细聚合一致;使用平台时区。 |
|
||||
|
||||
### 17.5 人工充值和账务细化
|
||||
|
||||
| 用例 | 细化执行点 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
|
||||
| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 |
|
||||
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
||||
| TC-BILLING-009 | 无权限用户、审核员、管理员、大额审批分别执行充值。 | 权限不足被拒绝并写失败日志;大额充值 pending 时不更新余额;审批通过才入账,驳回不入账。 |
|
||||
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
|
||||
|
||||
### 17.6 系统日志细化
|
||||
|
||||
| 用例 | 细化执行点 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-LOG-005 | 客户 A 查看日志并尝试查询客户 B 日志。 | 客户端只返回本租户日志;越权查询失败;日志包含 IP、User-Agent、result、resourceId。 |
|
||||
| TC-LOG-006 | 客户端导入号码、立即发送、创建并取消定时任务。 | 导入日志含文件名、行数、成功/失败数;发送日志含任务编号、号码数、发送类型;取消日志含取消人。 |
|
||||
| TC-LOG-007 | 运营端按客户、动作、资源、结果、时间查询并导出。 | 查询准确;导出内容与筛选一致;导出动作本身写日志。 |
|
||||
| TC-LOG-008 | 人工充值、冲正、账户调整。 | 日志含客户、金额、短信条数、订单号、流水号、操作者;敏感字段脱敏。 |
|
||||
| TC-LOG-009 | 触发无权限充值、余额不足发送、无在线通道发送。 | 失败动作也写日志;result/status 标记失败;失败原因与前端提示一致。 |
|
||||
|
||||
### 17.7 客户管理细化
|
||||
|
||||
| 用例 | 细化执行点 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-CUSTOMER-001 | 运营端创建客户并初始化管理员账号、租户、账户。 | Tenant、管理员用户、TenantAccount 创建成功;客户详情业务入口可用;新管理员只能访问本租户。 |
|
||||
| TC-CUSTOMER-002 | 修改客户名称、联系人、备注,查看客户端和历史数据。 | 展示同步更新;历史任务和账务不丢失;日志记录修改前后摘要。 |
|
||||
| TC-CUSTOMER-003 | 停用客户后分别通过客户端、API、CMPP 接入尝试发送。 | 全部阻断;不入队、不扣费;失败原因是客户停用;历史任务可查。 |
|
||||
| TC-CUSTOMER-004 | 客户 active 时创建 scheduled,到点前停用。 | 到点重校验失败;任务 rejected/canceled/failed;冻结费用释放;日志指向客户停用。 |
|
||||
| TC-CUSTOMER-005 | 重新启用客户后发送。 | 客户状态 active;新发送成功进入链路;账务和日志完整。 |
|
||||
| TC-CUSTOMER-006 | 余额不足、套餐不足、授信不足、欠费标记。 | 发送前账户校验失败;不投递 Gateway;客户详情展示欠费或不足状态。 |
|
||||
| TC-CUSTOMER-007 | 客户 A 使用 URL/API 参数访问客户 B 资源。 | 不泄露 B 数据;返回无权限或空结果;失败访问写安全日志。 |
|
||||
| TC-CUSTOMER-008 | 删除/归档有历史数据的客户。 | 不允许硬删除或执行归档;新发送和未执行 scheduled 阻断;历史 trace/对账可查。 |
|
||||
| TC-CUSTOMER-009 | 客户详情总览应用、签名、模板、今日发送、余额。 | 各指标与明细列表聚合一致;跳转带客户筛选;异常状态有标识。 |
|
||||
| TC-CUSTOMER-010 | 客户绑定通道组,主通道 online、备通道 disconnected。 | 客户详情展示通道组和连接状态;发送路由选择 online 且报备通过通道;trace channelId 一致。 |
|
||||
| TC-CUSTOMER-011 | 客户 A/B 不同连接配置和在线数。 | 客户列表摘要如 `2/2 online`、`1/3 degraded`;详情和通道监控一致。 |
|
||||
| TC-CUSTOMER-012 | 调整客户通道连接数并触发 Gateway 重载。 | desired/current 连接数最终一致;发送能力或窗口容量随配置变化;日志记录变更。 |
|
||||
| TC-CUSTOMER-013 | 超过通道最大连接数分配。 | 保存失败;提示最大连接数、已分配数和可用数;不影响已有连接;失败日志存在。 |
|
||||
|
||||
### 17.8 CMPP 连接状态细化
|
||||
|
||||
| 用例 | 模拟方式 | 必查断言 |
|
||||
| --- | --- | --- |
|
||||
| TC-CMPP-STATUS-001 | Gateway 未启动或未回写。 | 通道业务 active 与连接 disconnected/unknown 分开展示;不可误判为可提交。 |
|
||||
| TC-CMPP-STATUS-002 | 模拟 SMSC 登录成功。 | 状态 online;连接建立时间、最近心跳、窗口可用;发送可路由到该通道。 |
|
||||
| TC-CMPP-STATUS-003 | 模拟登录认证失败。 | 状态 auth_failed;错误码/原因展示;路由跳过;日志/告警记录。 |
|
||||
| TC-CMPP-STATUS-004 | 模拟 active test 超时。 | 状态 heartbeat_timeout/disconnected;进入重连;新发送走备用或等待失败。 |
|
||||
| TC-CMPP-STATUS-005 | 模拟 TCP 断开再恢复。 | 状态 disconnected -> reconnecting -> online;重连次数增加;未确认消息状态明确。 |
|
||||
| TC-CMPP-STATUS-006 | 主通道离线、备用在线且报备通过。 | 路由跳过主通道并选择备用;trace 展示备用 channelId。 |
|
||||
| TC-CMPP-STATUS-007 | 所有通道离线或认证失败。 | 不提交到离线连接;任务 delayed/retry/failed/pending_channel;不错误扣费。 |
|
||||
| TC-CMPP-STATUS-008 | 通道业务 disabled 但连接 online。 | 不参与路由;连接状态仍可运维观察;启用后按连接状态恢复可用性。 |
|
||||
| TC-CMPP-STATUS-009 | 模拟 submit resp 慢响应。 | 窗口占用、慢响应、队列积压可见;恢复后积压下降;超时可追踪。 |
|
||||
| TC-CMPP-STATUS-010 | 触发 online/disconnected/reconnecting/online。 | 每次变化有状态历史、健康指标和系统日志。 |
|
||||
| TC-CMPP-STATUS-011 | 通道 maxConnections=4、desired=2、current=2。 | 通道详情、监控、Dashboard 连接数一致;连接列表展示 connectionId、心跳、窗口、sequence。 |
|
||||
| TC-CMPP-STATUS-012 | desired 1 调整为 3。 | Gateway 建立新连接至 3/3;任务可按连接/窗口分摊;日志记录调整。 |
|
||||
| TC-CMPP-STATUS-013 | desired 3 调整为 1。 | 多余连接优雅关闭;未确认 submit 不丢失不重复;终态 1/1。 |
|
||||
| TC-CMPP-STATUS-014 | 3 条连接中断 1 条。 | 展示 degraded 或 2/3 online;异常连接数增加;重连恢复后 3/3。 |
|
||||
| TC-CMPP-STATUS-015 | desired=0 或 current=0。 | 路由不选择该通道;无备用时任务失败或等待;原因包含无在线连接。 |
|
||||
| TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 |
|
||||
|
||||
### 17.9 自动化落地建议
|
||||
|
||||
| 层级 | 建议覆盖 |
|
||||
| --- | --- |
|
||||
| API Jest | 认证、字典、安全控制、通道复制/软删除、连接状态、人工充值、系统日志查询、Dashboard 聚合口径。 |
|
||||
| HTTP Smoke | 客户创建、认证审核、通道复制、连接状态回写、人工充值、立即发送、定时到点、trace、reconciliation。 |
|
||||
| Go Gateway | 连接状态回写契约、登录成功/失败、心跳超时、断线重连、窗口占满、连接数调整。 |
|
||||
| 前端 Smoke | 客户端头像菜单、系统日志分页、运营模板审核搜索、企业认证详情、通道复制/链接日志、安全控制 CRUD、Dashboard 指标跳转。 |
|
||||
| 性能 Smoke | BullMQ 500 TPS、CMPP 连接数变化后的提交能力、慢响应积压恢复。 |
|
||||
|
||||
@@ -79,7 +79,8 @@ Windows 本项目推荐使用根脚本 `npm run test:gateway`,脚本会临时
|
||||
|
||||
## 4. 已知边界
|
||||
|
||||
- 本轮 API 测试不连接真实 PostgreSQL、Redis、MinIO。
|
||||
- 发送 Worker 的 Redis 限速和 BullMQ 投递在 unit/light integration 中使用 mock;真实 Redis 链路由 `spike:bullmq` 覆盖。
|
||||
- 前端暂未新增测试框架;当前保留 `npm run build` 作为 smoke。若后续引入 Vitest/Playwright,应先覆盖登录页、客户端发送页、运营端监控页的加载 smoke。
|
||||
- 单元测试和轻集成测试可以使用 mock Prisma/BullMQ/Redis 作为测试替身,但这只适用于测试隔离,不代表业务功能可以停留在 mock。
|
||||
- 真实开发完成标准必须包含:Prisma/PostgreSQL 模型或查询、NestJS Service/Controller、必要的操作日志、前端调用真实 API,以及在真实 PostgreSQL/Redis/MinIO 可用时完成 smoke。
|
||||
- 发送 Worker 的 Redis 限速和 BullMQ 投递可在 unit/light integration 中使用 mock;真实 Redis 链路仍需由 `spike:bullmq`、API smoke 或端到端验证覆盖。
|
||||
- 前端暂未新增测试框架;当前保留 `npm run build` 作为 smoke。新增页面能力不能只依赖前端本地状态或 localStorage,除非需求明确声明为临时演示。
|
||||
- Gateway 不连接真实运营商 SMSC;使用 gocmpp 适配测试和内部模拟器测试。
|
||||
|
||||
@@ -173,3 +173,49 @@ npm run test:gateway
|
||||
|
||||
- 客户侧导入当前提供 API 级文本预览/确认闭环;浏览器端真实文件选择、GBK 二进制转码和错误文件下载仍需前端/E2E 后续覆盖。
|
||||
- Gateway 连接状态通过 NestJS API 支持 mock/Gateway 回写;真实运营商 SMSC 联调仍需运营商测试环境。
|
||||
|
||||
## 2026-07-02 运营端优化转真实后端补齐
|
||||
|
||||
### 本轮修复范围
|
||||
|
||||
- 修正测试策略说明:mock 仅作为单元/轻集成测试替身,不能作为业务完成标准;新增页面能力必须接 NestJS API、Prisma/PostgreSQL 和必要操作日志。
|
||||
- 通道管理补齐真实 API:
|
||||
- `POST /api/admin/channels/:id/copy`:复制通道配置、通道报备字段和该通道签名报备材料,写入操作日志。
|
||||
- `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。
|
||||
- `GET /api/admin/channels/:id/link-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询链接日志。
|
||||
- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
|
||||
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
|
||||
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。
|
||||
- 前端新增 `/api` Vite 代理和 `src/api/adminApi.ts`,通道管理、模板审核、企业认证审核优先调用真实 API,API 不可用时仅保留静态兜底避免开发预览空白。
|
||||
|
||||
### 新增/更新测试
|
||||
|
||||
| 测试文件 | 新增覆盖 |
|
||||
| --- | --- |
|
||||
| `api/src/channels/channels.service.spec.ts` | 通道复制、软删除、连接状态日志写入、链接日志查询。 |
|
||||
| `api/src/dictionaries/dictionaries.service.spec.ts` | 敏感词、全局黑名单、企业黑名单查询、创建、软删除和操作日志。 |
|
||||
|
||||
### 已执行命令
|
||||
|
||||
```bash
|
||||
npm --prefix api run build
|
||||
npm --prefix api test
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 当前结果
|
||||
|
||||
- API build 通过。
|
||||
- API Jest:8 个 test suite 通过,38 个测试通过。
|
||||
- 前端 build 通过,仍存在既有 Vite chunk size warning。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- 已将今天的客户端和运营端优化要求补入 `docs/first-version-development-requirements.md`:
|
||||
- 去除客户端独立账号设置菜单,改为头像下拉承载退出登录和修改密码。
|
||||
- 明确模板审核搜索、企业认证详情审核、企业应用 CMPP 连接数/连接详情/参数复制、通道复制、通道软删除、通道链接日志、安全控制 CRUD、系统日志分页等均需要真实后端 API 支撑。
|
||||
- 补充客户端用户、运营端企业认证、通道、连接、字典、安全控制、系统日志等接口范围。
|
||||
- 修正 Codex 执行模板,明确 mock、localStorage 或静态数据不得作为真实开发完成标准。
|
||||
- 已将今天的验收点补入 `docs/system-functional-test-cases.md`:
|
||||
- 新增 TC-CLIENT-010 到 TC-CLIENT-011。
|
||||
- 新增 TC-ADMIN-014 到 TC-ADMIN-022。
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
type RequestOptions = RequestInit & {
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (options.tenantId) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export type AdminChannel = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
carrier?: string | null;
|
||||
gatewayHost: string;
|
||||
gatewayPort: number;
|
||||
enterpriseCode?: string | null;
|
||||
account: string;
|
||||
srcId: string;
|
||||
rateLimitPerSecond: number;
|
||||
unitPrice: number;
|
||||
status: string;
|
||||
config?: unknown;
|
||||
};
|
||||
|
||||
export type ChannelLinkLogResponse = {
|
||||
channelId: string;
|
||||
connectionStates: Array<Record<string, unknown>>;
|
||||
logs: Array<{
|
||||
id: string;
|
||||
time: string;
|
||||
event: string;
|
||||
action: string;
|
||||
resourceId?: string;
|
||||
detail?: unknown;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type EnterpriseCertification = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
companyName: string;
|
||||
licenseNo?: string | null;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
materials?: Record<string, unknown> | null;
|
||||
status: string;
|
||||
rejectReason?: string | null;
|
||||
submittedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
tenant?: { id: string; name: string; code: string };
|
||||
};
|
||||
|
||||
export type SmsTemplateAudit = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string | null;
|
||||
auditStatus: string;
|
||||
rejectReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
application?: { name: string };
|
||||
tenant?: { name: string };
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
changeChannelStatus: (id: string, status: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status, reason }),
|
||||
}),
|
||||
deleteChannel: (id: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listChannelLinkLogs: (id: string) => request<ChannelLinkLogResponse>(`/admin/channels/${id}/link-logs`),
|
||||
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword) params.set('keyword', query.keyword);
|
||||
if (query.status && query.status !== 'all') params.set('status', query.status);
|
||||
const suffix = params.toString() ? `?${params}` : '';
|
||||
return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`);
|
||||
},
|
||||
approveTemplate: (id: string) => request<SmsTemplateAudit>(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
rejectTemplate: (id: string, reason = '运营审核驳回') => request<SmsTemplateAudit>(`/admin/templates/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword) params.set('keyword', query.keyword);
|
||||
if (query.status && query.status !== 'all') params.set('status', query.status);
|
||||
const suffix = params.toString() ? `?${params}` : '';
|
||||
return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`);
|
||||
},
|
||||
getEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}`),
|
||||
approveEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
};
|
||||
@@ -86,6 +86,27 @@ const channelNames: Record<string, string> = {
|
||||
'67': '联通-行政-上海甲医院-34',
|
||||
};
|
||||
|
||||
const channelCopyStorageKey = 'cmpp-channel-copies';
|
||||
|
||||
type ChannelCopyMeta = {
|
||||
sourceId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
function readChannelCopyMeta(): Record<string, ChannelCopyMeta> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(channelCopyStorageKey);
|
||||
return raw ? JSON.parse(raw) as Record<string, ChannelCopyMeta> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getChannelName(channelId: string) {
|
||||
const copyMeta = readChannelCopyMeta()[channelId];
|
||||
return copyMeta?.name ?? channelNames[channelId] ?? `短信通道 ${channelId}`;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '报备成功', value: 'success' },
|
||||
@@ -426,11 +447,12 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
|
||||
export function AdminChannelReportPage() {
|
||||
const navigate = useNavigate();
|
||||
const { channelId = '88827' } = useParams();
|
||||
const channelName = getChannelName(channelId);
|
||||
const [reports, setReports] = useState(initialReports);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['sig-1']));
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [statusTarget, setStatusTarget] = useState<{ signatureId: string; drainageId?: string } | null>(null);
|
||||
const [nextStatus, setNextStatus] = useState<ReportStatus>('success');
|
||||
@@ -493,10 +515,10 @@ export function AdminChannelReportPage() {
|
||||
return (
|
||||
<section className="page-stack channel-report-page">
|
||||
<div className="surface channel-report-hero">
|
||||
<Breadcrumb items={[channelNames[channelId] ?? `短信通道 ${channelId}`]} />
|
||||
<Breadcrumb items={[channelName]} />
|
||||
<div className="channel-report-heading">
|
||||
<Button icon={<ChevronLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">返回列表</Button>
|
||||
<h1>{channelNames[channelId] ?? `短信通道 ${channelId}`}</h1>
|
||||
<h1>{channelName}</h1>
|
||||
<Button icon={<ChevronRight size={16} />} variant="ghost">下一个</Button>
|
||||
<div className="channel-report-config-actions">
|
||||
<Button icon={<FileUp size={16} />} onClick={() => setReceiptOpen(true)} variant="secondary">导入回执</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelLinkLogResponse } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
@@ -31,6 +32,16 @@ type ChannelModalState = {
|
||||
channel?: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'delete' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
type ChannelLogState = {
|
||||
channel: SmsChannel;
|
||||
data?: ChannelLinkLogResponse;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
@@ -171,6 +182,39 @@ const initialChannels: SmsChannel[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function mapApiChannel(channel: AdminChannel): SmsChannel {
|
||||
const statusMap: Record<string, ChannelStatus> = {
|
||||
active: 'normal',
|
||||
disabled: 'stopped',
|
||||
deleted: 'stopped',
|
||||
connecting: 'connecting',
|
||||
failed: 'failed',
|
||||
};
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier === 'unicom' || channel.carrier === 'telecom' ? channel.carrier : 'mobile',
|
||||
unitPrice: channel.unitPrice,
|
||||
status: statusMap[channel.status] ?? 'normal',
|
||||
total: 0,
|
||||
successRate: 0,
|
||||
successCount: 0,
|
||||
unknownRate: 0,
|
||||
unknownCount: 0,
|
||||
failureRate: 0,
|
||||
failureCount: 0,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: String(channel.gatewayPort),
|
||||
corpCode: channel.enterpriseCode ?? channel.code,
|
||||
account: channel.account,
|
||||
accessNo: channel.srcId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUiStatusToApi(channel: SmsChannel) {
|
||||
return channel.status === 'stopped' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
@@ -362,6 +406,14 @@ export function AdminChannelsPage() {
|
||||
const [status, setStatus] = useState('all');
|
||||
const [modal, setModal] = useState<ChannelModalState | null>(null);
|
||||
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
|
||||
const [logState, setLogState] = useState<ChannelLogState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listChannels()
|
||||
.then((items) => setChannels(items.filter((item) => item.status !== 'deleted').map(mapApiChannel)))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const filteredChannels = useMemo(
|
||||
() => channels.filter((channel) => {
|
||||
@@ -381,16 +433,63 @@ export function AdminChannelsPage() {
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
setChannels((items) => items.map((item) => (
|
||||
item.id === id ? { ...item, status: item.status === 'stopped' ? 'connecting' : 'stopped' } : item
|
||||
)));
|
||||
async function toggleChannel(channel: SmsChannel) {
|
||||
const updated = await adminApi.changeChannelStatus(channel.id, mapUiStatusToApi(channel));
|
||||
setChannels((items) => items.map((item) => (item.id === channel.id ? mapApiChannel(updated) : item)));
|
||||
}
|
||||
|
||||
function deleteChannel(id: string) {
|
||||
async function deleteChannel(id: string) {
|
||||
await adminApi.deleteChannel(id, '运营端删除通道');
|
||||
setChannels((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
const copied = await adminApi.copyChannel(channel.id);
|
||||
setChannels((items) => [mapApiChannel(copied), ...items]);
|
||||
}
|
||||
|
||||
async function openLinkLogs(channel: SmsChannel) {
|
||||
setLogState({ channel });
|
||||
const data = await adminApi.listChannelLinkLogs(channel.id);
|
||||
setLogState({ channel, data });
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'toggle') {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'delete') {
|
||||
void deleteChannel(confirmAction.channel.id);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'delete'
|
||||
? '确认删除通道'
|
||||
: confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'delete'
|
||||
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
|
||||
: confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入链接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
<div className="page-heading">
|
||||
@@ -429,7 +528,12 @@ export function AdminChannelsPage() {
|
||||
<Tag tone={carrierToneMap[channel.carrier]}>{carrierLabelMap[channel.carrier]}</Tag>
|
||||
<strong>{channel.unitPrice.toFixed(1)} 分</strong>
|
||||
</div>
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<div className="sms-channel-status-cell">
|
||||
<Tag tone={statusToneMap[channel.status]}>{statusLabelMap[channel.status]}</Tag>
|
||||
<button onClick={() => void openLinkLogs(channel)} type="button">
|
||||
<FileText size={14} />链接日志
|
||||
</button>
|
||||
</div>
|
||||
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
|
||||
<div className="sms-channel-quality">
|
||||
<RateBlock count={channel.successCount} label="成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
|
||||
@@ -438,14 +542,15 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
<div className="sms-channel-actions">
|
||||
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
|
||||
<Eye size={15} />通道报备详情
|
||||
<Eye size={15} />报备详情
|
||||
</button>
|
||||
<button onClick={() => setModal({ mode: 'edit', channel })} type="button"><Pencil size={15} />编辑</button>
|
||||
<button onClick={() => setConfirmAction({ type: 'copy', channel })} type="button"><Copy size={15} />复制通道</button>
|
||||
<button onClick={() => setTestChannel(channel)} type="button"><Send size={15} />发送测试</button>
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => toggleChannel(channel.id)} type="button">
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => deleteChannel(channel.id)} type="button"><Trash2 size={15} />删除</button>
|
||||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -472,6 +577,53 @@ export function AdminChannelsPage() {
|
||||
onClose={() => setTestChannel(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
open
|
||||
title={confirmTitle}
|
||||
>
|
||||
<div className="channel-confirm">
|
||||
<strong>{confirmAction.channel.name}</strong>
|
||||
<span>通道 ID:{confirmAction.channel.id}</span>
|
||||
<p>{confirmDescription}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{logState ? (
|
||||
<Modal
|
||||
footer={<Button onClick={() => setLogState(null)} variant="ghost">关闭</Button>}
|
||||
onClose={() => setLogState(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>链接日志</h2><p>{logState.channel.name}</p></div>}
|
||||
>
|
||||
<div className="channel-log-list">
|
||||
{(logState.data?.logs ?? []).map((log) => (
|
||||
<article className="channel-log-item" key={log.id}>
|
||||
<div>
|
||||
<strong>{log.event}</strong>
|
||||
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{log.resourceId}</span>
|
||||
<p>{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{logState.data && logState.data.logs.length === 0 ? <p className="muted">暂无链接日志</p> : null}
|
||||
{!logState.data ? <p className="muted">正在加载链接日志...</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,13 +90,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
onClick={() => navigate(`${basePath}/${record.id}`)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(`${basePath}/${record.id}/edit`)}
|
||||
size="sm"
|
||||
|
||||
@@ -148,11 +148,11 @@ export function AdminDrainageFieldsPage() {
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="admin-drainage-actions">
|
||||
<Button icon={<Pencil size={17} />} iconOnly onClick={() => setEditingField(record)} variant="ghost">编辑</Button>
|
||||
<Button icon={<Pencil size={17} />} onClick={() => setEditingField(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={17} />}
|
||||
iconOnly
|
||||
onClick={() => setFields((current) => current.filter((item) => item.id !== record.id))}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
@@ -166,8 +166,8 @@ export function AdminDrainageFieldsPage() {
|
||||
<section className="page-stack admin-system-page admin-drainage-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['基础配置', '引流信息报备字段库']} />
|
||||
<h1>引流信息报备字段库</h1>
|
||||
<Breadcrumb items={['基础配置', '报备字段库']} />
|
||||
<h1>报备字段库</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
@@ -13,18 +13,74 @@ type SmsApp = {
|
||||
deliveryRate: number;
|
||||
unitPrice: number;
|
||||
cmppStatus: 'connected' | 'disconnected' | 'inactive';
|
||||
cmppConnections: CmppConnection[];
|
||||
cmppParams: CmppParams;
|
||||
};
|
||||
|
||||
type MmsApp = Omit<SmsApp, 'cmppStatus'> & {
|
||||
type CmppParams = {
|
||||
host: string;
|
||||
port: number;
|
||||
enterpriseCode: string;
|
||||
account: string;
|
||||
password: string;
|
||||
accessNumber: string;
|
||||
maxConnections: number;
|
||||
heartbeatSeconds: number;
|
||||
windowSize: number;
|
||||
protocolVersion: string;
|
||||
};
|
||||
|
||||
type CmppConnection = {
|
||||
id: string;
|
||||
state: 'open' | 'closed' | 'reconnecting';
|
||||
bindType: 'transceiver' | 'submitter' | 'receiver';
|
||||
clientIp: string;
|
||||
sourceAddr: string;
|
||||
establishedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastSubmitAt: string;
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
|
||||
pointPrice: number;
|
||||
};
|
||||
|
||||
type AppKind = 'sms' | 'mms';
|
||||
|
||||
const initialSmsApps: SmsApp[] = [
|
||||
{ id: 'app-1', name: '营销推广平台', enterprise: '上海XXXXX科技有限公司', appId: 'AK_2024010912345678', enabled: true, sentToday: 1500, deliveryRate: 95, unitPrice: 0.05, cmppStatus: 'connected' },
|
||||
{ id: 'app-2', name: '客户服务系统', enterprise: '重庆进载数智', appId: 'AK_2024010987654321', enabled: true, sentToday: 800, deliveryRate: 90, unitPrice: 0.06, cmppStatus: 'disconnected' },
|
||||
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive' },
|
||||
{
|
||||
id: 'app-1',
|
||||
name: '营销推广平台',
|
||||
enterprise: '上海XXXXX科技有限公司',
|
||||
appId: 'AK_2024010912345678',
|
||||
enabled: true,
|
||||
sentToday: 1500,
|
||||
deliveryRate: 95,
|
||||
unitPrice: 0.05,
|
||||
cmppStatus: 'connected',
|
||||
cmppParams: { host: '127.0.0.1', port: 7890, enterpriseCode: '900123', account: 'AC900123', password: 'PW-9x8k2m', accessNumber: '106900123', maxConnections: 2, heartbeatSeconds: 30, windowSize: 32, protocolVersion: 'CMPP 2.0' },
|
||||
cmppConnections: [
|
||||
{ id: 'CMPP-001-A', state: 'open', bindType: 'transceiver', clientIp: '10.24.8.12:32516', sourceAddr: '900123', establishedAt: '2026-07-02 08:42:11', lastHeartbeatAt: '2026-07-02 10:18:32', lastSubmitAt: '2026-07-02 10:17:58', pendingWindow: 18 },
|
||||
{ id: 'CMPP-001-B', state: 'open', bindType: 'submitter', clientIp: '10.24.8.13:32520', sourceAddr: '900123', establishedAt: '2026-07-02 08:43:02', lastHeartbeatAt: '2026-07-02 10:18:28', lastSubmitAt: '2026-07-02 10:18:06', pendingWindow: 11 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
name: '客户服务系统',
|
||||
enterprise: '重庆进载数智',
|
||||
appId: 'AK_2024010987654321',
|
||||
enabled: true,
|
||||
sentToday: 800,
|
||||
deliveryRate: 90,
|
||||
unitPrice: 0.06,
|
||||
cmppStatus: 'disconnected',
|
||||
cmppParams: { host: '127.0.0.1', port: 7891, enterpriseCode: '901778', account: 'AC901778', password: 'PW-4n7q1a', accessNumber: '106901778', maxConnections: 1, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' },
|
||||
cmppConnections: [
|
||||
{ id: 'CMPP-002-A', state: 'closed', bindType: 'transceiver', clientIp: '10.24.9.21:31888', sourceAddr: '901778', establishedAt: '2026-07-02 07:55:19', lastHeartbeatAt: '2026-07-02 09:21:44', lastSubmitAt: '2026-07-02 09:20:17', pendingWindow: 0 },
|
||||
],
|
||||
},
|
||||
{ id: 'app-3', name: '验证码服务', enterprise: '超感世纪互三网', appId: 'AK_2024010811223344', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.04, cmppStatus: 'inactive', cmppParams: { host: '127.0.0.1', port: 7892, enterpriseCode: '902456', account: 'AC902456', password: 'PW-2d6f8p', accessNumber: '106902456', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 2.0' }, cmppConnections: [] },
|
||||
];
|
||||
|
||||
const initialMmsApps: MmsApp[] = [
|
||||
@@ -55,11 +111,136 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
|
||||
);
|
||||
}
|
||||
|
||||
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
|
||||
open: { label: '已连接', tone: 'success' },
|
||||
closed: { label: '已断开', tone: 'neutral' },
|
||||
reconnecting: { label: '重连中', tone: 'warning' },
|
||||
};
|
||||
|
||||
function formatCmppParams(app: SmsApp) {
|
||||
const { cmppParams } = app;
|
||||
return [
|
||||
`应用名称: ${app.name}`,
|
||||
`企业名称: ${app.enterprise}`,
|
||||
`AppID: ${app.appId}`,
|
||||
`CMPP网关地址: ${cmppParams.host}`,
|
||||
`CMPP网关端口: ${cmppParams.port}`,
|
||||
`企业代码: ${cmppParams.enterpriseCode}`,
|
||||
`接口账号: ${cmppParams.account}`,
|
||||
`接口密码: ${cmppParams.password}`,
|
||||
`接入号: ${cmppParams.accessNumber}`,
|
||||
`最大连接数: ${cmppParams.maxConnections}`,
|
||||
`心跳间隔: ${cmppParams.heartbeatSeconds}秒`,
|
||||
`提交窗口: ${cmppParams.windowSize}`,
|
||||
`协议版本: ${cmppParams.protocolVersion}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function CmppParamsModal({ app, onClose }: { app: SmsApp; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const paramsText = formatCmppParams(app);
|
||||
|
||||
async function copyParams() {
|
||||
await navigator.clipboard.writeText(paramsText);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">关闭</Button>
|
||||
<Button icon={<Copy size={15} />} onClick={copyParams}>{copied ? '已复制' : '一键复制'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接参数</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-param-detail">
|
||||
<div className="cmpp-param-grid">
|
||||
<div><span>CMPP网关地址</span><strong>{app.cmppParams.host}</strong></div>
|
||||
<div><span>CMPP网关端口</span><strong>{app.cmppParams.port}</strong></div>
|
||||
<div><span>企业代码</span><strong>{app.cmppParams.enterpriseCode}</strong></div>
|
||||
<div><span>接口账号</span><strong>{app.cmppParams.account}</strong></div>
|
||||
<div><span>接口密码</span><strong>{app.cmppParams.password}</strong></div>
|
||||
<div><span>接入号</span><strong>{app.cmppParams.accessNumber}</strong></div>
|
||||
<div><span>最大连接数</span><strong>{app.cmppParams.maxConnections}</strong></div>
|
||||
<div><span>心跳间隔</span><strong>{app.cmppParams.heartbeatSeconds} 秒</strong></div>
|
||||
<div><span>提交窗口</span><strong>{app.cmppParams.windowSize}</strong></div>
|
||||
<div><span>协议版本</span><strong>{app.cmppParams.protocolVersion}</strong></div>
|
||||
</div>
|
||||
<pre className="cmpp-param-copy">{paramsText}</pre>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function CmppConnectionModal({
|
||||
app,
|
||||
onClose,
|
||||
onDeleteConnection,
|
||||
}: {
|
||||
app: SmsApp;
|
||||
onClose: () => void;
|
||||
onDeleteConnection: (connectionId: string) => void;
|
||||
}) {
|
||||
const activeConnections = app.cmppConnections.filter((item) => item.state === 'open').length;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>CMPP连接详情</h2><p>{app.enterprise} / {app.name}</p></div>}
|
||||
>
|
||||
<div className="cmpp-connection-detail">
|
||||
<div className="cmpp-connection-summary">
|
||||
<div><span>当前连接数</span><strong>{activeConnections}</strong></div>
|
||||
<div><span>配置连接数</span><strong>{Math.max(activeConnections, app.cmppConnections.length)}</strong></div>
|
||||
<div><span>AppID</span><strong>{app.appId}</strong></div>
|
||||
<div><span>连接状态</span><Tag tone={app.cmppStatus === 'connected' ? 'success' : app.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}</Tag></div>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
|
||||
{ key: 'state', title: '状态', width: '100px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||||
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
|
||||
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
|
||||
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
|
||||
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
|
||||
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
|
||||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '100px', render: (record: CmppConnection) => record.pendingWindow },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
render: (record: CmppConnection) => (
|
||||
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger">删除</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={app.cmppConnections}
|
||||
emptyText="暂无CMPP连接"
|
||||
rowKey="id"
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [smsApps, setSmsApps] = useState(initialSmsApps);
|
||||
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
||||
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
||||
@@ -95,6 +276,26 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
function deleteConnection(appId: string, connectionId: string) {
|
||||
let nextConnectionApp: SmsApp | null = null;
|
||||
setSmsApps((current) => current.map((app) => {
|
||||
if (app.id !== appId) {
|
||||
return app;
|
||||
}
|
||||
|
||||
const nextConnections = app.cmppConnections.filter((connection) => connection.id !== connectionId);
|
||||
const nextOpenCount = nextConnections.filter((connection) => connection.state === 'open').length;
|
||||
const nextApp: SmsApp = {
|
||||
...app,
|
||||
cmppConnections: nextConnections,
|
||||
cmppStatus: nextOpenCount > 0 ? 'connected' : app.enabled ? 'disconnected' : 'inactive',
|
||||
};
|
||||
nextConnectionApp = nextApp;
|
||||
return nextApp;
|
||||
}));
|
||||
setConnectionApp(nextConnectionApp);
|
||||
}
|
||||
|
||||
const filteredSmsApps = useMemo(
|
||||
() => smsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
|
||||
[enterpriseKeyword, smsApps],
|
||||
@@ -115,11 +316,20 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: 'CMPP状态',
|
||||
width: '130px',
|
||||
width: '230px',
|
||||
render: (record) => (
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<div className="cmpp-status-cell">
|
||||
<Tag tone={record.cmppStatus === 'connected' ? 'success' : record.cmppStatus === 'disconnected' ? 'danger' : 'neutral'}>
|
||||
{record.cmppStatus === 'connected' ? '已连接' : record.cmppStatus === 'disconnected' ? '已断开' : '未开通'}
|
||||
</Tag>
|
||||
<button onClick={() => setConnectionApp(record)} type="button">
|
||||
{record.cmppConnections.filter((item) => item.state === 'open').length}
|
||||
</button>
|
||||
<button className="cmpp-status-cell__params" onClick={() => setParamsApp(record)} type="button">
|
||||
<Settings2 size={13} />
|
||||
参数
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||
@@ -206,6 +416,14 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
onConfirm={runConfirmedAction}
|
||||
/>
|
||||
) : null}
|
||||
{connectionApp ? (
|
||||
<CmppConnectionModal
|
||||
app={connectionApp}
|
||||
onClose={() => setConnectionApp(null)}
|
||||
onDeleteConnection={(connectionId) => deleteConnection(connectionApp.id, connectionId)}
|
||||
/>
|
||||
) : null}
|
||||
{paramsApp ? <CmppParamsModal app={paramsApp} onClose={() => setParamsApp(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
@@ -8,9 +9,18 @@ type EnterpriseAuditRecord = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
creditCode: string;
|
||||
legalPerson: string;
|
||||
registeredAddress: string;
|
||||
businessLicense: string;
|
||||
bankAccountName: string;
|
||||
bankName: string;
|
||||
bankAccountNo: string;
|
||||
verificationAmount: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
submittedAt: string;
|
||||
reviewRemark: string;
|
||||
status: EnterpriseAuditStatus;
|
||||
};
|
||||
|
||||
@@ -34,16 +44,45 @@ const statusToneMap: Record<EnterpriseAuditStatus, 'warning' | 'success' | 'dang
|
||||
};
|
||||
|
||||
const initialEnterpriseAudits: EnterpriseAuditRecord[] = [
|
||||
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', contactName: '张伟', contactPhone: '13800138000', submittedAt: '2026-03-19 10:23:45', status: 'pending' },
|
||||
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', contactName: '李娜', contactPhone: '13900139000', submittedAt: '2026-03-18 15:45:12', status: 'pending' },
|
||||
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', contactName: '王强', contactPhone: '13700137000', submittedAt: '2026-03-17 09:12:30', status: 'approved' },
|
||||
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', contactName: '陈杰', contactPhone: '13600136000', submittedAt: '2026-03-16 11:30:22', status: 'rejected' },
|
||||
{ id: 'ENT-20260319-001', companyName: '北京星云科技有限公司', creditCode: '91110000X12345678A', legalPerson: '赵明', registeredAddress: '北京市朝阳区望京东路 88 号', businessLicense: 'business-license-20260319-001.pdf', bankAccountName: '北京星云科技有限公司', bankName: '招商银行北京望京支行', bankAccountNo: '6214 **** **** 1028', verificationAmount: '0.23 元', contactName: '张伟', contactPhone: '13800138000', contactEmail: 'zhangwei@nebula.example.com', submittedAt: '2026-03-19 10:23:45', reviewRemark: '待核验营业执照与对公打款流水。', status: 'pending' },
|
||||
{ id: 'ENT-20260319-002', companyName: '上海蓝海科技有限公司', creditCode: '91310000X87654321B', legalPerson: '周海', registeredAddress: '上海市浦东新区张江路 66 号', businessLicense: 'business-license-20260319-002.pdf', bankAccountName: '上海蓝海科技有限公司', bankName: '建设银行上海张江支行', bankAccountNo: '6227 **** **** 3319', verificationAmount: '0.18 元', contactName: '李娜', contactPhone: '13900139000', contactEmail: 'lina@blueocean.example.com', submittedAt: '2026-03-18 15:45:12', reviewRemark: '联系人授权书已上传,等待人工复核。', status: 'pending' },
|
||||
{ id: 'ENT-20260318-001', companyName: '广州飞跃文化传媒有限公司', creditCode: '91440100X11223344C', legalPerson: '黄杰', registeredAddress: '广州市天河区体育西路 118 号', businessLicense: 'business-license-20260318-001.pdf', bankAccountName: '广州飞跃文化传媒有限公司', bankName: '工商银行广州天河支行', bankAccountNo: '6202 **** **** 7750', verificationAmount: '0.31 元', contactName: '王强', contactPhone: '13700137000', contactEmail: 'wangqiang@feiyue.example.com', submittedAt: '2026-03-17 09:12:30', reviewRemark: '资料一致,对公验证通过。', status: 'approved' },
|
||||
{ id: 'ENT-20260317-001', companyName: '深圳前海贸易有限公司', creditCode: '91440300X55667788D', legalPerson: '林越', registeredAddress: '深圳市前海深港合作区梦海大道 1 号', businessLicense: 'business-license-20260317-001.pdf', bankAccountName: '深圳前海贸易有限公司', bankName: '中国银行深圳前海支行', bankAccountNo: '6216 **** **** 8901', verificationAmount: '0.12 元', contactName: '陈杰', contactPhone: '13600136000', contactEmail: 'chenjie@qianhai.example.com', submittedAt: '2026-03-16 11:30:22', reviewRemark: '营业执照主体与对公账户户名不一致,请重新提交。', status: 'rejected' },
|
||||
];
|
||||
|
||||
function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecord {
|
||||
const materials = record.materials ?? {};
|
||||
return {
|
||||
id: record.id,
|
||||
companyName: record.companyName,
|
||||
creditCode: record.licenseNo ?? '',
|
||||
legalPerson: String(materials.legalPerson ?? ''),
|
||||
registeredAddress: String(materials.registeredAddress ?? ''),
|
||||
businessLicense: String(materials.businessLicense ?? ''),
|
||||
bankAccountName: String(materials.bankAccountName ?? record.companyName),
|
||||
bankName: String(materials.bankName ?? ''),
|
||||
bankAccountNo: String(materials.bankAccountNo ?? ''),
|
||||
verificationAmount: String(materials.verificationAmount ?? ''),
|
||||
contactName: record.contactName ?? '',
|
||||
contactPhone: record.contactPhone ?? '',
|
||||
contactEmail: String(materials.contactEmail ?? ''),
|
||||
submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }),
|
||||
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
|
||||
status: record.status as EnterpriseAuditStatus,
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminEnterpriseAuditPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [records, setRecords] = useState(initialEnterpriseAudits);
|
||||
const [detailRecord, setDetailRecord] = useState<EnterpriseAuditRecord | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listEnterpriseCertifications({ keyword, status })
|
||||
.then((items) => setRecords(items.map(mapCertification)))
|
||||
.catch(() => undefined);
|
||||
}, [keyword, status]);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
@@ -54,8 +93,13 @@ export function AdminEnterpriseAuditPage() {
|
||||
[keyword, records, status],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item)));
|
||||
async function updateStatus(id: string, nextStatus: EnterpriseAuditStatus) {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveEnterpriseCertification(id)
|
||||
: await adminApi.rejectEnterpriseCertification(id);
|
||||
const mapped = mapCertification(updated);
|
||||
setRecords((items) => items.map((item) => (item.id === id ? mapped : item)));
|
||||
setDetailRecord((current) => (current?.id === id ? mapped : current));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<EnterpriseAuditRecord>> = [
|
||||
@@ -78,11 +122,11 @@ export function AdminEnterpriseAuditPage() {
|
||||
<div className="audit-actions">
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<button className="audit-link audit-link--success" onClick={() => updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
<button className="audit-link audit-link--success" onClick={() => void updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => void updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
<button className="audit-link" type="button">详情</button>
|
||||
<button className="audit-link" onClick={() => setDetailRecord(record)} type="button">详情</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -110,6 +154,53 @@ export function AdminEnterpriseAuditPage() {
|
||||
<Button disabled icon={<X size={15} />} size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailRecord ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setDetailRecord(null)} variant="ghost">关闭</Button>
|
||||
{detailRecord.status === 'pending' ? (
|
||||
<>
|
||||
<Button onClick={() => void updateStatus(detailRecord.id, 'rejected')} variant="danger">驳回认证</Button>
|
||||
<Button onClick={() => void updateStatus(detailRecord.id, 'approved')}>审核通过</Button>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
onClose={() => setDetailRecord(null)}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>企业认证详情</h2><p>{detailRecord.id}</p></div>}
|
||||
>
|
||||
<div className="enterprise-audit-detail">
|
||||
<section>
|
||||
<h3>主体资料</h3>
|
||||
<div><span>企业名称</span><strong>{detailRecord.companyName}</strong></div>
|
||||
<div><span>统一社会信用代码</span><strong>{detailRecord.creditCode}</strong></div>
|
||||
<div><span>法定代表人</span><strong>{detailRecord.legalPerson}</strong></div>
|
||||
<div><span>注册地址</span><strong>{detailRecord.registeredAddress}</strong></div>
|
||||
<div><span>营业执照附件</span><strong>{detailRecord.businessLicense}</strong></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>对公验证</h3>
|
||||
<div><span>账户户名</span><strong>{detailRecord.bankAccountName}</strong></div>
|
||||
<div><span>开户银行</span><strong>{detailRecord.bankName}</strong></div>
|
||||
<div><span>银行账号</span><strong>{detailRecord.bankAccountNo}</strong></div>
|
||||
<div><span>验证金额</span><strong>{detailRecord.verificationAmount}</strong></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>联系人与审核</h3>
|
||||
<div><span>联系人</span><strong>{detailRecord.contactName}</strong></div>
|
||||
<div><span>联系电话</span><strong>{detailRecord.contactPhone}</strong></div>
|
||||
<div><span>联系邮箱</span><strong>{detailRecord.contactEmail}</strong></div>
|
||||
<div><span>提交时间</span><strong>{detailRecord.submittedAt}</strong></div>
|
||||
<div><span>当前状态</span><strong>{statusTextMap[detailRecord.status]}</strong></div>
|
||||
<div className="enterprise-audit-detail__remark"><span>审核备注</span><strong>{detailRecord.reviewRemark}</strong></div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseBlacklistItem = {
|
||||
id: string;
|
||||
@@ -19,8 +19,24 @@ const initialItems: EnterpriseBlacklistItem[] = [
|
||||
{ id: 'EBL20260630004', enterprise: '重庆香惠慧', application: '客服应用', phone: '18800000555', createdAt: '2026-06-24 18:01:10', reason: '敏感投诉号码', expiredAt: '2026-07-24 23:59:59' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminEnterpriseBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('');
|
||||
const [application, setApplication] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiredAt, setExpiredAt] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.enterprise, item.application, item.phone, item.reason].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.enterprise}</strong> },
|
||||
@@ -42,6 +58,29 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setEnterprise('');
|
||||
setApplication('');
|
||||
setPhone('');
|
||||
setReason('');
|
||||
setExpiredAt('');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextItem: EnterpriseBlacklistItem = {
|
||||
id: `EBL${Date.now()}`,
|
||||
enterprise: enterprise || '未命名企业',
|
||||
application: application || '默认应用',
|
||||
phone: phone || '待补充号码',
|
||||
createdAt: nowText(),
|
||||
reason: reason || '运营手动加入',
|
||||
expiredAt: expiredAt || '永久有效',
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -49,11 +88,46 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<Breadcrumb items={['安全控制', '企业黑名单']} />
|
||||
<h1>企业黑名单</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加黑名单</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、应用、手机号或原因"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无企业黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加企业黑名单"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="企业名称" onChange={(event) => setEnterprise(event.target.value)} placeholder="请输入企业名称" value={enterprise} />
|
||||
<Input label="应用名称" onChange={(event) => setApplication(event.target.value)} placeholder="请输入应用名称" value={application} />
|
||||
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
|
||||
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
const [activeTab, setActiveTab] = useState<SignatureKind>('sms');
|
||||
const [smsSignatures, setSmsSignatures] = useState(initialSmsSignatures);
|
||||
const [mmsSignatures, setMmsSignatures] = useState(initialMmsSignatures);
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('sig-1');
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('');
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -491,7 +491,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ kind: 'sms', item: signature })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="ghost">删除</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', signatureKind: 'sms', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -518,9 +518,9 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<StatusTag status={item.telecom} />
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<button onClick={() => setDrainageReport(item)} type="button">报备详情</button>
|
||||
<button onClick={() => setDrainageModal({ signatureId: signature.id, item })} type="button">编辑</button>
|
||||
<button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} type="button">删除</button>
|
||||
<Button onClick={() => setDrainageReport(item)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type GlobalBlacklistItem = {
|
||||
id: string;
|
||||
@@ -17,8 +17,22 @@ const initialItems: GlobalBlacklistItem[] = [
|
||||
{ id: 'GBL20260630004', phone: '15250668026', createdAt: '2026-06-25 14:12:18', reason: '高频退订', expiredAt: '2026-08-25 23:59:59' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminGlobalBlacklistPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [expiredAt, setExpiredAt] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.phone, item.reason, item.expiredAt].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<GlobalBlacklistItem>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
@@ -38,6 +52,25 @@ export function AdminGlobalBlacklistPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setPhone('');
|
||||
setReason('');
|
||||
setExpiredAt('');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const nextItem: GlobalBlacklistItem = {
|
||||
id: `GBL${Date.now()}`,
|
||||
phone: phone || '待补充号码',
|
||||
createdAt: nowText(),
|
||||
reason: reason || '运营手动加入',
|
||||
expiredAt: expiredAt || '永久有效',
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -45,11 +78,44 @@ export function AdminGlobalBlacklistPage() {
|
||||
<Breadcrumb items={['安全控制', '全局黑名单']} />
|
||||
<h1>全局黑名单</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加黑名单</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索手机号、原因或过期时间"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无全局黑名单记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无全局黑名单记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加全局黑名单"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
|
||||
<Input label="过期时间" onChange={(event) => setExpiredAt(event.target.value)} placeholder="例如 2026-12-31 23:59:59" value={expiredAt} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,14 @@ function formatAmount(value?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState(rechargeRecordsSeed);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
@@ -116,13 +124,13 @@ export function AdminRechargeRecordsPage() {
|
||||
<table className="ui-table admin-recharge-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>企业名称</th>
|
||||
<th>充值时间</th>
|
||||
<th>充值金额</th>
|
||||
<th>充值后余额</th>
|
||||
<th>充值类型</th>
|
||||
<th>操作人</th>
|
||||
<th>备注</th>
|
||||
<th style={{ width: '240px' }}>企业名称</th>
|
||||
<th style={{ width: '180px' }}>充值时间</th>
|
||||
<th style={{ width: '130px' }}>充值金额</th>
|
||||
<th style={{ width: '140px' }}>充值后余额</th>
|
||||
<th style={{ width: '120px' }}>充值类型</th>
|
||||
<th style={{ width: '110px' }}>操作人</th>
|
||||
<th style={{ width: '300px' }}>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -134,7 +142,7 @@ export function AdminRechargeRecordsPage() {
|
||||
<td>{formatAmount(record.balance)}</td>
|
||||
<td><Tag tone={record.type === 'manual' ? 'warning' : 'info'}>{record.type === 'manual' ? '人工充值' : '套餐充值'}</Tag></td>
|
||||
<td>{record.operator}</td>
|
||||
<td>{record.remark ?? '-'}</td>
|
||||
<td><RemarkCell value={record.remark} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -52,6 +52,14 @@ const initialRecords: ReportRecord[] = [
|
||||
{ id: 'RPT-REC-004', taskId: 'RPT-TASK-20260630-001', channel: '行北-集市三甲医院-39', enterprise: '上海XXXXX科技有限公司', type: '签名', content: '【科技公司】', carrier: '移动', status: 'unreported', submittedAt: '2026-06-30 09:12:00', updatedAt: '2026-06-30 09:12:00' },
|
||||
];
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备记录详情</h2><p>{record.id}</p></div>}>
|
||||
@@ -102,8 +110,8 @@ export function AdminReportRecordsPage() {
|
||||
{ key: 'carrier', title: '运营商', width: '90px', render: (record) => <Tag tone="info">{record.carrier}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusMeta[record.status].tone}>{statusMeta[record.status].label}</Tag> },
|
||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <div className="report-task-time"><span>提交 {record.submittedAt}</span><span>回执 {record.reportedAt ?? '-'}</span></div> },
|
||||
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '110px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
{ key: 'reason', title: '备注', width: '280px', render: (record) => <RemarkCell value={record.reason} /> },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button> },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,6 +93,14 @@ const initialTasks: ReportTask[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function RemarkCell({ value }: { value?: string }) {
|
||||
return (
|
||||
<div className={['admin-remark-cell', value ? '' : 'admin-remark-cell--empty'].filter(Boolean).join(' ')}>
|
||||
{value || '暂无备注'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: ReportTaskStatus }) {
|
||||
return <Tag tone={statusMeta[status].tone}>{statusMeta[status].label}</Tag>;
|
||||
}
|
||||
@@ -252,11 +260,12 @@ export function AdminReportTasksPage() {
|
||||
{ key: 'counts', title: '资料数量', width: '190px', render: (record) => <div className="admin-task-counts"><span>签名 {record.signatureCount}</span><span>引流 {record.drainageCount}</span><strong>缺 {record.missingCount}</strong></div> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{ key: 'time', title: '流转时间', width: '210px', render: (record) => <div className="report-task-time"><span>创建 {record.createdAt}</span><span>导出 {record.exportedAt ?? '-'}</span><span>回执 {record.receiptAt ?? '-'}</span></div> },
|
||||
{ key: 'remark', title: '备注', width: '300px', render: (record) => <RemarkCell value={record.remark} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '260px',
|
||||
width: '280px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type SensitiveLevel = 'low' | 'medium' | 'high';
|
||||
|
||||
@@ -32,8 +32,28 @@ const initialItems: SensitiveWordItem[] = [
|
||||
{ id: 'SW20260630004', word: '免费领取', category: '普通营销', level: 'low', createdAt: '2026-06-17 17:06:51', updatedAt: '2026-06-21 09:05:14' },
|
||||
];
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
];
|
||||
|
||||
function nowText() {
|
||||
return new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
export function AdminSensitiveWordsPage() {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [word, setWord] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [level, setLevel] = useState<SensitiveLevel>('medium');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const filteredItems = useMemo(() => items.filter((item) => {
|
||||
const text = [item.word, item.category, levelLabelMap[item.level]].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, keyword]);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
|
||||
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
|
||||
@@ -54,6 +74,27 @@ export function AdminSensitiveWordsPage() {
|
||||
},
|
||||
], []);
|
||||
|
||||
function resetForm() {
|
||||
setWord('');
|
||||
setCategory('');
|
||||
setLevel('medium');
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const time = nowText();
|
||||
const nextItem: SensitiveWordItem = {
|
||||
id: `SW${Date.now()}`,
|
||||
word: word || '待补充敏感词',
|
||||
category: category || '未分类',
|
||||
level,
|
||||
createdAt: time,
|
||||
updatedAt: time,
|
||||
};
|
||||
setItems((current) => [nextItem, ...current]);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-security-page">
|
||||
<div className="page-heading">
|
||||
@@ -61,11 +102,49 @@ export function AdminSensitiveWordsPage() {
|
||||
<Breadcrumb items={['安全控制', '敏感词管理']} />
|
||||
<h1>敏感词管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>添加敏感词</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-filter">
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索敏感词、分类或级别"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<div className="admin-security-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-security-table-card">
|
||||
<Table columns={columns} data={items} emptyText="暂无敏感词记录" rowKey="id" />
|
||||
<Table columns={columns} data={filteredItems} emptyText="暂无敏感词记录" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button onClick={addItem}>确认添加</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
title="添加敏感词"
|
||||
>
|
||||
<div className="admin-security-form">
|
||||
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
|
||||
<Input label="分类" onChange={(event) => setCategory(event.target.value)} placeholder="请输入分类" value={category} />
|
||||
<Select
|
||||
label="风险级别"
|
||||
onChange={(event) => setLevel(event.target.value as SensitiveLevel)}
|
||||
options={levelOptions}
|
||||
value={level}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
function goBack() {
|
||||
navigate(`/admin/customers/${enterpriseId ?? ''}`);
|
||||
navigate('/admin/enterprise-applications');
|
||||
}
|
||||
|
||||
function submit() {
|
||||
@@ -81,7 +81,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
<p>{enterprise?.name ?? '当前企业'} 的短信应用配置。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业详情
|
||||
返回企业应用管理
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -408,6 +408,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
|
||||
@@ -443,6 +444,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
setTasks((current) => current.map((task) => (
|
||||
task.id === taskId ? { ...task, status: 'terminated' } : task
|
||||
)));
|
||||
setTerminateTarget(null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -485,7 +487,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '100px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '120px' }}>操作</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -542,12 +544,11 @@ export function AdminSmsTaskProgressPage() {
|
||||
<td><Tag tone={statusTones[record.status]}>{statusLabels[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="admin-task-actions">
|
||||
<Button icon={<Eye size={15} />} iconOnly onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'sending'}
|
||||
icon={<StopCircle size={15} />}
|
||||
iconOnly
|
||||
onClick={() => terminateTask(record.id)}
|
||||
onClick={() => setTerminateTarget(record)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -577,6 +578,23 @@ export function AdminSmsTaskProgressPage() {
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
{terminateTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setTerminateTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => terminateTask(terminateTarget.id)} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setTerminateTarget(null)}
|
||||
open
|
||||
title="确认终止短信任务"
|
||||
>
|
||||
<div className="admin-confirm-text">
|
||||
确认终止任务 <strong>{terminateTarget.id}</strong> 吗?终止后将停止继续提交未发送号码,已提交部分仍以运营商回执为准。
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
@@ -44,6 +44,8 @@ export function AdminSystemLogsPage() {
|
||||
const [level, setLevel] = useState('all');
|
||||
const [module, setModule] = useState('all');
|
||||
const [range, setRange] = useState('today');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 5;
|
||||
|
||||
const moduleOptions = useMemo(() => {
|
||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
||||
@@ -57,6 +59,9 @@ export function AdminSystemLogsPage() {
|
||||
const matchesModule = module === 'all' || item.module === module;
|
||||
return matchesKeyword && matchesLevel && matchesModule;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<AdminSystemLog>>>(() => [
|
||||
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{record.time}</span> },
|
||||
@@ -66,7 +71,18 @@ export function AdminSystemLogsPage() {
|
||||
{ key: 'operator', title: '操作人', width: '120px', render: (record) => <strong>{record.operator}</strong> },
|
||||
{ key: 'action', title: '动作', width: '140px', render: (record) => record.action },
|
||||
{ key: 'resourceId', title: '资源ID', width: '150px', render: (record) => <span className="muted">{record.resourceId}</span> },
|
||||
{ key: 'detail', title: '详情', render: (record) => <span className="system-log-detail">{record.detail}</span> },
|
||||
{
|
||||
key: 'detail',
|
||||
title: '详情',
|
||||
width: '320px',
|
||||
render: (record) => (
|
||||
<div className="system-log-detail-card">
|
||||
<strong>{record.action}</strong>
|
||||
<span>{record.detail}</span>
|
||||
<small>{record.resourceId}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'ip', title: 'IP', width: '120px', render: (record) => <span className="muted">{record.ip}</span> },
|
||||
], []);
|
||||
|
||||
@@ -75,23 +91,20 @@ export function AdminSystemLogsPage() {
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<div>
|
||||
<Breadcrumb items={['系统管理', '系统日志']} />
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索企业、操作人、动作、资源ID或详情"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
onChange={(event) => { setLevel(event.target.value); setPage(1); }}
|
||||
options={[
|
||||
{ label: '全部级别', value: 'all' },
|
||||
{ label: '信息', value: 'info' },
|
||||
@@ -101,7 +114,7 @@ export function AdminSystemLogsPage() {
|
||||
]}
|
||||
value={level}
|
||||
/>
|
||||
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
|
||||
<Select onChange={(event) => { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} />
|
||||
</div>
|
||||
|
||||
<div className="system-log-range">
|
||||
@@ -124,9 +137,16 @@ export function AdminSystemLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredLogs.length}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminService } from '@/mock';
|
||||
import type { AuditItem, AuditStatus } from '@/mock';
|
||||
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';
|
||||
|
||||
const applicationMap: Record<string, string> = {
|
||||
'AUD-2401': '验证码服务',
|
||||
'AUD-2403': '营销推广平台',
|
||||
};
|
||||
|
||||
const auditStatusLabelMap: Record<AuditStatus, string> = {
|
||||
const auditStatusLabelMap: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
draft: '草稿',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
];
|
||||
|
||||
export function AdminTemplateAuditPage() {
|
||||
const [audits, setAudits] = useState(() => adminService.getAudits());
|
||||
const columns = useMemo<Array<TableColumn<AuditItem>>>(
|
||||
const [audits, setAudits] = useState<SmsTemplateAudit[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listTemplateAudits({ keyword, status })
|
||||
.then(setAudits)
|
||||
.catch(() => setAudits([]));
|
||||
}, [keyword, status]);
|
||||
|
||||
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveTemplate(id)
|
||||
: await adminApi.rejectTemplate(id);
|
||||
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SmsTemplateAudit>>>(
|
||||
() => [
|
||||
{ key: 'id', title: '审核编号', render: (record) => record.id },
|
||||
{ key: 'customer', title: '客户', render: (record) => record.customer },
|
||||
{ key: 'application', title: '短信应用', render: (record) => applicationMap[record.id] ?? '客户通知服务' },
|
||||
{ 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) => record.submittedAt },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'approved' ? 'success' : record.status === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.status]}
|
||||
<Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>
|
||||
{auditStatusLabelMap[record.auditStatus] ?? record.auditStatus}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -40,18 +58,18 @@ export function AdminTemplateAuditPage() {
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'approved'))}
|
||||
onClick={() => void reviewTemplate(record.id, 'approved')}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
disabled={record.status !== 'pending'}
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => setAudits(adminService.updateAuditStatus(record.id, 'rejected'))}
|
||||
onClick={() => void reviewTemplate(record.id, 'rejected')}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -63,15 +81,25 @@ export function AdminTemplateAuditPage() {
|
||||
],
|
||||
[],
|
||||
);
|
||||
const templateAudits = audits.filter((item) => item.type === '模板');
|
||||
const templateAudits = audits;
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack admin-template-audit-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['短信模板审核']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--template">
|
||||
<Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索客户、应用、模板内容或审核编号" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={templateAudits} rowKey="id" />
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CreditCard,
|
||||
Landmark,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
@@ -12,8 +10,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Button, Input, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'success' | 'failed';
|
||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
|
||||
type AuthMethod = 'face' | 'recharge';
|
||||
type CertificationStatus = 'uncertified' | 'pending' | 'approved' | 'rejected';
|
||||
|
||||
const companyInfo = {
|
||||
name: '上海闪联九玖信息通信技术有限公司',
|
||||
@@ -56,14 +55,21 @@ function EnterpriseStepper({ current }: { current: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthHeader({ onCertified }: { onCertified: () => void }) {
|
||||
function AuthHeader({ status }: { status: CertificationStatus }) {
|
||||
const statusText: Record<CertificationStatus, string> = {
|
||||
uncertified: '未认证',
|
||||
pending: '审核中',
|
||||
approved: '已通过',
|
||||
rejected: '未通过',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><ShieldCheck size={22} /></span>
|
||||
<h1>企业认证</h1>
|
||||
</div>
|
||||
<Button onClick={onCertified} variant="secondary">设为已认证(测试)</Button>
|
||||
<span className={`enterprise-review-status enterprise-review-status--${status}`}>{statusText[status]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,26 +77,39 @@ function AuthHeader({ onCertified }: { onCertified: () => void }) {
|
||||
export function ClientEnterpriseAuthPage() {
|
||||
const [step, setStep] = useState<AuthStep>('overview');
|
||||
const [method, setMethod] = useState<AuthMethod>('face');
|
||||
const [status, setStatus] = useState<CertificationStatus>('uncertified');
|
||||
|
||||
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'success' || step === 'failed' ? 4 : 1;
|
||||
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'pending' || step === 'success' || step === 'failed' ? 4 : 1;
|
||||
|
||||
if (step === 'overview') {
|
||||
const overviewCopy = status === 'approved'
|
||||
? '企业认证已审核通过,可正常使用发送、签名报备等能力。'
|
||||
: status === 'pending'
|
||||
? '企业认证资料已提交,平台运营将在 1 个工作日内完成审核。'
|
||||
: status === 'rejected'
|
||||
? '企业认证未通过,请根据驳回原因修改资料后重新提交。'
|
||||
: '您还未进行企业认证';
|
||||
|
||||
return (
|
||||
<section className="page-stack enterprise-page">
|
||||
<AuthHeader onCertified={() => setStep('success')} />
|
||||
<AuthHeader status={status} />
|
||||
|
||||
<div className="surface enterprise-status-card">
|
||||
<strong>您还未进行企业认证</strong>
|
||||
<button type="button" onClick={() => setStep('profile')}>企业认证 ></button>
|
||||
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
|
||||
<strong>{overviewCopy}</strong>
|
||||
{status === 'approved' ? null : (
|
||||
<button type="button" onClick={() => setStep(status === 'pending' ? 'pending' : 'profile')}>
|
||||
{status === 'pending' ? '查看审核进度 >' : '企业认证 >'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="surface enterprise-info-card">
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>未知</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>未知</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>未知</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>未知</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>未知</dd></div>
|
||||
<div><dt>企业名称:</dt><dd>{status === 'approved' ? companyInfo.name : '待认证'}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{status === 'approved' ? companyInfo.certifiedAt : '待审核完成'}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{status === 'approved' ? companyInfo.code : '待认证'}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{status === 'approved' ? companyInfo.address : '待认证'}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{status === 'approved' ? companyInfo.legalPerson : '待认证'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
@@ -202,7 +221,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
</div>
|
||||
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('success')}>确认并充值</Button>
|
||||
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}>确认并充值</Button>
|
||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,20 +254,36 @@ export function ClientEnterpriseAuthPage() {
|
||||
<h2>扫码认证</h2>
|
||||
<p>为了验证您的身份,请使用支付宝扫码进行人脸识别,剩余有效时间:<em>59分57秒</em></p>
|
||||
<div className="enterprise-qr">二维码</div>
|
||||
<span>完成扫描操作后,实名将立即成功</span>
|
||||
<span>完成扫描操作后,资料将提交至运营端审核。</span>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('face')} variant="secondary">重新填写法人信息</Button>
|
||||
<Button onClick={() => setStep('success')} className="enterprise-success-test">测试:认证成功</Button>
|
||||
<Button onClick={() => setStep('failed')} variant="danger">测试:认证失败</Button>
|
||||
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}>提交审核</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'pending' ? (
|
||||
<div className="enterprise-result enterprise-result--pending">
|
||||
<span><ShieldCheck size={70} /></span>
|
||||
<h2>认证资料已提交,等待运营审核</h2>
|
||||
<p>当前状态:运营端审核中。审核通过后将自动解锁短信发送、签名报备等能力;未通过时可根据驳回原因重新提交。</p>
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{companyInfo.name}</dd></div>
|
||||
<div><dt>提交时间:</dt><dd>2026年07月02日 09:58:00</dd></div>
|
||||
<div><dt>审核时效:</dt><dd>1 个工作日内</dd></div>
|
||||
<div><dt>当前节点:</dt><dd>运营端资料审核</dd></div>
|
||||
</dl>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">返回概览</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'success' ? (
|
||||
<div className="enterprise-result enterprise-result--success">
|
||||
<span><Check size={70} /></span>
|
||||
<h2>恭喜您,认证成功!</h2>
|
||||
<h2>认证审核通过</h2>
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{companyInfo.name}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{companyInfo.code}</dd></div>
|
||||
@@ -256,19 +291,18 @@ export function ClientEnterpriseAuthPage() {
|
||||
<div><dt>认证时间:</dt><dd>{companyInfo.certifiedAt}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{companyInfo.address}</dd></div>
|
||||
</dl>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">关闭页面</Button>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">返回概览</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'failed' ? (
|
||||
<div className="enterprise-result enterprise-result--failed">
|
||||
<span>!</span>
|
||||
<h2>很遗憾,认证失败!</h2>
|
||||
<p>相同方式认证时限倒计时:</p>
|
||||
<strong>2天23小时59分</strong>
|
||||
<button type="button" onClick={() => setStep('method')}>选其他认证方式 <ChevronRight size={18} /></button>
|
||||
<h2>认证审核未通过</h2>
|
||||
<p>驳回原因:营业执照照片不清晰,企业联系人手机号无法核验。请修改资料后重新提交。</p>
|
||||
<button type="button" onClick={() => setStep('profile')}>重新提交资料 <ChevronRight size={18} /></button>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('method')} variant="secondary">选择其他认证方式</Button>
|
||||
<Button onClick={() => setStep('profile')}>重新提交</Button>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">关闭页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -201,7 +201,7 @@ export function ClientHome() {
|
||||
<Chart height={300} option={sendTrendOption} />
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
<h2>通道占比</h2>
|
||||
<h2>运营商占比</h2>
|
||||
<p className="muted">按今日提交量估算。</p>
|
||||
<Chart height={300} option={channelShareOption} />
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ function SignatureForm({ signature }: { signature?: SignatureItem }) {
|
||||
export function ClientSignaturesPage() {
|
||||
const [signatures, setSignatures] = useState(initialSignatures);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [expandedId, setExpandedId] = useState('sig-1');
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const [signatureModal, setSignatureModal] = useState<{ mode: 'add' | 'edit'; signature?: SignatureItem } | null>(null);
|
||||
const [editingDrainage, setEditingDrainage] = useState<{ signature: SignatureItem; drainage?: DrainageInfo } | null>(null);
|
||||
|
||||
@@ -228,7 +228,7 @@ export function ClientSignaturesPage() {
|
||||
</div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal({ mode: 'edit', signature })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => deleteSignature(signature.id)} size="sm" variant="ghost">删除</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => deleteSignature(signature.id)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,8 +254,8 @@ export function ClientSignaturesPage() {
|
||||
<Tag tone={statusToneMap[item.telecom]}>{statusLabelMap[item.telecom]}</Tag>
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<button onClick={() => setEditingDrainage({ signature, drainage: item })} type="button">编辑</button>
|
||||
<button onClick={() => deleteDrainage(signature.id, item.id)} type="button">删除</button>
|
||||
<Button onClick={() => setEditingDrainage({ signature, drainage: item })} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => deleteDrainage(signature.id, item.id)} size="sm" variant="danger">删除</Button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
@@ -53,6 +54,8 @@ export function ClientSystemLogsPage() {
|
||||
const [level, setLevel] = useState('all');
|
||||
const [module, setModule] = useState('all');
|
||||
const [range, setRange] = useState('today');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 5;
|
||||
|
||||
const moduleOptions = useMemo(() => {
|
||||
const modules = Array.from(new Set(logsSeed.map((item) => item.module)));
|
||||
@@ -66,6 +69,9 @@ export function ClientSystemLogsPage() {
|
||||
const matchesModule = module === 'all' || item.module === module;
|
||||
return matchesKeyword && matchesLevel && matchesModule;
|
||||
});
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pagedLogs = filteredLogs.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const columns = useMemo<Array<TableColumn<SystemLog>>>(() => [
|
||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{record.time}</span> },
|
||||
@@ -89,13 +95,13 @@ export function ClientSystemLogsPage() {
|
||||
|
||||
<div className="system-log-filters">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onChange={(event) => { setKeyword(event.target.value); setPage(1); }}
|
||||
placeholder="搜索操作人、操作或详情"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
onChange={(event) => setLevel(event.target.value)}
|
||||
onChange={(event) => { setLevel(event.target.value); setPage(1); }}
|
||||
options={[
|
||||
{ label: '全部级别', value: 'all' },
|
||||
{ label: '信息', value: 'info' },
|
||||
@@ -105,7 +111,7 @@ export function ClientSystemLogsPage() {
|
||||
]}
|
||||
value={level}
|
||||
/>
|
||||
<Select onChange={(event) => setModule(event.target.value)} options={moduleOptions} value={module} />
|
||||
<Select onChange={(event) => { setModule(event.target.value); setPage(1); }} options={moduleOptions} value={module} />
|
||||
</div>
|
||||
|
||||
<div className="system-log-range">
|
||||
@@ -128,7 +134,15 @@ export function ClientSystemLogsPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface system-table-card">
|
||||
<Table columns={columns} data={filteredLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
<Table columns={columns} data={pagedLogs} emptyText="暂无系统日志" rowKey="id" />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
|
||||
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredLogs.length}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UserRole = 'admin' | 'user';
|
||||
type UserRole = 'enterprise_admin' | 'user';
|
||||
type UserStatus = 'active' | 'disabled';
|
||||
|
||||
type ClientUser = {
|
||||
@@ -25,12 +25,12 @@ type ClientUser = {
|
||||
};
|
||||
|
||||
const roleLabelMap: Record<UserRole, string> = {
|
||||
admin: '管理员',
|
||||
enterprise_admin: '企业管理员',
|
||||
user: '普通用户',
|
||||
};
|
||||
|
||||
const usersSeed: ClientUser[] = [
|
||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
||||
{ id: 'USER001', name: '张三', email: 'zhangsan@example.com', phone: '13800138000', role: 'enterprise_admin', status: 'active', lastLoginAt: '2026-03-17 09:15:00' },
|
||||
{ id: 'USER002', name: '李四', email: 'lisi@example.com', phone: '13800138001', role: 'user', status: 'active', lastLoginAt: '2026-03-16 16:45:00' },
|
||||
{ id: 'USER003', name: '王五', email: 'wangwu@example.com', phone: '13800138002', role: 'user', status: 'active', lastLoginAt: '2026-03-15 11:30:00' },
|
||||
{ id: 'USER004', name: '赵六', email: 'zhaoliu@example.com', phone: '13800138003', role: 'user', status: 'disabled', lastLoginAt: '2026-02-20 14:00:00' },
|
||||
@@ -51,6 +51,8 @@ export function ClientUsersPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<ClientUser | null>(null);
|
||||
const [draft, setDraft] = useState<ClientUser>(emptyUser);
|
||||
const enterpriseAdmin = usersSeed.find((item) => item.role === 'enterprise_admin');
|
||||
const canSelectEnterpriseAdmin = !enterpriseAdmin || editingUser?.id === enterpriseAdmin.id;
|
||||
|
||||
const filteredUsers = usersSeed.filter((item) => {
|
||||
const target = `${item.name} ${item.email} ${item.phone}`;
|
||||
@@ -71,7 +73,7 @@ export function ClientUsersPage() {
|
||||
key: 'role',
|
||||
title: '角色',
|
||||
width: '150px',
|
||||
render: (record) => <Tag tone={record.role === 'admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
||||
render: (record) => <Tag tone={record.role === 'enterprise_admin' ? 'info' : 'success'}>{roleLabelMap[record.role]}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
@@ -134,10 +136,11 @@ export function ClientUsersPage() {
|
||||
<Input label="邮箱 *" onChange={(event) => setDraft({ ...draft, email: event.target.value })} placeholder="请输入邮箱" value={draft.email} />
|
||||
<Input label="手机号 *" onChange={(event) => setDraft({ ...draft, phone: event.target.value })} placeholder="请输入手机号" value={draft.phone} />
|
||||
<Select
|
||||
hint={canSelectEnterpriseAdmin ? '企业管理员拥有企业空间最高权限。' : `当前企业管理员为 ${enterpriseAdmin?.name},每个企业仅允许 1 位企业管理员。`}
|
||||
label="角色 *"
|
||||
onChange={(event) => setDraft({ ...draft, role: event.target.value as UserRole })}
|
||||
options={[
|
||||
{ label: '管理员', value: 'admin' },
|
||||
...(canSelectEnterpriseAdmin ? [{ label: '企业管理员', value: 'enterprise_admin' }] : []),
|
||||
{ label: '普通用户', value: 'user' },
|
||||
]}
|
||||
value={draft.role}
|
||||
|
||||
@@ -34,6 +34,12 @@ export function AdminLayout() {
|
||||
workspaceName="平台运营工作区"
|
||||
userName="运营"
|
||||
userRole="平台管理员"
|
||||
auditNotifications={[
|
||||
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核', count: 8, to: '/admin/sms-audit' },
|
||||
{ label: '短信模板审核', count: 5, to: '/admin/templates' },
|
||||
{ label: '签名审核', count: 2, to: '/admin/enterprise-signatures' },
|
||||
]}
|
||||
navSections={[
|
||||
{
|
||||
title: '运营概览',
|
||||
@@ -42,7 +48,6 @@ export function AdminLayout() {
|
||||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||||
{ label: '数据统计', to: '/admin/analytics', icon: BarChart3 },
|
||||
{ label: '客户管理', to: '/admin/customers', icon: Building2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,7 +121,7 @@ export function AdminLayout() {
|
||||
items: [
|
||||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||||
{ label: '引流信息字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||
{ label: '报备字段库', to: '/admin/drainage-fields', icon: Hash },
|
||||
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
|
||||
+87
-12
@@ -1,10 +1,12 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search,
|
||||
@@ -25,6 +27,12 @@ export type ShellNavSection = {
|
||||
items: ShellNavItem[];
|
||||
};
|
||||
|
||||
export type AuditNotificationItem = {
|
||||
label: string;
|
||||
count: number;
|
||||
to: string;
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -32,6 +40,7 @@ type AppShellProps = {
|
||||
userName: string;
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
auditNotifications?: AuditNotificationItem[];
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
@@ -41,10 +50,32 @@ export function AppShell({
|
||||
userName,
|
||||
userRole,
|
||||
navSections,
|
||||
auditNotifications = [],
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||
const auditTotal = useMemo(
|
||||
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||
[auditNotifications],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (auditTotal <= 0 || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const alertedKey = `cmpp-audit-alert-${title}`;
|
||||
if (window.sessionStorage.getItem(alertedKey)) {
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.setItem(alertedKey, '1');
|
||||
const timer = window.setTimeout(() => {
|
||||
window.alert(`有新的审核任务进入待办,共 ${auditTotal} 条。`);
|
||||
}, 1200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [auditTotal, title]);
|
||||
|
||||
return (
|
||||
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : ''].filter(Boolean).join(' ')}>
|
||||
@@ -126,17 +157,61 @@ export function AppShell({
|
||||
<button className="icon-button" type="button" aria-label="帮助中心">
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
<button className="icon-button has-dot" type="button" aria-label="通知">
|
||||
<Bell size={18} />
|
||||
</button>
|
||||
<button className="user-menu" type="button">
|
||||
<span className="user-avatar">{userName.slice(0, 1)}</span>
|
||||
<span>
|
||||
<strong>{userName}</strong>
|
||||
<small>{userRole}</small>
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<div className="notice-menu-wrap">
|
||||
<button
|
||||
aria-expanded={noticeOpen}
|
||||
aria-haspopup="menu"
|
||||
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setNoticeOpen((open) => !open)}
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
|
||||
</button>
|
||||
{noticeOpen ? (
|
||||
<div className="notice-popover" role="menu">
|
||||
<div className="notice-popover__header">
|
||||
<strong>待审核任务</strong>
|
||||
<span>{auditTotal} 条</span>
|
||||
</div>
|
||||
{auditNotifications.length ? auditNotifications.map((item) => (
|
||||
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.count}</strong>
|
||||
</NavLink>
|
||||
)) : <p>暂无待审核任务</p>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="user-menu-wrap">
|
||||
<button
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="user-menu"
|
||||
onClick={() => setUserMenuOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<span className="user-avatar">{userName.slice(0, 1)}</span>
|
||||
<span>
|
||||
<strong>{userName}</strong>
|
||||
<small>{userRole}</small>
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{userMenuOpen ? (
|
||||
<div className="user-menu-popover" role="menu">
|
||||
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button">
|
||||
<KeyRound size={16} />
|
||||
修改密码
|
||||
</button>
|
||||
<button onClick={() => setUserMenuOpen(false)} role="menuitem" type="button">
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
ReceiptText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
@@ -66,7 +65,6 @@ export function ClientLayout() {
|
||||
items: [
|
||||
{ label: '企业认证', to: '/client/enterprise-auth', icon: ShieldCheck },
|
||||
{ label: '用户管理', to: '/client/users', icon: Users },
|
||||
{ label: '账号设置', to: '/client/settings', icon: Settings },
|
||||
{ label: '系统日志', to: '/client/system-logs', icon: FileText },
|
||||
],
|
||||
},
|
||||
|
||||
+464
-35
@@ -495,6 +495,86 @@ h3 {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.notice-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notice-count {
|
||||
align-items: center;
|
||||
background: var(--color-danger);
|
||||
border: 2px solid var(--color-surface);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-inverse) !important;
|
||||
display: inline-flex;
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 4px;
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
top: -7px;
|
||||
}
|
||||
|
||||
.notice-popover {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 238px;
|
||||
padding: var(--space-3);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
.notice-popover__header {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-1);
|
||||
padding: 0 var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.notice-popover__header strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.notice-popover__header span {
|
||||
color: var(--color-danger);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.notice-popover a,
|
||||
.notice-popover p {
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.notice-popover a:hover {
|
||||
background: var(--color-selected-soft);
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.notice-popover a strong {
|
||||
background: var(--color-danger-soft);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-danger);
|
||||
min-width: 28px;
|
||||
padding: 2px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
@@ -507,6 +587,43 @@ h3 {
|
||||
padding: 0 var(--space-3) 0 var(--space-1);
|
||||
}
|
||||
|
||||
.user-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-popover {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 164px;
|
||||
padding: var(--space-2);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
z-index: var(--z-dropdown);
|
||||
}
|
||||
|
||||
.user-menu-popover button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-menu-popover button:hover {
|
||||
background: var(--color-selected-soft);
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
align-items: center;
|
||||
background: var(--color-brand);
|
||||
@@ -2436,10 +2553,6 @@ h3 {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.signature-actions .ui-button:last-child {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.drainage-panel {
|
||||
background: #f1f3f5;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -2476,24 +2589,13 @@ h3 {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.drainage-table__row a,
|
||||
.drainage-row-actions button:first-child {
|
||||
.drainage-table__row a {
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.drainage-row-actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.drainage-row-actions button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.drainage-row-actions button:last-child {
|
||||
color: var(--color-danger);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.drainage-panel__footer {
|
||||
@@ -2529,6 +2631,113 @@ h3 {
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
|
||||
}
|
||||
|
||||
.cmpp-status-cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmpp-status-cell button {
|
||||
align-items: center;
|
||||
background: var(--color-selected-soft);
|
||||
border: 1px solid rgba(37, 99, 235, 0.22);
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
font-weight: var(--font-weight-bold);
|
||||
height: 28px;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.cmpp-status-cell button:hover {
|
||||
background: var(--color-selected);
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.cmpp-status-cell__params {
|
||||
gap: 4px;
|
||||
min-width: 58px !important;
|
||||
}
|
||||
|
||||
.cmpp-connection-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-connection-summary > div {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-height: 82px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.cmpp-connection-summary strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.cmpp-param-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.cmpp-param-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.cmpp-param-grid > div {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-height: 78px;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.cmpp-param-grid span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.cmpp-param-grid strong {
|
||||
color: var(--color-text-strong);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.cmpp-param-copy {
|
||||
background: #0f172a;
|
||||
border-radius: var(--radius-md);
|
||||
color: #e5e7eb;
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.admin-split-pagination {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -3724,15 +3933,10 @@ h3 {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inline-actions .ui-button--danger {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.system-user-form {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
.system-user-form .ui-field__label {
|
||||
@@ -3745,6 +3949,32 @@ h3 {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.system-log-detail-card {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
line-height: 1.55;
|
||||
max-width: 360px;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.system-log-detail-card strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.system-log-detail-card span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.system-log-detail-card small {
|
||||
color: var(--color-selected);
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.enterprise-page {
|
||||
gap: 28px;
|
||||
}
|
||||
@@ -3769,6 +3999,48 @@ h3 {
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.enterprise-status-card--pending {
|
||||
border-color: rgba(37, 99, 235, 0.26);
|
||||
}
|
||||
|
||||
.enterprise-status-card--approved {
|
||||
border-color: rgba(22, 163, 74, 0.28);
|
||||
}
|
||||
|
||||
.enterprise-status-card--rejected {
|
||||
border-color: rgba(220, 38, 38, 0.28);
|
||||
}
|
||||
|
||||
.enterprise-review-status {
|
||||
align-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
min-height: 30px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.enterprise-review-status--uncertified {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.enterprise-review-status--pending {
|
||||
background: var(--color-info-soft);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.enterprise-review-status--approved {
|
||||
background: var(--color-success-soft);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.enterprise-review-status--rejected {
|
||||
background: var(--color-danger-soft);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.enterprise-status-card button,
|
||||
.enterprise-result button {
|
||||
align-items: center;
|
||||
@@ -4129,13 +4401,18 @@ h3 {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.enterprise-result--success > span {
|
||||
.enterprise-result--success > span,
|
||||
.enterprise-result--pending > span {
|
||||
background: #00bb38;
|
||||
color: #ffffff;
|
||||
height: 132px;
|
||||
width: 132px;
|
||||
}
|
||||
|
||||
.enterprise-result--pending > span {
|
||||
background: var(--color-selected);
|
||||
}
|
||||
|
||||
.enterprise-result--failed > span {
|
||||
border: 5px solid #ff4040;
|
||||
color: #ff4040;
|
||||
@@ -4899,6 +5176,10 @@ h3 {
|
||||
grid-template-columns: minmax(280px, 1.4fr) minmax(220px, 1fr) auto;
|
||||
}
|
||||
|
||||
.audit-filter-grid--template {
|
||||
grid-template-columns: minmax(320px, 1.4fr) minmax(220px, 0.8fr) auto;
|
||||
}
|
||||
|
||||
.audit-filter-grid--sms {
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(220px, 1.2fr) minmax(160px, 1fr) auto;
|
||||
}
|
||||
@@ -4919,6 +5200,47 @@ h3 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.enterprise-audit-detail {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.enterprise-audit-detail section {
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.enterprise-audit-detail h3 {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.enterprise-audit-detail div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.enterprise-audit-detail span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.enterprise-audit-detail strong {
|
||||
color: var(--color-text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.enterprise-audit-detail__remark {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.audit-link {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
@@ -5236,7 +5558,7 @@ h3 {
|
||||
.sms-channel-table__row {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(210px, 1.45fr) 170px;
|
||||
grid-template-columns: minmax(150px, 1.2fr) 88px 86px 96px minmax(210px, 1.35fr) 190px;
|
||||
min-width: 860px;
|
||||
}
|
||||
|
||||
@@ -5286,6 +5608,30 @@ h3 {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-selected);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sms-channel-status-cell button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sms-channel-quality {
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -5333,7 +5679,7 @@ h3 {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
width: 170px;
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.sms-channel-actions button {
|
||||
@@ -5370,8 +5716,57 @@ h3 {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.sms-channel-actions .sms-channel-report-entry {
|
||||
grid-column: 1 / -1;
|
||||
.channel-confirm {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.channel-confirm strong {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.channel-confirm span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.channel-confirm p {
|
||||
color: var(--color-text);
|
||||
line-height: 1.7;
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
|
||||
.channel-log-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.channel-log-item {
|
||||
align-items: start;
|
||||
background: var(--color-surface-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.channel-log-item strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.channel-log-item span {
|
||||
color: var(--color-text-muted);
|
||||
display: block;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.channel-log-item p {
|
||||
color: var(--color-text);
|
||||
line-height: 1.65;
|
||||
margin: var(--space-1) 0 0;
|
||||
}
|
||||
|
||||
.sms-channel-pagination {
|
||||
@@ -6524,7 +6919,7 @@ h3 {
|
||||
}
|
||||
|
||||
.report-task-table-card .ui-table {
|
||||
min-width: 1180px;
|
||||
min-width: 1380px;
|
||||
}
|
||||
|
||||
.report-task-table-card .ui-table th:last-child,
|
||||
@@ -6552,6 +6947,25 @@ h3 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-remark-cell {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
display: -webkit-box;
|
||||
line-height: 1.6;
|
||||
max-width: 360px;
|
||||
min-width: 220px;
|
||||
overflow: hidden;
|
||||
padding: var(--space-3);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.admin-remark-cell--empty {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.report-task-generate {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
@@ -7468,7 +7882,7 @@ h3 {
|
||||
}
|
||||
|
||||
.admin-recharge-table {
|
||||
min-width: 980px;
|
||||
min-width: 1220px;
|
||||
}
|
||||
|
||||
.admin-recharge-table th {
|
||||
@@ -7481,8 +7895,9 @@ h3 {
|
||||
.admin-recharge-table td {
|
||||
color: var(--color-text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
height: 68px;
|
||||
height: 76px;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-recharge-table td:nth-child(3),
|
||||
@@ -7523,6 +7938,24 @@ h3 {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-security-filter {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(320px, 1fr) auto;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.admin-security-filter__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-security-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-security-table-card {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
@@ -7628,10 +8061,6 @@ h3 {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-drainage-actions .ui-button--danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-drainage-pagination {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@@ -11,5 +11,19 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user