fix: complete first version issue remediation

This commit is contained in:
hectorzhao
2026-07-11 10:14:37 +08:00
parent 709ac97764
commit 208a6c23f8
73 changed files with 1549 additions and 245 deletions
+61 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { CheckCircle2, Info, Pencil, Plus, RadioTower, Trash2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
@@ -24,15 +24,6 @@ type RouteModalState = {
route?: ProvinceRoute | NationalRoute;
};
const provinceOptions = [
{ label: '请选择', value: '' },
{ label: '山东', value: '山东' },
{ label: '河南', value: '河南' },
{ label: '北京', value: '北京' },
{ label: '上海', value: '上海' },
{ label: '广东', value: '广东' },
];
const priorityOptions = [
{ label: '请选择', value: '' },
{ label: '1', value: '1' },
@@ -83,12 +74,14 @@ function RouteConfigModal({
channels,
carrier,
modal,
occupiedChannelIds,
onClose,
onSubmit,
}: {
channels: AdminChannel[];
carrier: Carrier;
modal: RouteModalState;
occupiedChannelIds: string[];
onClose: () => void;
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
}) {
@@ -97,9 +90,20 @@ function RouteConfigModal({
const [province, setProvince] = useState(provinceRoute?.province ?? '');
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
const [error, setError] = useState('');
const provinceOptions = [
{ label: '请选择省份', value: '' },
...Array.from(new Set(channels
.filter((channel) => isCarrierCompatible(channel.carrier, carrier))
.map((channel) => channel.sendRegion)
.filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')),
)).sort().map((region) => ({ label: region, value: region })),
];
const selectableChannels = channels.filter((channel) => {
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false;
if (modal.type === 'province' && province) {
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
}
@@ -114,10 +118,16 @@ function RouteConfigModal({
];
function submit() {
if (!channelId) return;
if (!channelId) {
setError('请选择可用通道');
return;
}
const channel = channels.find((item) => item.id === channelId);
if (modal.type === 'province') {
if (!province) return;
if (!province) {
setError('请选择省份');
return;
}
onSubmit({
id: provinceRoute?.id ?? `p-${Date.now()}`,
province,
@@ -127,7 +137,10 @@ function RouteConfigModal({
return;
}
if (!priority) return;
if (!priority) {
setError('请选择优先级');
return;
}
onSubmit({
id: nationalRoute?.id ?? `n-${Date.now()}`,
priority: Number(priority),
@@ -147,7 +160,12 @@ function RouteConfigModal({
onClose={onClose}
open
size="xl"
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
title={(
<div className="channel-route-modal__title">
<span><RadioTower size={20} /></span>
<div><h2>{modal.mode === 'edit' ? '编辑通道' : '添加通道'}</h2><p>{modal.type === 'province' ? '为指定省份选择匹配的上游通道' : '按优先级配置全国通道补发顺序'}</p></div>
</div>
)}
>
<div className="channel-route-modal">
{modal.type === 'province' ? (
@@ -162,6 +180,22 @@ function RouteConfigModal({
</>
)}
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
{selectableChannels.length === 0 ? <p className="channel-route-modal__empty"></p> : null}
{channelId ? (() => {
const selected = channels.find((channel) => channel.id === channelId);
if (!selected) return null;
const status = getChannelStatus(selected);
return (
<div className="channel-route-modal__selected">
<CheckCircle2 size={18} />
<div>
<strong>{selected.name}</strong>
<span>{selected.code} · {selected.sendRegion ?? '全国'} · {statusLabels[status]}</span>
</div>
</div>
);
})() : null}
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>
);
@@ -441,7 +475,19 @@ export function AdminChannelGroupFormPage() {
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost"></Button>
</div>
{modal ? <RouteConfigModal carrier={carrier} channels={channels} modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
{modal ? (
<RouteConfigModal
carrier={carrier}
channels={channels}
modal={modal}
occupiedChannelIds={[
...provinceRoutes.map((route) => route.channelId),
...nationalRoutes.map((route) => route.channelId),
]}
onClose={() => setModal(null)}
onSubmit={saveRoute}
/>
) : null}
</div>
);
}
@@ -157,6 +157,8 @@ export function AdminChannelGroupsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredGroups.length}
/>
+7 -2
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { ArrowLeft, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
export function AdminChannelReportPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [fields, setFields] = useState<ChannelReportField[]>([]);
const [channelId, setChannelId] = useState('');
@@ -60,7 +62,10 @@ export function AdminChannelReportPage() {
<Breadcrumb items={['报备管理', '通道报备配置']} />
<h1></h1>
</div>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
<div className="page-actions">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<Button disabled={!channelId} icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
+117 -23
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
@@ -29,6 +29,8 @@ type SmsChannel = {
cmppVersion: '2.0' | '3.0';
desiredConnections: number;
windowSize: number;
extensionDigits: number;
rateLimitPerSecond: number;
passwordCipher?: string;
};
@@ -47,6 +49,22 @@ type ChannelLogState = {
data?: ChannelConnectionLogResponse;
};
const connectionStatusLabelMap: Record<string, string> = {
connected: '已连接',
connecting: '连接中',
reconnecting: '重连中',
disconnected: '已断开',
failed: '连接失败',
auth_failed: '鉴权失败',
heartbeat_timeout: '心跳超时',
};
function formatLogDetail(detail?: unknown) {
if (!detail) return '无附加信息';
if (typeof detail === 'string') return detail;
return JSON.stringify(detail, null, 2);
}
const carrierOptions = [
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
@@ -154,6 +172,8 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
cmppVersion: channel.cmppVersion === '3.0' ? '3.0' : '2.0',
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
windowSize: Number(channel.config?.windowSize ?? 16),
extensionDigits: Number(channel.config?.extensionDigits ?? 0),
rateLimitPerSecond: channel.rateLimitPerSecond,
};
}
@@ -173,10 +193,11 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
passwordCipher: passwordCipher || undefined,
srcId: channel.accessNo,
cmppVersion: channel.cmppVersion,
rateLimitPerSecond: 100,
rateLimitPerSecond: channel.rateLimitPerSecond,
unitPrice: Math.round(channel.unitPrice),
desiredConnections: channel.desiredConnections,
windowSize: channel.windowSize,
config: { extensionDigits: channel.extensionDigits },
};
}
@@ -212,8 +233,8 @@ function ChannelFormModal({
const [cmppVersion, setCmppVersion] = useState<'2.0' | '3.0'>(channel?.cmppVersion ?? '2.0');
const [password, setPassword] = useState('');
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
const [extensionDigits, setExtensionDigits] = useState('0');
const [flowLimit, setFlowLimit] = useState('1-2000');
const [extensionDigits, setExtensionDigits] = useState(String(channel?.extensionDigits ?? 0));
const [flowLimit, setFlowLimit] = useState(String(channel?.rateLimitPerSecond ?? 100));
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
@@ -240,6 +261,8 @@ function ChannelFormModal({
cmppVersion,
desiredConnections: Number(desiredConnections) || 1,
windowSize: Number(windowSize) || 16,
extensionDigits: Number(extensionDigits),
rateLimitPerSecond: Number(flowLimit),
passwordCipher: password || undefined,
});
}
@@ -287,12 +310,20 @@ function ChannelFormModal({
<Input label="* 企业代码" onChange={(event) => setCorpCode(event.target.value)} placeholder="请输入企业代码" value={corpCode} />
<Input label="* 网关账号" onChange={(event) => setAccount(event.target.value)} placeholder="请输入网关账号" value={account} />
<Select label="* CMPP版本" onChange={(event) => setCmppVersion(event.target.value as '2.0' | '3.0')} options={cmppVersionOptions} value={cmppVersion} />
<Input label="* 网关密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入网关密码" type="password" value={password} />
<Input
hint={modal.mode === 'edit' ? '已配置的密码不会回显;留空保持不变,填写新密码才更新。' : undefined}
label="网关密码"
onChange={(event) => setPassword(event.target.value)}
placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'}
required={modal.mode === 'create'}
type="password"
value={password}
/>
<div className="sms-channel-inline-field">
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
</div>
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
</div>
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
</div>
@@ -305,16 +336,18 @@ function ChannelFormModal({
function SmsTestModal({
channel,
onClose,
onOpenRecords,
}: {
channel: SmsChannel;
onClose: () => void;
onOpenRecords: () => void;
}) {
const [phones, setPhones] = useState('');
const [content, setContent] = useState('');
const [accessNo, setAccessNo] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState('');
const [result, setResult] = useState<ChannelTestResponse | null>(null);
const billingCount = Math.max(1, Math.ceil(content.length / 67));
async function submitTestSms() {
@@ -328,14 +361,14 @@ function SmsTestModal({
}
setSubmitting(true);
setError('');
setResult('');
setResult(null);
try {
const response = await adminApi.testChannel(channel.id, {
phones,
content,
accessNo: accessNo.trim() || undefined,
});
setResult(`已提交 ${response.submitted} 条测试短信,测试流水号 ${response.testNo}`);
setResult(response);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '测试短信发送失败');
} finally {
@@ -348,8 +381,9 @@ function SmsTestModal({
footer={(
<>
<Button onClick={onClose} variant="ghost"></Button>
<Button disabled={submitting} icon={<Send size={16} />} onClick={submitTestSms}>
{submitting ? '发送中...' : '发送测试'}
{result ? <Button icon={<ExternalLink size={16} />} onClick={onOpenRecords} variant="ghost"></Button> : null}
<Button disabled={submitting || Boolean(result)} icon={<Send size={16} />} onClick={submitTestSms}>
{submitting ? '发送中...' : result ? '已提交' : '发送测试'}
</Button>
</>
)}
@@ -402,9 +436,22 @@ function SmsTestModal({
</div>
{error ? <p className="form-error">{error}</p> : null}
{result ? (
<div className="signature-alert sms-test-note">
<Info size={18} />
<span>{result}</span>
<div className="sms-test-result">
<div className="sms-test-result__summary">
<CheckCircle2 size={20} />
<div>
<strong></strong>
<span>{result.testNo} {result.submitted} </span>
</div>
</div>
<div className="sms-test-result__records">
{result.messages.map((message) => (
<div key={message.messageRecordId}>
<span>{message.phoneNumber}</span>
<code>{message.submitId}</code>
</div>
))}
</div>
</div>
) : null}
</div>
@@ -423,6 +470,7 @@ export function AdminChannelsPage() {
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
const [logState, setLogState] = useState<ChannelLogState | null>(null);
const [logKeyword, setLogKeyword] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
@@ -496,8 +544,14 @@ export function AdminChannelsPage() {
async function openLinkLogs(channel: SmsChannel) {
setLogState({ channel });
const data = await adminApi.listChannelConnectionLogs(channel.id);
setLogState({ channel, data });
setLogKeyword('');
try {
const data = await adminApi.listChannelConnectionLogs(channel.id);
setLogState({ channel, data });
} catch (failure) {
setLogState(null);
setError(failure instanceof Error ? failure.message : '连接日志加载失败');
}
}
function submitConfirmAction() {
@@ -606,6 +660,8 @@ export function AdminChannelsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredChannels.length}
/>
@@ -623,6 +679,10 @@ export function AdminChannelsPage() {
<SmsTestModal
channel={testChannel}
onClose={() => setTestChannel(null)}
onOpenRecords={() => {
setTestChannel(null);
navigate('/admin/sms-records');
}}
/>
) : null}
@@ -654,8 +714,38 @@ export function AdminChannelsPage() {
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) => (
<div className="channel-log-modal">
{logState.data ? (
<div className="channel-connection-summary">
{logState.data.connectionStates.map((connection) => (
<article key={connection.id}>
<div>
<span> ID</span>
<strong>{connection.connectionId}</strong>
</div>
<Tag tone={connection.status === 'connected' ? 'success' : connection.lastError ? 'danger' : 'info'}>
{connectionStatusLabelMap[connection.status] ?? connection.status}
</Tag>
<div>
<span> / </span>
<strong>{connection.currentConnections} / {connection.desiredConnections}</strong>
</div>
<div>
<span></span>
<strong>{connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'}</strong>
</div>
{connection.lastError ? <p>{connection.lastError}</p> : null}
</article>
))}
{logState.data.connectionStates.length === 0 ? <p className="muted"></p> : null}
</div>
) : null}
<Input label="筛选日志" onChange={(event) => setLogKeyword(event.target.value)} placeholder="事件、资源或详情关键词" value={logKeyword} />
<div className="channel-log-list">
{(logState.data?.logs ?? []).filter((log) => {
const keyword = logKeyword.trim().toLowerCase();
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
}).map((log) => (
<article className="channel-log-item" key={log.id}>
<div>
<strong>{log.event}</strong>
@@ -663,12 +753,16 @@ export function AdminChannelsPage() {
</div>
<div>
<span>{log.resourceId}</span>
<p>{typeof log.detail === 'string' ? log.detail : JSON.stringify(log.detail ?? {})}</p>
<pre>{formatLogDetail(log.detail)}</pre>
</div>
</article>
))}
{logState.data && logState.data.logs.length === 0 ? <p className="muted"></p> : null}
{logState.data && logState.data.logs.filter((log) => {
const keyword = logKeyword.trim().toLowerCase();
return !keyword || `${log.event} ${log.resourceId ?? ''} ${formatLogDetail(log.detail)}`.toLowerCase().includes(keyword);
}).length === 0 ? <p className="muted"></p> : null}
{!logState.data ? <p className="muted">...</p> : null}
</div>
</div>
</Modal>
) : null}
@@ -364,6 +364,8 @@ export function AdminDownstreamDeliveriesPage() {
<Pagination
total={total}
page={page}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
@@ -332,6 +332,8 @@ export function AdminDownstreamRecoveryStatusesPage() {
<Pagination
total={total}
page={page}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
@@ -3,6 +3,7 @@ import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type SmsApp = {
id: string;
@@ -140,7 +141,6 @@ function formatCmppParams(app: SmsApp, params?: ApplicationCmppParams | null) {
`接入号: ${'srcId' in cmppParams ? cmppParams.srcId : cmppParams.accessNumber}`,
`最大连接数: ${cmppParams.maxConnections}`,
`心跳间隔: ${cmppParams.heartbeatSeconds}`,
`提交窗口: ${cmppParams.windowSize}`,
`协议版本: ${cmppParams.protocolVersion}`,
].join('\n');
}
@@ -186,7 +186,6 @@ function CmppParamsModal({ app, params, onClose }: { app: SmsApp; params?: Appli
<div><span></span><strong>{srcId}</strong></div>
<div><span></span><strong>{params?.maxConnections ?? app.cmppParams.maxConnections}</strong></div>
<div><span></span><strong>{params?.heartbeatSeconds ?? app.cmppParams.heartbeatSeconds} </strong></div>
<div><span></span><strong>{params?.windowSize ?? app.cmppParams.windowSize}</strong></div>
<div><span></span><strong>{params?.protocolVersion ?? app.cmppParams.protocolVersion}</strong></div>
</div>
<pre className="cmpp-param-copy">{paramsText}</pre>
@@ -382,7 +381,7 @@ export function AdminEnterpriseApplicationsPage() {
render: (record) => (
<div className="table-actions">
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant={record.enabled ? 'warning' : 'success'}>
{record.enabled ? '停用' : '启用'}
</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger"></Button>
@@ -401,7 +400,7 @@ export function AdminEnterpriseApplicationsPage() {
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}></Button>
</div>
<div className="surface admin-split-filter">
<div className="surface admin-split-filter admin-application-filter">
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
@@ -481,9 +480,9 @@ function mapConnection(connection: CmppConnectionState): CmppConnection {
bindType: 'transceiver',
clientIp: String(connection.channel?.gatewayHost ?? ''),
sourceAddr: String(connection.channel?.enterpriseCode ?? ''),
establishedAt: connection.lastConnectedAt ? new Date(connection.lastConnectedAt).toLocaleString('zh-CN') : '',
lastHeartbeatAt: connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN') : '',
lastSubmitAt: connection.updatedAt ? new Date(connection.updatedAt).toLocaleString('zh-CN') : '',
establishedAt: formatDateTime(connection.lastConnectedAt),
lastHeartbeatAt: formatDateTime(connection.lastHeartbeatAt),
lastSubmitAt: formatDateTime(connection.updatedAt),
pendingWindow: connection.currentConnections,
};
}
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { displayFileName } from '@/utils/fileName';
import { formatDateTime } from '@/utils/dateTime';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -69,13 +71,6 @@ const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neut
filing: 'neutral',
};
const statusOptions = [
{ label: '已通过', value: 'approved' },
{ label: '审核中', value: 'pending' },
{ label: '已驳回', value: 'rejected' },
{ label: '待报备', value: 'filing' },
];
function StatusTag({ status }: { status: CarrierStatus }) {
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
}
@@ -170,12 +165,16 @@ function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filin
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
}
function toAuditStatus(status: CarrierStatus) {
return status === 'filing' ? 'pending' : status;
function signatureCardTone(statuses: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }) {
const values = Object.values(statuses);
if (values.includes('rejected')) return 'red';
if (values.every((status) => status === 'approved')) return 'green';
if (values.includes('pending')) return 'blue';
return 'gray';
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-';
return formatDateTime(value);
}
function SignatureUploadBox({
@@ -210,7 +209,7 @@ function SignatureUploadBox({
<label className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
<span>{label}</span>
<Upload size={compact ? 30 : 42} />
<strong>{uploading ? '上传中...' : file?.fileName || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
<strong>{uploading ? '上传中...' : (file ? displayFileName(file.fileName) : '') || (compact ? '上传文件' : '点击上传 或拖拽文件到此处')}</strong>
<FileActions file={file} />
{!compact ? <small> PNGJPGJPEGPDF 10M</small> : null}
{error ? <small className="form-error">{error}</small> : null}
@@ -348,14 +347,6 @@ function SignatureFormModal({
</div>
</section>
<section>
<h3></h3>
<div className="signature-form-grid">
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
</div>
</section>
</div>
</Modal>
);
@@ -379,7 +370,7 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
mobile: 'filing',
unicom: 'filing',
telecom: 'filing',
submittedAt: new Date().toLocaleString('zh-CN'),
submittedAt: formatDateTime(new Date()),
remark: '',
});
@@ -429,9 +420,6 @@ function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; o
<Input label="* 字段名称8" onChange={(event) => update('field8', event.target.value)} placeholder="请输入责任人身份证号" value={form.field8 ?? ''} />
<Input label="* 字段名称9" onChange={(event) => update('field9', event.target.value)} placeholder="请输入责任人姓名" value={form.field9 ?? ''} />
<Input label="* 字段名称10" onChange={(event) => update('field10', event.target.value)} placeholder="请输入责任人手机号" value={form.field10 ?? ''} />
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
</div>
@@ -559,7 +547,6 @@ export function AdminEnterpriseSignaturesPage() {
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null,
auditStatus: toAuditStatus(state.mobile),
drainageInfo,
name: state.name,
purpose: state.purpose,
@@ -622,7 +609,7 @@ export function AdminEnterpriseSignaturesPage() {
const payload = readDrainagePayload(signature);
const expanded = expandedSignatureId === signature.id;
return (
<article className="signature-card signature-card--green" key={signature.id}>
<article className={`signature-card signature-card--${signatureCardTone(payload.carrierStatus)}`} key={signature.id}>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
@@ -647,7 +634,7 @@ export function AdminEnterpriseSignaturesPage() {
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
@@ -657,7 +644,7 @@ export function AdminEnterpriseSignaturesPage() {
{payload.links.map((item) => (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<StatusTag status={item.mobile} />
<StatusTag status={item.unicom} />
<StatusTag status={item.telecom} />
@@ -686,6 +673,8 @@ export function AdminEnterpriseSignaturesPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
type TemplateFormState = {
@@ -41,7 +42,7 @@ function extractVariables(content: string): TemplateVariable[] {
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN') : '-';
return formatDateTime(value);
}
function statusTone(status: string) {
@@ -51,6 +52,14 @@ function statusTone(status: string) {
return 'info';
}
const auditStatusLabel: Record<string, string> = {
approved: '已通过',
draft: '草稿',
pending: '审核中',
rejected: '已驳回',
deleted: '已删除',
};
function billingUnits(content: string) {
if (!content) {
return 1;
@@ -324,7 +333,7 @@ export function AdminEnterpriseTemplatesPage() {
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0}` },
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{record.auditStatus}</Tag> },
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{auditStatusLabel[record.auditStatus] ?? record.auditStatus}</Tag> },
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
{
key: 'actions',
+33 -11
View File
@@ -18,9 +18,10 @@ type CarrierRule = DictionaryItem & {
};
export function AdminPhoneSegmentsPage() {
const pageSize = 20;
const pageSize = 25;
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [rules, setRules] = useState<CarrierRule[]>([]);
const [ruleTotal, setRuleTotal] = useState(0);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
const [keyword, setKeyword] = useState('');
const [creating, setCreating] = useState(false);
@@ -37,6 +38,7 @@ export function AdminPhoneSegmentsPage() {
const [loading, setLoading] = useState(false);
const [segmentQuery, setSegmentQuery] = useState('');
const [page, setPage] = useState(1);
const [rulePage, setRulePage] = useState(1);
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
@@ -56,14 +58,15 @@ export function AdminPhoneSegmentsPage() {
setLoading(true);
Promise.all([
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
adminApi.listPhoneCarrierRules(),
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
])
.then(([segmentPage, ruleItems]) => {
.then(([segmentPage, ruleResponse]) => {
if (cancelled) return;
setSegments(segmentPage.items as PhoneSegment[]);
setHasMore(segmentPage.hasMore);
setNextCursor(segmentPage.nextCursor);
setRules(ruleItems as CarrierRule[]);
setRules(ruleResponse.items as CarrierRule[]);
setRuleTotal(ruleResponse.total);
setError('');
})
.catch((failure: Error) => {
@@ -75,12 +78,9 @@ export function AdminPhoneSegmentsPage() {
return () => {
cancelled = true;
};
}, [page, pageCursors, reloadKey, segmentQuery]);
}, [activeTab, page, pageCursors, reloadKey, rulePage, segmentQuery]);
const filteredRules = useMemo(
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
[keyword, rules],
);
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
function createSegment() {
adminApi.createPhoneSegment({ prefix, carrier, province, city })
@@ -141,7 +141,11 @@ export function AdminPhoneSegmentsPage() {
<div className="surface admin-system-table-card">
<Tabs
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
className="phone-segment-tabs"
onChange={(value) => {
setActiveTab(value as 'segments' | 'rules');
setRulePage(1);
}}
value={activeTab}
items={[
{
@@ -168,7 +172,25 @@ export function AdminPhoneSegmentsPage() {
</>
),
},
{ label: '运营商区分规则', value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
{
label: '运营商区分规则',
value: 'rules',
content: (
<>
<Table columns={ruleColumns} data={rules} emptyText={loading ? '加载中...' : '暂无运营商区分规则'} pagination={false} rowKey="id" />
<Pagination
page={rulePage}
total={ruleTotal}
totalPages={ruleTotalPages}
previousDisabled={rulePage <= 1 || loading}
nextDisabled={rulePage >= ruleTotalPages || loading}
onPrevious={() => setRulePage((current) => Math.max(1, current - 1))}
onNext={() => setRulePage((current) => Math.min(ruleTotalPages, current + 1))}
onPageChange={setRulePage}
/>
</>
),
},
]}
/>
</div>
+35 -22
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type ManualRechargeForm = {
tenantId: string;
@@ -36,7 +37,6 @@ function RemarkCell({ value }: { value?: string }) {
export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState<RechargeOrder[]>([]);
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
@@ -45,19 +45,19 @@ export function AdminRechargeRecordsPage() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [manualError, setManualError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function loadData() {
setLoading(true);
setError('');
try {
const [nextTenants, nextRecords, nextAccounts] = await Promise.all([
const [nextTenants, nextRecords] = await Promise.all([
adminApi.listTenants(),
adminApi.listManualRecharges(),
adminApi.listAccounts(),
]);
setTenants(nextTenants);
setRecords(nextRecords);
setAccounts(nextAccounts);
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
} catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败');
@@ -98,23 +98,33 @@ export function AdminRechargeRecordsPage() {
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
setForm((current) => ({ ...current, [key]: value }));
setManualError('');
}
async function submitManualRecharge() {
const amount = Number(form.amount);
const smsUnits = Number(form.smsUnits || 0);
if (!form.tenantId || !Number.isFinite(amount) || !Number.isFinite(smsUnits) || (amount === 0 && smsUnits === 0)) {
setManualError('请填写非 0 的充值金额或短信条数;金额支持负数冲正。');
return;
}
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: Math.round(amount * 100),
smsUnits,
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
});
await loadData();
setManualOpen(false);
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
setSubmitting(true);
setManualError('');
try {
await adminApi.createManualRecharge({
tenantId: form.tenantId,
amountCents: Math.round(amount * 100),
smsUnits,
remark: [form.operator, form.remark].filter(Boolean).join(' / '),
});
await loadData();
setManualOpen(false);
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
} catch (failure) {
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
} finally {
setSubmitting(false);
}
}
return (
@@ -158,14 +168,13 @@ export function AdminRechargeRecordsPage() {
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={7}></td></tr>
) : visibleRows.map((record) => {
const account = accounts.find((item) => item.tenantId === record.tenantId);
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return (
<tr key={record.id}>
<td><strong>{tenantName}</strong></td>
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
<td>{formatAmount(record.amountCents / 100)}</td>
<td>{formatAmount((account?.balanceCents ?? 0) / 100)}</td>
<td>{formatDateTime(record.paidAt ?? record.createdAt)}</td>
<td>¥{formatAmount(record.amountCents / 100)}</td>
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
<td><Tag tone="warning"></Tag></td>
<td>{record.operatorId || '运营'}</td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
@@ -181,6 +190,8 @@ export function AdminRechargeRecordsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
/>
@@ -190,8 +201,8 @@ export function AdminRechargeRecordsPage() {
<Modal
footer={(
<>
<Button onClick={() => setManualOpen(false)} variant="ghost"></Button>
<Button onClick={submitManualRecharge}></Button>
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost"></Button>
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
</>
)}
onClose={() => setManualOpen(false)}
@@ -204,13 +215,15 @@ export function AdminRechargeRecordsPage() {
label="企业名称"
onChange={(event) => updateForm('tenantId', event.target.value)}
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
required
value={form.tenantId}
/>
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" type="number" value={form.amount} />
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required type="number" value={form.amount} />
<Input label="短信条数" onChange={(event) => updateForm('smsUnits', event.target.value)} type="number" value={form.smsUnits} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} value={form.operator} />
<Input label="操作人" onChange={(event) => updateForm('operator', event.target.value)} required value={form.operator} />
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
</div>
{manualError ? <p className="form-error">{manualError}</p> : null}
</Modal>
) : null}
</section>
+2 -1
View File
@@ -58,7 +58,7 @@ export function AdminSensitiveWordsPage() {
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => record.status ?? '-' },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{
key: 'actions',
@@ -122,6 +122,7 @@ export function AdminSensitiveWordsPage() {
)}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加敏感词"
>
<div className="admin-security-form">
@@ -29,7 +29,6 @@ export function AdminSmsApplicationFormPage() {
const [interfaceEnabled, setInterfaceEnabled] = useState(true);
const [interfaceType, setInterfaceType] = useState<InterfaceType>('cmpp20');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [cmppWindowSize, setCmppWindowSize] = useState('16');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
@@ -89,7 +88,6 @@ export function AdminSmsApplicationFormPage() {
setInterfaceEnabled(application.interfaceEnabled !== false);
setInterfaceType('cmpp20');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
@@ -131,7 +129,6 @@ export function AdminSmsApplicationFormPage() {
interfaceEnabled,
interfaceType,
cmppMaxConnections: Number(cmppMaxConnections) || 1,
cmppWindowSize: Number(cmppWindowSize) || 16,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: parseIpAllowlist(ipAddress),
@@ -258,7 +255,6 @@ export function AdminSmsApplicationFormPage() {
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
</div>
</section>
+28 -12
View File
@@ -182,9 +182,27 @@ function SendDetailModal({
onClose={onClose}
open
size="xl"
title="发送详情"
title={<div className="template-modal-title"><h2></h2><p>{record.messageId}</p></div>}
>
<div className="admin-sms-send-detail">
<div className="admin-sms-detail-overview">
<div>
<span></span>
<Tag tone={record.status === 'delivered' ? 'success' : ['failed', 'rejected'].includes(record.status) ? 'danger' : 'info'}>{getStatusLabel(record.status)}</Tag>
</div>
<div>
<span></span>
<strong>{record.submitStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.receiptStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
</div>
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{record.content}</p>
@@ -211,6 +229,10 @@ function SendDetailModal({
<dt></dt>
<dd>{route.receiptCode ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{route.submitStatus ?? '-'}</dd>
</div>
</dl>
</div>
</article>
@@ -263,6 +285,10 @@ export function AdminSmsRecordsPage() {
tenantId: enterprise === 'all' ? undefined : enterprise,
applicationId: application === 'all' ? undefined : application,
phoneNumber: phoneKeyword || undefined,
contentKeyword: contentKeyword || undefined,
channelKeyword: channelKeyword || undefined,
queuedAtFrom: dateRange.start,
queuedAtTo: dateRange.end,
status: status === 'all' ? undefined : status,
})
.then((items) => {
@@ -309,17 +335,7 @@ export function AdminSmsRecordsPage() {
return [{ label: '全部应用', value: 'all' }, ...Array.from(applications, ([value, label]) => ({ label, value }))];
}, [enterprise, records]);
const filteredRows = useMemo(
() => records.filter((item) => {
const submittedDate = getDate(item.queuedAt);
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
const matchesChannel = !channelKeyword || (item.channel?.name ?? item.channelId ?? '').includes(channelKeyword);
return matchesStartDate && matchesEndDate && matchesContent && matchesChannel;
}),
[channelKeyword, contentKeyword, dateRange.end, dateRange.start, records],
);
const filteredRows = records;
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
+5 -2
View File
@@ -131,7 +131,8 @@ function mapTask(task: SmsBatchTask): SmsTask {
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
const processedCount = submittedCount + (task.unknownTotal ?? 0) + (task.timeoutTotal ?? 0);
// submittedTotal already includes unknown and timeout records, so never add them again.
const processedCount = Math.max(submittedCount, successCount + failedCount + (task.unknownTotal ?? 0));
const billingCount = messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
@@ -149,7 +150,7 @@ function mapTask(task: SmsBatchTask): SmsTask {
scheduledAt: task.scheduledAt,
submittedCount,
submittedSuccess: submittedCount,
sentCount: Math.max(processedCount, successCount + failedCount),
sentCount: Math.min(task.phoneTotal, processedCount),
successCount,
failedCount,
status: normalizeTaskStatus(task.status),
@@ -532,6 +533,8 @@ export function AdminSmsTaskProgressPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
+4 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type OperationLogItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
@@ -54,7 +55,7 @@ export function AdminSystemLogsPage() {
const currentPage = Math.min(page, totalPages);
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
@@ -133,6 +134,8 @@ export function AdminSystemLogsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={total}
/>
+26 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { KeyRound, Plus, Search } from 'lucide-react';
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
@@ -62,6 +63,7 @@ export function AdminUsersPage() {
const [newPassword, setNewPassword] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function load() {
const [nextUsers, nextTenants] = await Promise.all([adminApi.listUsers(), adminApi.listTenants()]);
@@ -102,6 +104,11 @@ export function AdminUsersPage() {
}
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6) || (form.roleCode === 'enterprise_admin' && !form.tenantId)) {
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位,企业管理员必须关联企业');
return;
}
setSaving(true);
setError('');
const body: UserPayload = {
tenantId: form.roleCode === 'enterprise_admin' ? form.tenantId : null,
@@ -113,14 +120,20 @@ export function AdminUsersPage() {
roleCode: form.roleCode,
operatorId: session?.user.id,
};
if (creating) {
await adminApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
await adminApi.updateUser(editingUser.id, body);
try {
if (creating) {
await adminApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
await adminApi.updateUser(editingUser.id, body);
}
setCreating(false);
setEditingUser(null);
await load();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户保存失败');
} finally {
setSaving(false);
}
setCreating(false);
setEditingUser(null);
await load();
}
async function runConfirm() {
@@ -147,7 +160,7 @@ export function AdminUsersPage() {
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => formatDateTime(record.lastLoginAt) },
{
key: 'actions',
title: '操作',
@@ -157,7 +170,7 @@ export function AdminUsersPage() {
<div className="admin-system-actions">
<Button onClick={() => openEdit(record)} size="sm" variant="ghost"></Button>
<Button onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
@@ -186,14 +199,14 @@ export function AdminUsersPage() {
{(creating || editingUser) ? (
<Modal
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost"></Button><Button onClick={() => void saveUser()}></Button></>}
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="ghost"></Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
onClose={() => { setCreating(false); setEditingUser(null); }}
open
title={creating ? '新增用户' : '编辑用户'}
>
<div className="admin-system-modal-form">
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} value={form.displayName} />
<Input label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
<Input label="用户姓名" onChange={(event) => updateField('displayName', event.target.value)} required value={form.displayName} />
<Input hint="邮箱和手机号至少填写一项" label="邮箱" onChange={(event) => updateField('email', event.target.value)} value={form.email} />
<Input label="手机号" onChange={(event) => updateField('phone', event.target.value)} value={form.phone} />
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
<Select
@@ -210,7 +223,7 @@ export function AdminUsersPage() {
value={form.tenantId}
/>
) : null}
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} required type="password" value={form.password} /> : null}
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
</div>
</Modal>
+2 -1
View File
@@ -50,7 +50,6 @@ function mapParams(params: ApplicationCmppParams): ParamRow[] {
{ label: '接入号', value: params.srcId || '-' },
{ label: '连接数', value: String(params.maxConnections || '-') },
{ label: '心跳间隔', value: `${params.heartbeatSeconds}` },
{ label: '窗口大小', value: String(params.windowSize) },
{ label: '协议版本', value: params.protocolVersion },
];
}
@@ -169,6 +168,8 @@ export function ClientApplicationsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={applications.length}
/>
+2
View File
@@ -298,6 +298,8 @@ export function ClientBatchTasksPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
+2
View File
@@ -64,6 +64,8 @@ export function ClientBillingPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={plans.length}
/>
+2 -1
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import { Button, FileActions, Input, Select, Textarea } from '@/components/ui';
import { clientApi, type EnterpriseCertification, type FileObject, type FileRef } from '@/api/adminApi';
import { displayFileName } from '@/utils/fileName';
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
type AuthMethod = 'face' | 'recharge';
@@ -59,7 +60,7 @@ function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; upl
return (
<label className="enterprise-upload">
<Upload size={38} />
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
<strong>{uploading ? '上传中...' : file ? displayFileName(file.fileName) : '点击上传'}</strong>
<FileActions file={fileRef} />
<input
accept="image/png,image/jpeg,image/webp,application/pdf"
+2 -1
View File
@@ -13,6 +13,7 @@ import { useNavigate } from 'react-router-dom';
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
type RecentTaskRow = {
id: string;
@@ -59,7 +60,7 @@ export function ClientHome() {
scene: String(task.category ?? task.content ?? '短信发送'),
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
createdAt: task.createdAt ? new Date(String(task.createdAt)).toLocaleString('zh-CN') : '',
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
status: String(task.status ?? 'unknown'),
})), [dashboard]);
const latestRecharge = dashboard?.recentRecharges[0];
+2
View File
@@ -229,6 +229,8 @@ export function ClientSendDetailPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
/>
+2
View File
@@ -157,6 +157,8 @@ export function ClientSignaturesPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
+4 -1
View File
@@ -10,6 +10,7 @@ import {
type TableColumn,
} from '@/components/ui';
import { clientApi, type OperationLogItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type LogLevel = 'info' | 'success' | 'warning' | 'error';
@@ -61,7 +62,7 @@ export function ClientSystemLogsPage() {
const currentPage = Math.min(page, totalPages);
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.time)}</span> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
@@ -127,6 +128,8 @@ export function ClientSystemLogsPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={total}
/>
+4 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type TemplateVariable = {
name: string;
@@ -276,7 +277,7 @@ export function ClientTemplatesPage() {
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted"></span>}
</div>
<div className="template-card-footer">
<span>{new Date(template.updatedAt).toLocaleString('zh-CN')}</span>
<span>{formatDateTime(template.updatedAt)}</span>
<div>
<button onClick={() => setModalTemplate(template)} type="button">
<Edit3 size={14} />
@@ -297,6 +298,8 @@ export function ClientTemplatesPage() {
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
/>
+23 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
@@ -50,6 +51,7 @@ export function ClientUsersPage() {
const [newPassword, setNewPassword] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
async function load() {
if (!tenantId) return;
@@ -79,6 +81,11 @@ export function ClientUsersPage() {
}
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
setError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
const body: UserPayload = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
@@ -88,14 +95,20 @@ export function ClientUsersPage() {
roleCode: 'enterprise_admin',
operatorId: session?.user.id,
};
if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId);
} else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId);
try {
if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId);
} else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId);
}
setCreating(false);
setEditingUser(null);
await load();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '用户保存失败');
} finally {
setSaving(false);
}
setCreating(false);
setEditingUser(null);
await load();
}
async function runConfirm() {
@@ -122,7 +135,7 @@ export function ClientUsersPage() {
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-'}</span> },
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span> },
{
key: 'actions',
title: '操作',
@@ -131,7 +144,7 @@ export function ClientUsersPage() {
<div className="inline-actions">
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button>
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant="ghost">{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button onClick={() => setConfirmAction({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div>
),
@@ -158,7 +171,7 @@ export function ClientUsersPage() {
{(creating || editingUser) ? (
<Modal
footer={<><Button onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button onClick={() => void saveUser()}></Button></>}
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
onClose={() => { setCreating(false); setEditingUser(null); }}
open
size="xl"