fix: close documented platform polish gaps

This commit is contained in:
hectorzhao
2026-07-11 18:20:38 +08:00
parent 6236547871
commit 2ecbb920a6
25 changed files with 193 additions and 110 deletions
+4 -10
View File
@@ -3,6 +3,7 @@ import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Po
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
@@ -97,13 +98,6 @@ const regionOptions = [
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
];
const extensionOptions = [
{ label: '0', value: '0' },
{ label: '2', value: '2' },
{ label: '4', value: '4' },
{ label: '6', value: '6' },
];
const carrierLabelMap: Record<Carrier, string> = {
mobile: '移动',
unicom: '联通',
@@ -321,7 +315,7 @@ function ChannelFormModal({
/>
<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} />
<Input label="展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" 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} />
@@ -732,7 +726,7 @@ export function AdminChannelsPage() {
</div>
<div>
<span></span>
<strong>{connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'}</strong>
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
</div>
{connection.lastError ? <p>{connection.lastError}</p> : null}
</article>
@@ -749,7 +743,7 @@ export function AdminChannelsPage() {
<article className="channel-log-item" key={log.id}>
<div>
<strong>{log.event}</strong>
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
<span>{formatDateTime(log.time)}</span>
</div>
<div>
<span>{log.resourceId}</span>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, FileSearch, Search, X } from 'lucide-react';
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
@@ -59,7 +60,7 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
contactName: record.contactName ?? '',
contactPhone: record.contactPhone ?? '',
contactEmail: String(materials.contactEmail ?? ''),
submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }),
submittedAt: formatDateTime(record.submittedAt),
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
status: record.status as EnterpriseAuditStatus,
};
@@ -455,7 +455,7 @@ function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="引流信息报备详情">
<div className="detail-grid">
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><StatusTag status={item.mobile} /></div>
<div><span></span><StatusTag status={item.unicom} /></div>
<div><span></span><StatusTag status={item.telecom} /></div>
+23 -10
View File
@@ -85,6 +85,7 @@ export function AdminHome() {
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 };
const sendTrendOption = useMemo(
() => createLineOption({
@@ -99,12 +100,12 @@ export function AdminHome() {
const auditTrendOption = useMemo(
() => createBarOption({
labels: ['待审核'],
labels: ['企业认证', '短信审核', '模板', '签名'],
series: [
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] },
],
}),
[dashboard],
[pendingAudits],
);
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
@@ -230,14 +231,26 @@ export function AdminHome() {
<BarChart3 size={20} className="status-info" />
</div>
<div className="overview-grid overview-grid--three">
<div className="mini-status-card">
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
<FileCheck2 size={22} />
<div>
<span></span>
<strong>{dashboard?.pendingAuditCount ?? 0} </strong>
<small></small>
</div>
</div>
<span></span>
<strong>{pendingAudits.enterpriseCertifications} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.smsAudits} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.templates} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-signatures')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.signatures} </strong>
</Button>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
+10 -19
View File
@@ -20,6 +20,7 @@ type CarrierRule = DictionaryItem & {
export function AdminPhoneSegmentsPage() {
const pageSize = 25;
const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [segmentTotal, setSegmentTotal] = useState(0);
const [rules, setRules] = useState<CarrierRule[]>([]);
const [ruleTotal, setRuleTotal] = useState(0);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
@@ -39,16 +40,12 @@ export function AdminPhoneSegmentsPage() {
const [segmentQuery, setSegmentQuery] = useState('');
const [page, setPage] = useState(1);
const [rulePage, setRulePage] = useState(1);
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => {
setSegmentQuery(keyword.trim());
setPage(1);
setPageCursors([undefined]);
}, 300);
return () => window.clearTimeout(timer);
}, [keyword]);
@@ -57,14 +54,13 @@ export function AdminPhoneSegmentsPage() {
let cancelled = false;
setLoading(true);
Promise.all([
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }),
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
])
.then(([segmentPage, ruleResponse]) => {
if (cancelled) return;
setSegments(segmentPage.items as PhoneSegment[]);
setHasMore(segmentPage.hasMore);
setNextCursor(segmentPage.nextCursor);
setSegmentTotal(segmentPage.total);
setRules(ruleResponse.items as CarrierRule[]);
setRuleTotal(ruleResponse.total);
setError('');
@@ -78,8 +74,9 @@ export function AdminPhoneSegmentsPage() {
return () => {
cancelled = true;
};
}, [activeTab, page, pageCursors, reloadKey, rulePage, segmentQuery]);
}, [activeTab, page, reloadKey, rulePage, segmentQuery]);
const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize));
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
function createSegment() {
@@ -90,7 +87,6 @@ export function AdminPhoneSegmentsPage() {
setCity('');
setCreating(false);
setPage(1);
setPageCursors([undefined]);
setReloadKey((current) => current + 1);
})
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
@@ -157,17 +153,12 @@ export function AdminPhoneSegmentsPage() {
<Pagination
page={page}
previousDisabled={page <= 1 || loading}
nextDisabled={!hasMore || loading}
nextDisabled={page >= segmentTotalPages || loading}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => {
if (!nextCursor) return;
setPageCursors((current) => {
const updated = [...current];
updated[page] = nextCursor;
return updated;
});
setPage((current) => current + 1);
}}
onNext={() => setPage((current) => Math.min(segmentTotalPages, current + 1))}
onPageChange={setPage}
total={segmentTotal}
totalPages={segmentTotalPages}
/>
</>
),
@@ -146,7 +146,6 @@ export function AdminRechargeRecordsPage() {
<th style={{ width: '130px' }}></th>
<th style={{ width: '140px' }}></th>
<th style={{ width: '120px' }}></th>
<th style={{ width: '140px' }}></th>
<th style={{ width: '300px' }}></th>
</tr>
</thead>
@@ -166,7 +165,6 @@ export function AdminRechargeRecordsPage() {
<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>
</tr>
);
+17 -2
View File
@@ -267,6 +267,7 @@ function SendDetailModal({
}
export function AdminSmsRecordsPage() {
const pageSize = 25;
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
@@ -279,6 +280,7 @@ export function AdminSmsRecordsPage() {
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
const [segmentLoading, setSegmentLoading] = useState(false);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
function loadData() {
adminApi.listOperationMessages({
@@ -293,6 +295,7 @@ export function AdminSmsRecordsPage() {
})
.then((items) => {
setRecords(items);
setPage(1);
setError('');
})
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
@@ -336,6 +339,9 @@ export function AdminSmsRecordsPage() {
}, [enterprise, records]);
const filteredRows = records;
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
@@ -421,7 +427,7 @@ export function AdminSmsRecordsPage() {
<tr>
<td className="ui-table__empty" colSpan={5}></td>
</tr>
) : filteredRows.map((record) => (
) : visibleRows.map((record) => (
<tr key={record.id}>
<td>
<div className="admin-sms-record-sender">
@@ -453,7 +459,16 @@ export function AdminSmsRecordsPage() {
</tbody>
</table>
</div>
<Pagination total={filteredRows.length} />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
onPageChange={setPage}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
totalPages={totalPages}
/>
</div>
{selectedRecord ? (
+4 -3
View File
@@ -1,6 +1,7 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import {
Breadcrumb,
Button,
@@ -85,7 +86,7 @@ function formatNumber(value: number) {
}
function formatTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-';
return formatDateTime(value);
}
function normalizeTaskStatus(status: string): TaskStatus {
@@ -468,7 +469,7 @@ export function AdminSmsTaskProgressPage() {
<span>{record.application}</span>
</div>
</td>
<td><span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span></td>
<td><span>{formatTime(record.submittedAt)}</span></td>
<td>
<div className="admin-task-counts">
<strong>{formatNumber(record.phoneCount)}</strong>
@@ -481,7 +482,7 @@ export function AdminSmsTaskProgressPage() {
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
{sendTypeLabels[record.sendType]}
</Tag>
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
</div>
</td>
<td>
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Check, Search, X } from 'lucide-react';
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
const auditStatusLabelMap: Record<string, string> = {
pending: '待审核',
@@ -41,7 +42,7 @@ export function AdminTemplateAuditPage() {
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
{ key: 'submittedAt', title: '提交时间', render: (record) => new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.createdAt) },
{
key: 'status',
title: '状态',
+37 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { KeyRound, Plus, Search } from 'lucide-react';
import { Eye, EyeOff, KeyRound, Plus, RefreshCw, Search } from 'lucide-react';
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
@@ -51,6 +51,13 @@ function toForm(user?: ManagedUser): UserForm {
} : emptyForm;
}
function generateInitialPassword() {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
const values = new Uint32Array(14);
crypto.getRandomValues(values);
return Array.from(values, (value) => alphabet[value % alphabet.length]).join('');
}
export function AdminUsersPage() {
const session = readSession();
const [users, setUsers] = useState<ManagedUser[]>([]);
@@ -61,6 +68,7 @@ export function AdminUsersPage() {
const [form, setForm] = useState<UserForm>(emptyForm);
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
const [newPassword, setNewPassword] = useState('');
const [showInitialPassword, setShowInitialPassword] = useState(false);
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
@@ -84,7 +92,8 @@ export function AdminUsersPage() {
}, [keyword, users]);
function openCreate() {
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
setShowInitialPassword(false);
setCreating(true);
}
@@ -223,8 +232,32 @@ export function AdminUsersPage() {
value={form.tenantId}
/>
) : null}
{creating ? <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} />
{creating ? (
<Input
label="初始密码"
onChange={(event) => updateField('password', event.target.value)}
required
suffix={(
<>
<button aria-label={showInitialPassword ? '隐藏初始密码' : '显示初始密码'} className="icon-button" onClick={() => setShowInitialPassword((current) => !current)} type="button">
{showInitialPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
<button aria-label="随机生成初始密码" className="icon-button" onClick={() => updateField('password', generateInitialPassword())} type="button">
<RefreshCw size={15} />
</button>
</>
)}
type={showInitialPassword ? 'text' : 'password'}
value={form.password}
/>
) : null}
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<label><input checked={form.status === 'active'} onChange={() => updateField('status', 'active')} type="radio" /></label>
<label><input checked={form.status === 'disabled'} onChange={() => updateField('status', 'disabled')} type="radio" /></label>
</div>
</div>
</div>
</Modal>
) : null}