fix: polish client templates docs dashboard and receipt display
CSS quality / css-quality (push) Has been cancelled
CSS quality / css-quality (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user