fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-14 22:53:34 +08:00
parent 7f9abe3da0
commit 4665079ca3
22 changed files with 1305 additions and 762 deletions
+67 -38
View File
@@ -1,20 +1,12 @@
import { useEffect, useMemo, useState } from 'react';
import {
BadgeCheck,
ClipboardList,
FileText,
PenLine,
Plus,
ReceiptText,
Send,
WalletCards,
} from 'lucide-react';
import { BadgeCheck, ClipboardList, FileText, PenLine, Plus, ReceiptText, Send, WalletCards } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
import { formatAmount, formatCents, moneyUnitsToYuan } from '@/utils/currency';
type RecentTaskRow = {
@@ -28,10 +20,23 @@ type RecentTaskRow = {
const columns: Array<TableColumn<RecentTaskRow>> = [
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
{
key: 'scene',
title: '短信内容',
width: '300px',
render: (record) => <span className="ui-table__long-text">{record.scene}</span>,
},
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')}` },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
{
key: 'status',
title: '状态',
render: (record) => (
<Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>
{batchTaskStatusMeta[normalizeBatchTaskStatus(record.status)].label}
</Tag>
),
},
];
export function ClientHome() {
@@ -40,7 +45,8 @@ export function ClientHome() {
const [error, setError] = useState('');
useEffect(() => {
clientApi.getDashboard()
clientApi
.getDashboard()
.then(setDashboard)
.catch((err) => {
setError(err instanceof Error ? err.message : '客户端工作台加载失败');
@@ -51,33 +57,42 @@ export function ClientHome() {
const account = dashboard?.accounts[0];
const availableBalance = moneyUnitsToYuan((account?.balanceCents ?? 0) + (account?.creditCents ?? 0));
const todayRefundCents = Math.max(0, dashboard?.today.returnedCents ?? 0);
const recentMessages = useMemo<RecentTaskRow[]>(() => (dashboard?.recentTasks ?? []).map((task) => ({
id: String(task.id ?? task.taskNo),
taskNo: String(task.taskNo ?? task.id),
scene: String(task.category ?? task.content ?? '短信发送'),
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
status: String(task.status ?? 'unknown'),
})), [dashboard]);
const recentMessages = useMemo<RecentTaskRow[]>(
() =>
(dashboard?.recentTasks ?? []).map((task) => ({
id: String(task.id ?? task.taskNo),
taskNo: String(task.taskNo ?? task.id),
scene: String(task.content ?? '-'),
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
status: String(task.status ?? 'unknown'),
})),
[dashboard],
);
const latestRecharge = dashboard?.recentRecharges[0];
const sendTrendOption = useMemo(
() => createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
() =>
createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交量', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功量', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
[dashboard],
);
const channelShareOption = useMemo(() => createPieOption({
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
name: item.status,
value: item._sum.currentConnections ?? item._count._all,
})),
}), [dashboard]);
const channelShareOption = useMemo(
() =>
createPieOption({
data: (dashboard?.gatewayConnections ?? []).map((item) => ({
name: item.status,
value: item._sum.currentConnections ?? item._count._all,
})),
}),
[dashboard],
);
return (
<section className="page-stack">
@@ -91,7 +106,9 @@ export function ClientHome() {
<Button icon={<Plus size={16} />} onClick={() => navigate('/client/templates')} variant="ghost">
</Button>
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}></Button>
<Button icon={<Send size={16} />} onClick={() => navigate('/client/send')}>
</Button>
</div>
</div>
@@ -179,7 +196,11 @@ export function ClientHome() {
</div>
<div className="overview-grid overview-grid--three">
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/templates')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/templates')}
type="button"
>
<BadgeCheck size={22} />
<div>
<span></span>
@@ -187,7 +208,11 @@ export function ClientHome() {
<small></small>
</div>
</button>
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/signatures')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/signatures')}
type="button"
>
<PenLine size={22} />
<div>
<span></span>
@@ -195,7 +220,11 @@ export function ClientHome() {
<small></small>
</div>
</button>
<button className="surface mini-status-card mini-status-card--action" onClick={() => navigate('/client/batch-tasks')} type="button">
<button
className="surface mini-status-card mini-status-card--action"
onClick={() => navigate('/client/batch-tasks')}
type="button"
>
<ClipboardList size={22} />
<div>
<span></span>
+6 -6
View File
@@ -1,4 +1,3 @@
import { HttpDeveloperDocs } from './http-docs/HttpDeveloperDocs';
import { useEffect, useRef, useState } from 'react';
import { Copy, KeyRound, RefreshCw, Webhook } from 'lucide-react';
import {
@@ -269,8 +268,6 @@ export function ClientHttpApiPage() {
</div>
);
const docsPanel = <HttpDeveloperDocs config={config} loading={loading} applicationId={applicationId} />;
const logsPanel = (
<div className="page-stack">
<div className="surface" style={{ padding: 16 }}>
@@ -316,7 +313,6 @@ export function ClientHttpApiPage() {
{ label: '接口概览', value: 'overview', content: overview },
{ label: '访问凭据', value: 'credentials', content: credentialPanel },
{ label: '回调配置', value: 'callbacks', content: callbackPanel },
{ label: '接口文档', value: 'docs', content: docsPanel },
{ label: '调用与回调记录', value: 'logs', content: logsPanel },
];
@@ -325,7 +321,7 @@ export function ClientHttpApiPage() {
<div className="page-heading">
<div>
<h1></h1>
<p> HTTP 访</p>
<p> HTTP 访</p>
</div>
<Select
label="企业应用"
@@ -360,7 +356,11 @@ export function ClientHttpApiPage() {
</Button>
</div>
) : null}
{applicationId ? <Tabs items={tabs} /> : docsPanel}
{applicationId ? (
<Tabs items={tabs} />
) : (
<p className="muted"></p>
)}
</section>
);
}
+87 -24
View File
@@ -5,39 +5,102 @@ import { ClientBatchTasksPage } from './ClientBatchTasksPage';
import { ClientSendDetailPage } from './ClientSendDetailPage';
import { ClientUplinkMessagesPage } from './ClientUplinkMessagesPage';
const { clientApi } = vi.hoisted(() => ({ clientApi: {
listApplicationOptions: vi.fn(), listBatchTasksPage: vi.fn(), listMessages: vi.fn(), listUplinkMessagesPage: vi.fn(),
} }));
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listApplicationOptions: vi.fn(),
listBatchTasksPage: vi.fn(),
listMessages: vi.fn(),
listUplinkMessagesPage: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
describe('explicit client queries', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
clientApi.listApplicationOptions.mockResolvedValue([]);
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage]) method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
for (const method of [clientApi.listBatchTasksPage, clientApi.listMessages, clientApi.listUplinkMessagesPage])
method.mockResolvedValue({ items: [], total: 25, page: 1, pageSize: 10 });
});
it('shows canonical receipt state and Beijing receipt time while keeping missing receipts empty', async () => {
clientApi.listMessages.mockResolvedValue({
items: [
{
id: 'delivered',
content: '有回执',
phoneNumber: '13800138000',
billingUnits: 1,
status: 'delivered',
queuedAt: '2026-09-14T06:00:00Z',
receiptStatus: 'delivered',
deliveredAt: '2026-09-14T07:34:41.935Z',
receiptRecords: [{ rawStatus: 'UNDELIV', deliveredAt: '2026-09-13T01:00:00Z' }],
},
{
id: 'pending',
content: '等待回执',
phoneNumber: '13800138001',
billingUnits: 1,
status: 'submitted',
queuedAt: '2026-09-14T06:00:00Z',
receiptStatus: null,
deliveredAt: null,
},
],
total: 2,
});
render(<ClientSendDetailPage />);
expect(await screen.findByText('送达成功')).toBeInTheDocument();
expect(screen.getByText('15:34:41')).toBeInTheDocument();
expect(screen.getByText('暂无回执')).toBeInTheDocument();
expect(screen.queryByText('未送达')).not.toBeInTheDocument();
});
it.each([
{ Component: ClientBatchTasksPage, method: clientApi.listBatchTasksPage, label: '发送批次号', key: 'keyword' },
{ Component: ClientSendDetailPage, method: clientApi.listMessages, label: '短信内容', key: 'contentKeyword' },
{ Component: ClientUplinkMessagesPage, method: clientApi.listUplinkMessagesPage, label: '上行内容', key: 'keyword' },
])('$label only applies filters on Query or Reset, including pagination back to page one', async ({ Component, method, label, key }) => {
render(<MemoryRouter><Component /></MemoryRouter>);
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); });
expect(method).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })));
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() => expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })));
expect(screen.getByLabelText(label)).toHaveValue('');
});
{
Component: ClientUplinkMessagesPage,
method: clientApi.listUplinkMessagesPage,
label: '上行内容',
key: 'keyword',
},
])(
'$label only applies filters on Query or Reset, including pagination back to page one',
async ({ Component, method, label, key }) => {
render(
<MemoryRouter>
<Component />
</MemoryRouter>,
);
await waitFor(() => expect(method).toHaveBeenCalledTimes(1));
fireEvent.change(screen.getByLabelText(label), { target: { value: '待查询' } });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
expect(method).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: undefined })),
);
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
);
fireEvent.change(screen.getByLabelText(label), { target: { value: '未提交' } });
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, [key]: '待查询' })),
);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: '待查询' })),
);
fireEvent.click(screen.getByRole('button', { name: '重置' }));
await waitFor(() =>
expect(method).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, [key]: undefined })),
);
expect(screen.getByLabelText(label)).toHaveValue('');
},
);
});
+126 -77
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Fragment, startTransition, useEffect, useMemo, useState } from 'react';
import { FileText, Search, Smartphone } from 'lucide-react';
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import {
@@ -12,7 +12,7 @@ import {
Tag,
type DateRangeValue,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
const statusLabelMap: Record<string, string> = {
delivered: '成功',
@@ -44,25 +44,34 @@ const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> =
timeout: 'danger',
};
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
}
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
const latest = record.receiptRecords?.[0] as
{ rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
time: latest?.deliveredAt ?? record.deliveredAt,
status: receiptStatusLabel(record.receiptStatus ?? latest?.rawStatus ?? latest?.receiptStatus),
time: record.deliveredAt ?? latest?.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
if (!status) return '-';
if (!status) return '暂无回执';
const normalized = status.trim().toUpperCase();
return {
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
return (
{
DELIVERED: '送达成功',
FAILED: '送达失败',
TIMEOUT: '回执超时',
DELIVRD: '送达成功',
ACCEPTD: '已受理',
UNDELIV: '未送达',
REJECTD: '已拒绝',
EXPIRED: '已过期',
DELETED: '已删除',
UNKNOWN: '状态未知',
}[normalized] ??
statusLabelMap[status.toLowerCase()] ??
'状态未知'
);
}
export function ClientSendDetailPage() {
@@ -81,16 +90,17 @@ export function ClientSendDetailPage() {
function loadData(targetPage = page) {
setLoading(true);
clientApi.listMessages({
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
phoneNumber: applied.phoneKeyword.trim() || undefined,
status: applied.status === 'all' ? undefined : applied.status,
contentKeyword: applied.contentKeyword.trim() || undefined,
queuedAtFrom: applied.dateRange.start || undefined,
queuedAtTo: applied.dateRange.end || undefined,
page: targetPage,
pageSize: 10,
})
clientApi
.listMessages({
applicationId: applied.applicationId === 'all' ? undefined : applied.applicationId,
phoneNumber: applied.phoneKeyword.trim() || undefined,
status: applied.status === 'all' ? undefined : applied.status,
contentKeyword: applied.contentKeyword.trim() || undefined,
queuedAtFrom: applied.dateRange.start || undefined,
queuedAtTo: applied.dateRange.end || undefined,
page: targetPage,
pageSize: 10,
})
.then((result) => {
setRecords(result.items);
setTotal(result.total);
@@ -101,20 +111,18 @@ export function ClientSendDetailPage() {
}
useEffect(() => {
loadData(page);
startTransition(() => loadData(page));
}, [applied, page]);
useEffect(() => {
clientApi.listApplicationOptions()
clientApi
.listApplicationOptions()
.then((items) => setApplications(items.map((item) => ({ id: item.id, name: item.name }))))
.catch((reason: Error) => setError(reason.message || '应用列表加载失败'));
}, []);
const applicationOptions = useMemo(() => {
return [
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
];
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item.name, value: item.id }))];
}, [applications]);
const filteredRows = records;
@@ -129,7 +137,13 @@ export function ClientSendDetailPage() {
}
function reset() {
const defaults = { applicationId: 'all', status: 'all', dateRange: recentBeijingDateRange(7), contentKeyword: '', phoneKeyword: '' };
const defaults = {
applicationId: 'all',
status: 'all',
dateRange: recentBeijingDateRange(7),
contentKeyword: '',
phoneKeyword: '',
};
setApplicationId(defaults.applicationId);
setStatus(defaults.status);
setDateRange(defaults.dateRange);
@@ -150,9 +164,18 @@ export function ClientSendDetailPage() {
<QueryPanel
title="查询条件"
summary={<> <strong>{total}</strong> </>}
summary={
<>
<strong>{total}</strong>
</>
}
>
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
<Select
label="应用名称"
onChange={(event) => setApplicationId(event.target.value)}
options={applicationOptions}
value={applicationId}
/>
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
<Select
label="发送状态"
@@ -202,53 +225,79 @@ export function ClientSendDetailPage() {
</thead>
<tbody>
{loading ? (
<tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
<tr>
<td className="ui-table__empty" colSpan={9}>
...
</td>
</tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={9}></td></tr>
) : visibleRows.map((record) => {
const receipt = getReceipt(record);
const region = record.province ?? '-';
return (
<Fragment key={record.id}>
<tr className="send-detail-main-row">
<td><strong className="send-detail-app-name">{record.application?.name ?? record.applicationId ?? '-'}</strong></td>
<td>
<span className="send-detail-time">
{record.queuedAt.slice(0, 10)}
<small>{record.queuedAt.slice(11, 19)}</small>
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span className="send-detail-count">
<strong>{[...record.content].length}</strong>
<small>{record.billingUnits}</small>
</span>
</td>
<td><strong>{record.phoneNumber}</strong></td>
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
<td><span className="send-detail-region">{region}</span></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
<td>
{receipt.time ? (
<tr>
<td className="ui-table__empty" colSpan={9}>
</td>
</tr>
) : (
visibleRows.map((record) => {
const receipt = getReceipt(record);
const region = record.province ?? '-';
return (
<Fragment key={record.id}>
<tr className="send-detail-main-row">
<td>
<strong className="send-detail-app-name">
{record.application?.name ?? record.applicationId ?? '-'}
</strong>
</td>
<td>
<span className="send-detail-time">
{receipt.time.slice(0, 10)}
<small>{receipt.time.slice(11, 19)}</small>
{formatDateTime(record.queuedAt).slice(0, 10)}
<small>{formatDateTime(record.queuedAt).slice(11, 19)}</small>
</span>
) : <span className="muted">-</span>}
</td>
</tr>
<tr className="send-detail-content-row">
<td colSpan={9}>
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
</div>
</td>
</tr>
</Fragment>
);
})}
</td>
<td style={{ textAlign: 'center' }}>
<span className="send-detail-count">
<strong>{[...record.content].length}</strong>
<small>{record.billingUnits}</small>
</span>
</td>
<td>
<strong>{record.phoneNumber}</strong>
</td>
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
<td>
<span className="send-detail-region">{region}</span>
</td>
<td style={{ textAlign: 'center' }}>
<Tag tone={statusToneMap[record.status] ?? 'info'}>
{statusLabelMap[record.status] ?? '状态未知'}
</Tag>
</td>
<td style={{ textAlign: 'center' }}>
<strong className="send-detail-receipt-code">{receipt.status}</strong>
</td>
<td>
{receipt.time ? (
<span className="send-detail-time">
{formatDateTime(receipt.time).slice(0, 10)}
<small>{formatDateTime(receipt.time).slice(11, 19)}</small>
</span>
) : (
<span className="muted">-</span>
)}
</td>
</tr>
<tr className="send-detail-content-row">
<td colSpan={9}>
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
</div>
</td>
</tr>
</Fragment>
);
})
)}
</tbody>
</table>
</div>
+49
View File
@@ -0,0 +1,49 @@
.client-templates-page .client-template-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-4);
}
.client-templates-page .client-template-content {
height: 132px;
overflow-y: auto;
overflow-wrap: anywhere;
white-space: pre-wrap;
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--color-text-muted);
}
.client-templates-page .client-template-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
border-top: 1px solid var(--color-border);
padding-top: var(--space-4);
margin-top: auto;
}
.client-templates-page .client-template-footer > span {
font-size: 13px;
color: var(--color-text-muted);
}
.client-templates-page .client-template-footer > div {
display: flex;
gap: var(--space-2);
}
@media (width <= 700px) {
.client-templates-page .client-template-toolbar {
grid-template-columns: minmax(0, 1fr);
justify-items: start;
}
.client-templates-page .client-template-toolbar > .ui-field {
width: 100%;
}
}
@@ -0,0 +1,40 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ClientTemplatesPage } from './ClientTemplatesPage';
vi.mock('@/api/adminApi', () => ({
clientApi: {
listTemplatesPage: vi.fn().mockResolvedValue({
items: [
{
id: 't',
name: '测试模板',
content: '【签名】正文',
auditStatus: 'approved',
applicationId: 'a',
signatureId: 's',
variables: [],
updatedAt: '2026-09-14T00:00:00Z',
},
],
total: 1,
}),
listApplicationOptions: vi.fn().mockResolvedValue([{ id: 'a', name: '应用', status: 'active' }]),
listSignatureOptions: vi
.fn()
.mockResolvedValue([{ id: 's', name: '【签名】', applicationId: 'a', auditStatus: 'approved' }]),
},
}));
describe('client template form', () => {
it('orders requested fields and keeps standard deletion action', async () => {
render(<ClientTemplatesPage />);
await screen.findByText('测试模板');
expect(screen.getByRole('button', { name: '删除' })).toHaveClass('ui-button');
fireEvent.click(screen.getByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog');
const labels = Array.from(dialog.querySelectorAll('.ui-field__label')).map((x) =>
x.textContent?.replace(/\*/g, ''),
);
expect(labels.slice(0, 4)).toEqual(['短信应用', '模板名称', '短信签名', '模板内容']);
expect(within(dialog).queryByText('模板分类')).not.toBeInTheDocument();
});
});
+157 -59
View File
@@ -1,9 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { startTransition, useEffect, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
import {
clientApi,
type ClientSmsApplication,
type ClientSmsSignatureView,
type ClientSmsTemplate,
} from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
import './ClientTemplatesPage.css';
type TemplateVariable = {
name: string;
@@ -15,7 +21,6 @@ type TemplateFormState = {
applicationId: string;
signatureId: string;
name: string;
category: string;
content: string;
variables: TemplateVariable[];
};
@@ -49,8 +54,10 @@ const recommendedVariables = [
];
function extractVariables(content: string): TemplateVariable[] {
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
.map((name) => ({ name, required: true }));
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]))).map((name) => ({
name,
required: true,
}));
}
function billingUnits(content: string) {
@@ -77,23 +84,28 @@ function TemplateModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
: (item?.content ?? '');
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
category: item?.category ?? '行业通知',
content: initialContent,
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
variables:
item?.variables?.map((variable) => ({
name: variable.name,
example: variable.example ?? undefined,
required: variable.required ?? true,
})) ?? [],
});
const initialForm = useRef(form).current;
const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const application = applications.find((candidate) => candidate.id === form.applicationId);
const availableSignatures = signatures.filter((signature) => (
signature.auditStatus === 'approved'
&& (!application || signature.tenantId === application.tenantId)
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const availableSignatures = signatures.filter(
(signature) =>
signature.auditStatus === 'approved' &&
(!application || signature.tenantId === application.tenantId) &&
(!signature.applicationId || signature.applicationId === form.applicationId),
);
const variables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
@@ -127,7 +139,10 @@ function TemplateModal({
}
function updateVariableExample(name: string, example: string) {
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
update(
'variables',
variables.map((variable) => (variable.name === name ? { ...variable, example } : variable)),
);
}
return (
@@ -135,8 +150,15 @@ function TemplateModal({
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost"></Button>
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}></Button>
<Button onClick={requestClose} variant="ghost">
</Button>
<Button
disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
onClick={() => onSubmit({ ...form, variables })}
>
</Button>
</>
)}
onClose={onClose}
@@ -144,22 +166,32 @@ function TemplateModal({
size="xl"
title={item ? '编辑短信模板' : '添加短信模板'}
>
<div className="template-form">
<div className="template-form client-template-form">
<Select
label="短信应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
options={[
{ label: '请选择应用', value: '' },
...applications.map((app) => ({ label: app.name, value: app.id })),
]}
value={form.applicationId}
/>
<Input
label="模板名称"
onChange={(event) => update('name', event.target.value)}
placeholder="请输入模板名称"
value={form.name}
/>
<Select
label="短信签名"
onChange={(event) => selectSignature(event.target.value)}
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
options={[
{ label: '请选择签名', value: '' },
...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
]}
required
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
@@ -174,34 +206,53 @@ function TemplateModal({
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
<span>
{form.content.length} {billingUnits(form.content)}
</span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
<h3></h3>
<div className="template-variable-buttons">
{recommendedVariables.map(([label, value]) => (
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
<button key={value} onClick={() => insertVariable(value)} type="button">
{label} ({value})
</button>
))}
</div>
<h3></h3>
<div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button>
<Input
onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable);
setCustomVariable('');
}}
>
</Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{variables.length ? variables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
{variables.length ? (
variables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</Modal>
@@ -225,19 +276,26 @@ export function ClientTemplatesPage() {
function loadData(targetPage = page) {
const sequence = ++requestSequence.current;
setLoading(true);
clientApi.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
clientApi
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
.then((templateResult) => {
if (sequence !== requestSequence.current) return;
setTemplates(templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setTemplates(
templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
);
setTotal(templateResult.total);
setError('');
})
.catch((reason: Error) => { if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败'); })
.finally(() => { if (sequence === requestSequence.current) setLoading(false); });
.catch((reason: Error) => {
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
})
.finally(() => {
if (sequence === requestSequence.current) setLoading(false);
});
}
useEffect(() => {
loadData(page);
startTransition(() => loadData(page));
}, [appliedKeyword, page]);
useEffect(() => {
@@ -251,7 +309,9 @@ export function ClientTemplatesPage() {
.catch((reason: Error) => {
if (!cancelled) setError(reason.message || '模板选项加载失败');
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
const filteredTemplates = templates;
@@ -267,7 +327,6 @@ export function ClientTemplatesPage() {
signatureId: state.signatureId || undefined,
name: state.name,
content: state.content,
category: state.category,
variables: state.variables,
};
const template = existing
@@ -282,7 +341,7 @@ export function ClientTemplatesPage() {
}
return (
<section className="page-stack">
<section className="page-stack client-templates-page">
<div className="template-page-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
@@ -292,7 +351,7 @@ export function ClientTemplatesPage() {
</div>
</div>
<div className="template-toolbar">
<div className="client-template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称、应用、签名或内容"
@@ -300,35 +359,72 @@ export function ClientTemplatesPage() {
value={keyword}
/>
<div className="ui-query-actions">
<Button icon={<Search size={17} />} onClick={() => { setPage(1); setAppliedKeyword(keyword.trim()); }}></Button>
<Button onClick={() => { setKeyword(''); setPage(1); setAppliedKeyword(''); }} variant="ghost"></Button>
<Button
icon={<Search size={17} />}
onClick={() => {
setPage(1);
setAppliedKeyword(keyword.trim());
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setPage(1);
setAppliedKeyword('');
}}
variant="ghost"
>
</Button>
</div>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}></Button>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>
</Button>
</div>
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{visibleTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
const variables =
template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
return (
<article className="template-card template-card--green" key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<p className="template-content">{template.content}</p>
<p className="muted">
{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}
</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>
{statusLabel[template.auditStatus] ?? template.auditStatus}
</Tag>
<p className="client-template-content">{template.content}</p>
<div className="template-vars">
<span></span>
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted"></span>}
{variables.length > 0 ? (
variables.map((item) => <strong key={item}>${`{${item}}`}</strong>)
) : (
<span className="muted"></span>
)}
</div>
<div className="template-card-footer">
<div className="client-template-footer">
<span>{formatDateTime(template.updatedAt)}</span>
<div>
<button onClick={() => setModalTemplate(template)} type="button">
<Edit3 size={14} />
<Button
icon={<Edit3 size={14} />}
onClick={() => setModalTemplate(template)}
size="sm"
variant="ghost"
>
</button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="client"
targetId={template.id}
targetType="template"
/>
</div>
</div>
</article>
@@ -352,7 +448,9 @@ export function ClientTemplatesPage() {
applications={applications}
item={modalTemplate === 'new' ? undefined : modalTemplate}
onClose={() => setModalTemplate(null)}
onSubmit={(state) => { void saveTemplate(state); }}
onSubmit={(state) => {
void saveTemplate(state);
}}
signatures={signatures}
/>
) : null}
@@ -0,0 +1,16 @@
import { Link } from 'react-router-dom';
import './HttpDeveloperDocs.css';
export function ClientHttpDocsPage() {
return (
<section className="page-stack client-http-docs">
<div className="page-heading">
<div>
<h1></h1>
<p></p>
</div>
<Link to="/client/http-api"></Link>
</div>
<iframe className="client-http-docs-reader" title="HTTP接口接入文档" src="/api/client-docs" />
</section>
);
}
+1
View File
@@ -54,6 +54,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
{ label: '签名与引流信息', to: '/client/signatures', icon: PenLine },
{ label: '模板管理', to: '/client/templates', icon: FileText },
{ label: '接口对接', to: '/client/http-api', icon: Cable },
{ label: '接口文档', to: '/client/http-docs', icon: FileText },
],
},
{
+2
View File
@@ -150,6 +150,7 @@ const ClientEnterpriseAuthPage = lazyNamed(
'ClientEnterpriseAuthPage',
);
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
const ClientHttpDocsPage = lazyNamed(() => import('@/apps/client/http-docs/ClientHttpDocsPage'), 'ClientHttpDocsPage');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
@@ -185,6 +186,7 @@ export function AppRoutes() {
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
<Route path="applications" element={<ClientApplicationsPage />} />
<Route path="http-api" element={<ClientHttpApiPage />} />
<Route path="http-docs" element={<ClientHttpDocsPage />} />
<Route path="templates" element={<ClientTemplatesPage />} />
<Route path="signatures" element={<ClientSignaturesPage />} />
<Route path="mms-signatures" element={<PagePlaceholder />} />