fix: complete first version issue remediation
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>支持 PNG、JPG、JPEG、PDF,文件大小不超过 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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user