fix: polish channel groups and add production deployment

This commit is contained in:
hectorzhao
2026-07-07 11:16:08 +08:00
parent b5132d7f4e
commit 72f2c010ce
44 changed files with 1265 additions and 489 deletions
+3 -18
View File
@@ -171,19 +171,6 @@ export type RechargeOrder = {
tenant?: TenantOption;
};
export type AccountTransaction = {
id: string;
tenantId: string;
transactionType: string;
amountCents: number;
smsUnits: number;
balanceAfter: number;
relatedType?: string | null;
relatedId?: string | null;
remark?: string | null;
createdAt: string;
};
export type BillingPlan = {
id: string;
name: string;
@@ -339,6 +326,7 @@ export type ChannelGroup = DictionaryItem & {
description?: string | null;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
items?: ChannelGroupItem[];
};
@@ -555,7 +543,6 @@ export const adminApi = {
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
listTransactions: (tenantId?: string) => request<AccountTransaction[]>(withQuery('/admin/billing/transactions', { tenantId })),
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
@@ -644,9 +631,9 @@ export const adminApi = {
body: JSON.stringify({ reason }),
}),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) =>
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) =>
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array<Record<string, unknown>> }) =>
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteChannelGroup: (id: string) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
@@ -758,8 +745,6 @@ export const clientApi = {
}),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<AccountTransaction[]>('/client/billing/transactions', { tenantId }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listPlans: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
-48
View File
@@ -1,48 +0,0 @@
import { useEffect, useState } from 'react';
import { Breadcrumb, Table, Tag, type TableColumn } from '@/components/ui';
import { adminApi, type TenantAccount } from '@/api/adminApi';
const columns: Array<TableColumn<TenantAccount>> = [
{ key: 'id', title: '账户编号', render: (record) => record.id },
{ key: 'name', title: '客户名称', render: (record) => record.tenant?.name ?? record.tenantId },
{ key: 'balance', title: '现金余额', render: (record) => `¥${(record.balanceCents / 100).toLocaleString('zh-CN')}` },
{ key: 'smsUnits', title: '短信余量', render: (record) => `${record.smsUnits.toLocaleString('zh-CN')}` },
{ key: 'creditCents', title: '授信额度', render: (record) => `¥${(record.creditCents / 100).toLocaleString('zh-CN')}` },
{
key: 'status',
title: '账户状态',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
{record.status === 'active' ? '正常' : '已停用'}
</Tag>
),
},
];
export function AdminBillingPage() {
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [error, setError] = useState('');
useEffect(() => {
adminApi.listAccounts()
.then((items) => {
setAccounts(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '账务账户加载失败'));
}, []);
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['账单流水']} />
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface">
<Table columns={columns} data={accounts} emptyText="暂无账户数据" rowKey="id" />
</div>
</section>
);
}
+107 -67
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom';
type ChannelStatus = 'normal' | 'stopped';
@@ -49,7 +49,7 @@ const carrierLabels: Record<Carrier, string> = {
};
const statusLabels: Record<ChannelStatus, string> = {
normal: '通道启用',
normal: '链接正常',
stopped: '通道停用',
};
@@ -67,45 +67,18 @@ function isCarrierCompatible(channelCarrier: string | null | undefined, carrier:
}
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
return channel?.status === 'active' ? 'normal' : 'stopped';
if (channel?.status !== 'active') return 'stopped';
return (channel.connectionStates ?? []).some((connection) =>
connection.status === 'connected'
&& connection.desiredConnections > 0
&& connection.currentConnections > 0
) ? 'normal' : 'stopped';
}
function StatusTag({ status }: { status: ChannelStatus }) {
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
}
function RouteCard({
title,
subtitle,
channel,
status,
onEdit,
onDelete,
}: {
title: string;
subtitle: string;
channel?: AdminChannel;
status: ChannelStatus;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<article className="channel-route-card">
<div>
<strong>{title}</strong>
<span>{subtitle}</span>
</div>
<p>{channel?.name ?? '未命名通道'}</p>
<small>{channel?.sendRegion ?? '全国'} / {channel?.carrier ?? '未标记'}</small>
<StatusTag status={status} />
<footer>
<button onClick={onEdit} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={onDelete} type="button"><Trash2 size={15} /></button>
</footer>
</article>
);
}
function RouteConfigModal({
channels,
carrier,
@@ -135,7 +108,7 @@ function RouteConfigModal({
const channelOptions = [
{ label: '请选择', value: '' },
...selectableChannels.map((channel) => ({
label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
label: `${channel.name}${channel.code} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
value: channel.id,
})),
];
@@ -173,6 +146,7 @@ function RouteConfigModal({
)}
onClose={onClose}
open
size="xl"
title={modal.mode === 'edit' ? '编辑通道' : '添加通道'}
>
<div className="channel-route-modal">
@@ -187,7 +161,7 @@ function RouteConfigModal({
</div>
</>
)}
<Select label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
<Select className="channel-route-modal__channel-select" label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
</div>
</Modal>
);
@@ -201,6 +175,8 @@ export function AdminChannelGroupFormPage() {
const [groupName, setGroupName] = useState('');
const [carrier, setCarrier] = useState<Carrier>('mobile');
const [retryEnabled, setRetryEnabled] = useState(true);
const [retryLimitHours, setRetryLimitHours] = useState('12');
const [retryLimitMinutes, setRetryLimitMinutes] = useState('0');
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
const [modal, setModal] = useState<RouteModalState | null>(null);
@@ -209,11 +185,66 @@ export function AdminChannelGroupFormPage() {
const [error, setError] = useState('');
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
{ key: 'province', title: '省份', width: '160px', render: (route) => route.province },
{
key: 'channel',
title: '通道名称',
width: '280px',
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'province', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
{ key: 'priority', title: '优先级', width: '140px', render: (route) => route.priority },
{
key: 'channel',
title: '通道名称',
width: '280px',
render: (route) => channelById.get(route.channelId)?.name ?? '未命名通道',
},
{
key: 'status',
title: '通道状态',
width: '140px',
render: (route) => <StatusTag status={getChannelStatus(channelById.get(route.channelId))} />,
},
{
key: 'actions',
title: '操作',
width: '160px',
render: (route) => (
<div className="channel-group-row-actions">
<button onClick={() => setModal({ type: 'national', mode: 'edit', route })} type="button"><Pencil size={15} /></button>
<button className="is-danger" onClick={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))} type="button"><Trash2 size={15} /></button>
</div>
),
},
], [channelById]);
function applyGroup(group: ChannelGroup) {
setGroupName(group.name);
setCarrier(group.carrier);
setRetryEnabled(group.retryEnabled ?? true);
const retryMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
setRetryLimitHours(String(Math.floor(retryMinutes / 60)));
setRetryLimitMinutes(String(retryMinutes % 60));
setProvinceRoutes((group.items ?? [])
.filter((item) => item.province)
.map((item) => ({
@@ -294,12 +325,24 @@ export function AdminChannelGroupFormPage() {
setError('请输入通道组名称');
return;
}
const retryHours = Number(retryLimitHours);
const retryMinutes = Number(retryLimitMinutes);
if (!Number.isInteger(retryHours) || retryHours < 0 || retryHours > 72 || !Number.isInteger(retryMinutes) || retryMinutes < 0 || retryMinutes > 59) {
setError('补发时间上限需为 0 到 72 小时、0 到 59 分钟的整数');
return;
}
const retryTimeLimitMinutes = retryHours * 60 + retryMinutes;
if (retryTimeLimitMinutes < 1 || retryTimeLimitMinutes > 72 * 60) {
setError('补发时间上限需大于 0 分钟且不超过 72 小时');
return;
}
const payload = {
name: groupName.trim(),
carrier,
status: 'active',
retryEnabled,
retryTimeLimitHours: 72,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
items: buildItems(),
};
setSaving(true);
@@ -312,7 +355,8 @@ export function AdminChannelGroupFormPage() {
carrier,
status: 'active',
retryEnabled,
retryTimeLimitHours: 72,
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
retryTimeLimitMinutes,
}).then((group) => adminApi.updateChannelGroup(group.id, payload));
request
@@ -351,25 +395,34 @@ export function AdminChannelGroupFormPage() {
<i />
</button>
</div>
<div className="channel-group-retry-limit">
<span></span>
<Input
disabled={!retryEnabled}
max="72"
min="0"
onChange={(event) => setRetryLimitHours(event.target.value)}
suffix="小时"
type="number"
value={retryLimitHours}
/>
<Input
disabled={!retryEnabled}
max="59"
min="0"
onChange={(event) => setRetryLimitMinutes(event.target.value)}
suffix="分钟"
type="number"
value={retryLimitMinutes}
/>
<small> 1 72 12 0 </small>
</div>
</div>
</section>
<section className="surface channel-group-form-section">
<h2></h2>
<div className="channel-route-card-grid">
{provinceRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => setProvinceRoutes((current) => current.filter((item) => item.id !== route.id))}
onEdit={() => setModal({ type: 'province', mode: 'edit', route })}
status={route.status}
subtitle="省网优先路由"
title={route.province}
/>
))}
{provinceRoutes.length === 0 ? <p className="channel-route-empty"></p> : null}
</div>
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" pagination={false} rowKey="id" />
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
</Button>
@@ -377,20 +430,7 @@ export function AdminChannelGroupFormPage() {
<section className="surface channel-group-form-section">
<h2></h2>
<div className="channel-route-card-grid">
{nationalRoutes.map((route) => (
<RouteCard
key={route.id}
channel={channelById.get(route.channelId)}
onDelete={() => setNationalRoutes((current) => current.filter((item) => item.id !== route.id))}
onEdit={() => setModal({ type: 'national', mode: 'edit', route })}
status={route.status}
subtitle="全国补发路由"
title={`优先级 ${route.priority}`}
/>
))}
{nationalRoutes.length === 0 ? <p className="channel-route-empty"></p> : null}
</div>
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" pagination={false} rowKey="id" />
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
</Button>
+95 -30
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Layers3, Pencil, Plus, Search, Trash2, UsersRound } from 'lucide-react';
import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui';
import { adminApi, type ChannelGroup } from '@/api/adminApi';
type GroupCarrier = 'mobile' | 'unicom' | 'telecom';
@@ -12,12 +12,32 @@ const carrierLabels: Record<GroupCarrier, string> = {
telecom: '电信',
};
function formatRetryLimit(group: ChannelGroup) {
if (group.retryEnabled === false) return '已关闭';
const totalMinutes = group.retryTimeLimitMinutes ?? (group.retryTimeLimitHours ?? 12) * 60;
return `${Math.floor(totalMinutes / 60)}小时${totalMinutes % 60}分钟`;
}
function getGroupSummary(group: ChannelGroup) {
const items = group.items ?? [];
const provinceCount = items.filter((item) => item.province).length;
const nationalCount = items.length - provinceCount;
const connectedCount = items.filter((item) => (item.channel?.connectionStates ?? []).some((connection) =>
connection.status === 'connected'
&& connection.desiredConnections > 0
&& connection.currentConnections > 0
)).length;
return { connectedCount, nationalCount, provinceCount, totalCount: items.length };
}
export function AdminChannelGroupsPage() {
const navigate = useNavigate();
const [groupName, setGroupName] = useState('');
const [groups, setGroups] = useState<ChannelGroup[]>([]);
const [deleteTarget, setDeleteTarget] = useState<ChannelGroup | null>(null);
const [page, setPage] = useState(1);
const [error, setError] = useState('');
const pageSize = 10;
function loadData() {
adminApi.listChannelGroups()
@@ -33,6 +53,13 @@ export function AdminChannelGroupsPage() {
}, []);
const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]);
const totalPages = Math.max(1, Math.ceil(filteredGroups.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleGroups = filteredGroups.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [groupName, groups.length]);
function deleteGroup() {
if (!deleteTarget) return;
@@ -65,36 +92,74 @@ export function AdminChannelGroupsPage() {
</section>
<section className="surface channel-group-list">
<div className="channel-group-grid">
{filteredGroups.map((group) => (
<article className="channel-group-card" key={group.id}>
<header>
<div>
<Layers3 size={18} />
<strong>{group.name}</strong>
<div className="channel-group-config-list">
{visibleGroups.map((group) => {
const summary = getGroupSummary(group);
const previewItems = (group.items ?? []).slice(0, 4);
return (
<article className="channel-group-config-item" key={group.id}>
<div className="channel-group-config-item__identity">
<span className="channel-group-config-item__icon"><Layers3 size={18} /></span>
<div>
<strong>{group.name}</strong>
<span>{carrierLabels[group.carrier] ?? group.carrier}</span>
</div>
</div>
<span title="运营商">{carrierLabels[group.carrier] ?? group.carrier}</span>
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
</header>
<div className="channel-group-card__body">
{(group.items ?? []).slice(0, 5).map((item, index) => {
const channel = item.channel as { name?: string } | undefined;
return <p key={`${group.id}-${index}`}>{channel?.name ?? '未命名通道'}</p>;
})}
{(group.items?.length ?? 0) === 0 ? <p className="muted"></p> : null}
</div>
<footer>
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} />
</button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<Trash2 size={15} />
</button>
</footer>
</article>
))}
<div className="channel-group-config-item__metrics" aria-label="通道组配置摘要">
<div>
<span></span>
<strong>{summary.provinceCount}</strong>
</div>
<div>
<span></span>
<strong>{summary.nationalCount}</strong>
</div>
<div>
<span></span>
<strong>{summary.connectedCount}/{summary.totalCount}</strong>
</div>
</div>
<div className="channel-group-config-item__policy">
<Tag tone={group.retryEnabled === false ? 'neutral' : 'success'}>
{group.retryEnabled === false ? '补发关闭' : '补发开启'}
</Tag>
<span><Clock3 size={15} />{formatRetryLimit(group)}</span>
<span><RadioTower size={15} />{summary.totalCount ? `${summary.totalCount} 个通道` : '暂无通道'}</span>
</div>
<div className="channel-group-config-item__channels">
{previewItems.map((item) => (
<span key={item.id}>
{item.province ? `${item.province} / ` : `P${item.priority} / `}
{item.channel?.name ?? '未命名通道'}
</span>
))}
{summary.totalCount > previewItems.length ? <span>+{summary.totalCount - previewItems.length}</span> : null}
{summary.totalCount === 0 ? <span></span> : null}
</div>
<div className="channel-group-config-item__actions">
<button onClick={() => navigate(`/admin/channel-groups/${group.id}/edit`)} type="button">
<Pencil size={15} />
</button>
<button className="is-danger" onClick={() => setDeleteTarget(group)} type="button">
<Trash2 size={15} />
</button>
</div>
</article>
);
})}
</div>
<Pagination nextDisabled={false} page={1} total={filteredGroups.length} />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredGroups.length}
/>
</section>
<Modal
+19 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Copy, Eye, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
@@ -362,6 +362,8 @@ export function AdminChannelsPage() {
const [testChannel, setTestChannel] = useState<SmsChannel | null>(null);
const [confirmAction, setConfirmAction] = useState<ChannelConfirmAction | null>(null);
const [logState, setLogState] = useState<ChannelLogState | null>(null);
const [page, setPage] = useState(1);
const pageSize = 10;
function loadChannels() {
adminApi.listChannels()
@@ -389,6 +391,13 @@ export function AdminChannelsPage() {
}),
[carrier, channels, keyword, status],
);
const totalPages = Math.max(1, Math.ceil(filteredChannels.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleChannels = filteredChannels.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [carrier, channels.length, keyword, status]);
async function upsertChannel(nextChannel: SmsChannel) {
try {
@@ -495,7 +504,7 @@ export function AdminChannelsPage() {
<span></span>
<span></span>
</div>
{filteredChannels.map((channel) => (
{visibleChannels.map((channel) => (
<article className="sms-channel-table__row" key={channel.id}>
<div className="sms-channel-identity">
<strong>{channel.name}</strong>
@@ -531,13 +540,14 @@ export function AdminChannelsPage() {
</div>
</article>
))}
<div className="sms-channel-pagination">
<Select options={[{ label: '10 条/页', value: '10' }, { label: '20 条/页', value: '20' }]} value="10" />
<Button disabled size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">1</Button>
<Button size="sm" variant="ghost">2</Button>
<Button size="sm" variant="ghost"></Button>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredChannels.length}
/>
</div>
{modal ? (
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
@@ -510,6 +510,7 @@ export function AdminEnterpriseSignaturesPage() {
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData() {
try {
@@ -537,6 +538,14 @@ export function AdminEnterpriseSignaturesPage() {
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
}), [enterpriseKeyword, signatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [enterpriseKeyword, filteredSignatures.length, signatureKeyword]);
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -609,7 +618,7 @@ export function AdminEnterpriseSignaturesPage() {
const smsSignatureContent = (
<div className="signature-list admin-enterprise-signature-list">
{filteredSignatures.map((signature) => {
{visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const expanded = expandedSignatureId === signature.id;
return (
@@ -672,6 +681,14 @@ export function AdminEnterpriseSignaturesPage() {
</article>
);
})}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
{filteredSignatures.length === 0 ? <div className="ui-table__empty"></div> : null}
</div>
);
+1 -1
View File
@@ -261,7 +261,7 @@ export function AdminHome() {
footer={(
<>
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost"></Button>
<Button onClick={() => navigate('/admin/billing')}></Button>
<Button onClick={() => navigate('/admin/recharge-records')}></Button>
</>
)}
onClose={() => setSelectedEnterprise(null)}
+9 -23
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
type PhoneSegment = DictionaryItem & {
@@ -123,25 +123,6 @@ export function AdminPhoneSegmentsPage() {
</section>
</div>
<div className="surface phone-segment-tabs">
<button className={activeTab === 'segments' ? 'is-active' : ''} onClick={() => setActiveTab('segments')} type="button">
<Smartphone size={18} />
<span>
<strong></strong>
<small> 7 </small>
</span>
<em>{segments.length}</em>
</button>
<button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setActiveTab('rules')} type="button">
<RadioTower size={18} />
<span>
<strong></strong>
<small></small>
</span>
<em>{rules.length}</em>
</button>
</div>
<div className="surface admin-system-toolbar phone-segment-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
@@ -150,9 +131,14 @@ export function AdminPhoneSegmentsPage() {
</div>
<div className="surface admin-system-table-card">
{activeTab === 'segments'
? <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" />
: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" />}
<Tabs
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
value={activeTab}
items={[
{ label: `手机号段 ${segments.length}`, value: 'segments', content: <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> },
{ label: `运营商区分规则 ${rules.length}`, value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
]}
/>
</div>
<Modal
+23 -22
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type AccountTransaction, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
type ManualRechargeForm = {
tenantId: string;
@@ -37,12 +37,12 @@ function RemarkCell({ value }: { value?: string }) {
export function AdminRechargeRecordsPage() {
const [records, setRecords] = useState<RechargeOrder[]>([]);
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [manualOpen, setManualOpen] = useState(false);
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', smsUnits: '0', operator: '运营', remark: '' });
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -50,16 +50,14 @@ export function AdminRechargeRecordsPage() {
setLoading(true);
setError('');
try {
const [nextTenants, nextRecords, nextAccounts, nextTransactions] = await Promise.all([
const [nextTenants, nextRecords, nextAccounts] = await Promise.all([
adminApi.listTenants(),
adminApi.listManualRecharges(),
adminApi.listAccounts(),
adminApi.listTransactions(),
]);
setTenants(nextTenants);
setRecords(nextRecords);
setAccounts(nextAccounts);
setTransactions(nextTransactions);
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
} catch (err) {
setError(err instanceof Error ? err.message : '充值记录加载失败');
@@ -84,6 +82,14 @@ export function AdminRechargeRecordsPage() {
}),
[dateRange.end, dateRange.start, enterpriseKeyword, records, tenants],
);
const pageSize = 10;
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);
useEffect(() => {
setPage(1);
}, [dateRange.end, dateRange.start, enterpriseKeyword, records.length]);
function resetFilters() {
setEnterpriseKeyword('');
@@ -151,16 +157,15 @@ export function AdminRechargeRecordsPage() {
<tr><td className="ui-table__empty" colSpan={7}>...</td></tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={7}></td></tr>
) : filteredRows.map((record) => {
) : visibleRows.map((record) => {
const account = accounts.find((item) => item.tenantId === record.tenantId);
const transaction = transactions.find((item) => item.relatedId === record.id);
const tenantName = record.tenant?.name ?? tenants.find((tenant) => tenant.id === record.tenantId)?.name ?? record.tenantId;
return (
<tr key={record.id}>
<td><strong>{tenantName}</strong></td>
<td>{new Date(record.paidAt ?? record.createdAt).toLocaleString('zh-CN')}</td>
<td>{formatAmount(record.amountCents / 100)}</td>
<td>{formatAmount((transaction?.balanceAfter ?? account?.balanceCents ?? 0) / 100)}</td>
<td>{formatAmount((account?.balanceCents ?? 0) / 100)}</td>
<td><Tag tone="warning"></Tag></td>
<td>{record.operatorId || '运营'}</td>
<td><RemarkCell value={record.remark ?? undefined} /></td>
@@ -171,18 +176,14 @@ export function AdminRechargeRecordsPage() {
</table>
</div>
<div className="admin-recharge-pagination">
<Select options={[{ label: '10条/页', value: '10' }, { label: '20条/页', value: '20' }]} value="10" />
<div>
<Button icon={<ChevronLeft size={16} />} iconOnly variant="ghost"></Button>
<Button size="sm" variant="ghost">24</Button>
<Button size="sm">25</Button>
<Button size="sm" variant="ghost">26</Button>
<span>...</span>
<Button size="sm" variant="ghost">63</Button>
<Button icon={<ChevronRight size={16} />} iconOnly variant="ghost"></Button>
</div>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
/>
</div>
{manualOpen ? (
+1 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
@@ -104,7 +104,6 @@ export function AdminSmsRecordsPage() {
<div className="surface">
<Table columns={columns} data={filteredRows} emptyText="暂无短信记录" rowKey="id" />
<Pagination total={filteredRows.length} />
</div>
<Modal
+18 -2
View File
@@ -329,6 +329,7 @@ export function AdminSmsTaskProgressPage() {
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
const [loading, setLoading] = useState(true);
@@ -371,6 +372,14 @@ export function AdminSmsTaskProgressPage() {
}),
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks],
);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
function resetFilters() {
setKeyword('');
@@ -439,7 +448,7 @@ export function AdminSmsTaskProgressPage() {
<tr><td className="ui-table__empty" colSpan={8}>...</td></tr>
) : filteredTasks.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={8}></td></tr>
) : filteredTasks.map((record) => {
) : visibleTasks.map((record) => {
const progress = getProgress(record);
const { signature, content } = splitSignature(record.templateContent);
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
@@ -518,7 +527,14 @@ export function AdminSmsTaskProgressPage() {
</tbody>
</table>
</div>
<Pagination total={filteredTasks.length} />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
</div>
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
@@ -7,7 +7,6 @@ import {
DateRangeInput,
Input,
Modal,
Pagination,
Table,
type DateRangeValue,
type TableColumn,
@@ -225,7 +224,6 @@ export function AdminSmsUplinkRecordsPage() {
<div className="surface admin-uplink-table-card">
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
<Pagination total={filteredMessages.length} />
</div>
{selectedMessage ? (
+1 -1
View File
@@ -127,7 +127,7 @@ export function AdminSystemLogsPage() {
</div>
<div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
+19 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { ClipboardCopy, FileText } from 'lucide-react';
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
import { Button, Modal, Tag } from '@/components/ui';
import { Button, Modal, Pagination, Tag } from '@/components/ui';
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
@@ -61,6 +61,7 @@ export function ClientApplicationsPage() {
const [params, setParams] = useState<ApplicationCmppParams | null>(null);
const [loading, setLoading] = useState(true);
const [paramsLoading, setParamsLoading] = useState(false);
const [page, setPage] = useState(1);
const [error, setError] = useState('');
const [paramsError, setParamsError] = useState('');
const [copied, setCopied] = useState(false);
@@ -96,6 +97,14 @@ export function ClientApplicationsPage() {
}
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(applications.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleApplications = applications.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [applications.length]);
function copyParams() {
if (selectedRows.length === 0) {
@@ -123,7 +132,7 @@ export function ClientApplicationsPage() {
) : null}
<div className="sms-app-grid">
{applications.map((application) => {
{visibleApplications.map((application) => {
const linkStatus = normalizeStatus(application);
return (
<article className="sms-app-card" key={application.id}>
@@ -155,6 +164,14 @@ export function ClientApplicationsPage() {
);
})}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={applications.length}
/>
<Modal
footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>}
+18 -2
View File
@@ -103,6 +103,7 @@ export function ClientBatchTasksPage() {
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
function loadTasks() {
@@ -136,6 +137,14 @@ export function ClientBatchTasksPage() {
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
});
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTasks.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTasks = filteredTasks.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [application, keyword, submittedDateRange.end, submittedDateRange.start, tasks.length]);
function terminateTask(id: string) {
const source = tasks.find((item) => item.id === id);
@@ -253,7 +262,7 @@ export function ClientBatchTasksPage() {
</tr>
</thead>
<tbody>
{filteredTasks.map((record, index) => {
{visibleTasks.map((record, index) => {
const { signature, content } = splitSignature(record.templateContent);
return (
@@ -284,7 +293,14 @@ export function ClientBatchTasksPage() {
</tbody>
</table>
</div>
<Pagination total={filteredTasks.length} />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTasks.length}
/>
</div>
<Modal
+19 -2
View File
@@ -1,12 +1,17 @@
import { useEffect, useState } from 'react';
import { CreditCard } from 'lucide-react';
import { Button, Tag } from '@/components/ui';
import { Button, Pagination, Tag } from '@/components/ui';
import { clientApi, type BillingPlan } from '@/api/adminApi';
export function ClientBillingPage() {
const [plans, setPlans] = useState<BillingPlan[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(plans.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visiblePlans = plans.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setLoading(true);
@@ -19,6 +24,10 @@ export function ClientBillingPage() {
.finally(() => setLoading(false));
}, []);
useEffect(() => {
setPage(1);
}, [plans.length]);
function createOrder(plan: BillingPlan) {
clientApi.createOrder({ planId: plan.id, amountCents: plan.amountCents, smsUnits: plan.smsUnits, payMethod: 'manual' })
.catch((reason: Error) => setError(reason.message || '充值订单创建失败'));
@@ -36,7 +45,7 @@ export function ClientBillingPage() {
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="plan-grid">
{plans.map((plan) => (
{visiblePlans.map((plan) => (
<article className={['plan-card', plan.smsUnits >= 100000 ? 'plan-card--highlight' : ''].filter(Boolean).join(' ')} key={plan.id}>
<div className="section-heading">
<h2>{plan.name}</h2>
@@ -50,6 +59,14 @@ export function ClientBillingPage() {
</article>
))}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={plans.length}
/>
{!loading && !error && plans.length === 0 ? <p className="muted"></p> : null}
</div>
</section>
-87
View File
@@ -1,87 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Table, Tag, type TableColumn } from '@/components/ui';
import { clientApi, type AccountTransaction, type RechargeOrder } from '@/api/adminApi';
type Invoice = {
id: string;
title: string;
messages: number;
amount: number;
createdAt: string;
status: 'paid' | 'pending' | 'failed';
};
const statusToneMap: Record<Invoice['status'], 'success' | 'info' | 'danger'> = {
paid: 'success',
pending: 'info',
failed: 'danger',
};
const statusLabelMap: Record<Invoice['status'], string> = {
paid: '已支付',
pending: '处理中',
failed: '支付失败',
};
const columns: Array<TableColumn<Invoice>> = [
{ key: 'id', title: '流水号', render: (record) => record.id },
{ key: 'title', title: '项目', render: (record) => record.title },
{ key: 'messages', title: '短信条数', render: (record) => `${record.messages.toLocaleString('zh-CN')}` },
{ key: 'amount', title: '金额', render: (record) => `¥${record.amount.toLocaleString('zh-CN')}` },
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
];
export function ClientInvoicesPage() {
const [orders, setOrders] = useState<RechargeOrder[]>([]);
const [transactions, setTransactions] = useState<AccountTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
Promise.all([clientApi.listOrders(), clientApi.listTransactions()])
.then(([orderItems, transactionItems]) => {
setOrders(orderItems);
setTransactions(transactionItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '账单流水加载失败'))
.finally(() => setLoading(false));
}, []);
const rows = useMemo<Invoice[]>(() => [
...orders.map((item) => ({
id: item.orderNo,
title: item.payMethod === 'manual_topup' ? '人工充值' : '充值订单',
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: item.status === 'paid' ? 'paid' as const : item.status === 'failed' ? 'failed' as const : 'pending' as const,
})),
...transactions.map((item) => ({
id: item.id,
title: item.remark ?? item.transactionType,
messages: item.smsUnits,
amount: item.amountCents / 100,
createdAt: item.createdAt,
status: 'paid' as const,
})),
].sort((left, right) => right.createdAt.localeCompare(left.createdAt)), [orders, transactions]);
return (
<section className="page-stack">
<div className="page-heading">
<div>
<p className="eyebrow"></p>
<h1></h1>
</div>
</div>
<div className="surface">
{loading ? <p className="muted">...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<Table columns={columns} data={rows} emptyText="暂无账单流水" rowKey="id" />
</div>
</section>
);
}
+19 -1
View File
@@ -4,6 +4,7 @@ import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
import {
DateRangeInput,
Input,
Pagination,
QueryPanel,
Select,
Tag,
@@ -58,6 +59,7 @@ export function ClientSendDetailPage() {
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -100,6 +102,14 @@ export function ClientSendDetailPage() {
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
return matchesStartDate && matchesEndDate && matchesContent;
});
const pageSize = 10;
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);
useEffect(() => {
setPage(1);
}, [contentKeyword, dateRange.end, dateRange.start, records.length]);
return (
<section className="page-stack">
@@ -166,7 +176,7 @@ export function ClientSendDetailPage() {
<tr><td className="ui-table__empty" colSpan={9}>...</td></tr>
) : filteredRows.length === 0 ? (
<tr><td className="ui-table__empty" colSpan={9}></td></tr>
) : filteredRows.map((record) => {
) : visibleRows.map((record) => {
const receipt = getReceipt(record);
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
const region = record.channel?.sendRegion ?? '-';
@@ -214,6 +224,14 @@ export function ClientSendDetailPage() {
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredRows.length}
/>
</div>
</section>
);
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, FileActions, Input, Modal, Select, Tag } from '@/components/ui';
import { Button, FileActions, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
@@ -37,6 +37,7 @@ export function ClientSignaturesPage() {
const [name, setName] = useState('');
const [purpose, setPurpose] = useState('');
const [file, setFile] = useState<File | null>(null);
const [page, setPage] = useState(1);
function loadData() {
setLoading(true);
@@ -57,6 +58,14 @@ export function ClientSignaturesPage() {
const filteredSignatures = useMemo(() => signatures.filter((item) => (
!keyword || [item.name, item.purpose, item.applicationId].join(' ').includes(keyword)
)), [keyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleSignatures = filteredSignatures.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredSignatures.length, keyword]);
async function createSignature() {
try {
@@ -114,7 +123,7 @@ export function ClientSignaturesPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="signature-list">
{filteredSignatures.map((signature) => (
{visibleSignatures.map((signature) => (
<article className="signature-card signature-card--green" key={signature.id}>
<div className="signature-summary">
<div>
@@ -143,6 +152,14 @@ export function ClientSignaturesPage() {
</article>
))}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredSignatures.length}
/>
{!loading && !error && filteredSignatures.length === 0 ? <p className="muted"></p> : null}
<Modal
+1 -1
View File
@@ -121,7 +121,7 @@ export function ClientSystemLogsPage() {
</div>
<div className="surface system-table-card">
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} rowKey="id" />
<Table columns={columns} data={logs} emptyText={error || '暂无系统日志'} pagination={false} rowKey="id" />
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
+19 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
type TemplateVariable = {
@@ -180,6 +180,7 @@ export function ClientTemplatesPage() {
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
const [page, setPage] = useState(1);
function loadData() {
setLoading(true);
@@ -201,6 +202,14 @@ export function ClientTemplatesPage() {
const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
)), [keyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [filteredTemplates.length, keyword]);
async function saveTemplate(state: TemplateFormState) {
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
@@ -254,7 +263,7 @@ export function ClientTemplatesPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{filteredTemplates.map((template) => {
{visibleTemplates.map((template) => {
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}>
@@ -283,6 +292,14 @@ export function ClientTemplatesPage() {
);
})}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
/>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted"></p> : null}
{modalTemplate ? (
+1 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
import { readSession } from '@/api/session';
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
type UserForm = {
displayName: string;
@@ -154,7 +154,6 @@ export function ClientUsersPage() {
{error ? <div className="surface empty-state">{error}</div> : null}
<div className="surface system-table-card">
<Table columns={columns} data={filteredUsers} emptyText="暂无用户" rowKey="id" />
<Pagination total={filteredUsers.length} page={1} />
</div>
{(creating || editingUser) ? (
-2
View File
@@ -5,7 +5,6 @@ const pageTitleMap: Record<string, string> = {
'/client/templates': '模板管理',
'/client/signatures': '签名与引流信息',
'/client/billing': '充值套餐',
'/client/invoices': '账单流水',
'/client/settings': '账号设置',
'/admin/monitor': '发送监控',
'/admin/analytics': '数据统计',
@@ -13,7 +12,6 @@ const pageTitleMap: Record<string, string> = {
'/admin/templates': '模板审核',
'/admin/signatures': '签名审核',
'/admin/channels': '通道管理',
'/admin/billing': '账单流水',
'/admin/settings': '系统配置',
};
+28 -3
View File
@@ -1,4 +1,6 @@
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Pagination } from './PagePrimitives';
export type TableColumn<T> = {
key: string;
@@ -13,13 +15,26 @@ type TableProps<T> = {
data: T[];
rowKey: keyof T | ((record: T) => string);
emptyText?: string;
pagination?: boolean;
pageSize?: number;
};
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) {
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据', pageSize = 10, pagination = true }: TableProps<T>) {
const [page, setPage] = useState(1);
const minimumTableWidth = columns.reduce((sum, column) => {
const match = column.width?.match(/^(\d+)px$/);
return sum + (match ? Number(match[1]) : 0);
}, 0);
const totalPages = Math.max(1, Math.ceil(data.length / pageSize));
const activePage = Math.min(page, totalPages);
const visibleData = useMemo(
() => pagination ? data.slice((activePage - 1) * pageSize, activePage * pageSize) : data,
[activePage, data, pageSize, pagination],
);
useEffect(() => {
setPage(1);
}, [data, pageSize]);
function getRowKey(record: T) {
if (typeof rowKey === 'function') {
@@ -50,14 +65,14 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
</tr>
</thead>
<tbody>
{data.length === 0 ? (
{visibleData.length === 0 ? (
<tr>
<td className="ui-table__empty" colSpan={columns.length}>
{emptyText}
</td>
</tr>
) : (
data.map((record, index) => (
visibleData.map((record, index) => (
<tr key={getRowKey(record)}>
{columns.map((column) => (
<td
@@ -72,6 +87,16 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
)}
</tbody>
</table>
{pagination && data.length > pageSize ? (
<Pagination
nextDisabled={activePage >= totalPages}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
page={activePage}
previousDisabled={activePage <= 1}
total={data.length}
/>
) : null}
</div>
);
}
-1
View File
@@ -135,7 +135,6 @@ export function AdminLayout() {
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true },
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
{ label: '账单流水', to: '/admin/billing', icon: ReceiptText },
],
},
{
-1
View File
@@ -65,7 +65,6 @@ export function ClientLayout() {
title: '账户',
items: [
{ label: '充值套餐', to: '/client/billing', icon: BadgeDollarSign },
{ label: '账单流水', to: '/client/invoices', icon: ReceiptText },
],
},
{
-4
View File
@@ -1,6 +1,5 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage';
import { AdminBillingPage } from '@/apps/admin/AdminBillingPage';
import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage';
import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage';
import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage';
@@ -35,7 +34,6 @@ import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
import { ClientHome } from '@/apps/client/ClientHome';
import { ClientInvoicesPage } from '@/apps/client/ClientInvoicesPage';
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
import { ClientSendPage } from '@/apps/client/ClientSendPage';
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
@@ -70,7 +68,6 @@ export function AppRoutes() {
<Route path="mms-send-detail" element={<PagePlaceholder />} />
<Route path="mms-uplink-messages" element={<PagePlaceholder />} />
<Route path="billing" element={<ClientBillingPage />} />
<Route path="invoices" element={<ClientInvoicesPage />} />
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
<Route path="users" element={<ClientUsersPage />} />
<Route path="system-logs" element={<ClientSystemLogsPage />} />
@@ -120,7 +117,6 @@ export function AppRoutes() {
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="billing" element={<AdminBillingPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
</Routes>
+158 -82
View File
@@ -6828,105 +6828,141 @@ h3 {
gap: var(--space-5);
}
.channel-group-grid {
.channel-group-config-list {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-3);
}
.channel-group-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
display: grid;
grid-template-rows: auto minmax(210px, 1fr) auto;
min-width: 0;
overflow: hidden;
}
.channel-group-card header {
align-items: center;
background: #e8f2ff;
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
padding: var(--space-4);
}
.channel-group-card header > div,
.channel-group-card header > span {
align-items: center;
display: inline-flex;
gap: var(--space-2);
min-width: 0;
}
.channel-group-card header strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-card header > span {
color: var(--color-text-muted);
flex: 0 0 auto;
font-weight: var(--font-weight-semibold);
}
.channel-group-card__body {
color: var(--color-text-muted);
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.channel-group-card__body p {
line-height: 1.55;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-card__body small {
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
}
.channel-group-card footer {
align-items: center;
background: var(--color-surface-subtle);
border-top: 1px solid var(--color-border);
display: flex;
gap: var(--space-4);
justify-content: flex-end;
padding: var(--space-3) var(--space-4);
}
.channel-group-card footer button {
.channel-group-config-item {
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1.15fr) minmax(210px, 0.9fr) minmax(200px, 0.95fr) minmax(220px, 1.2fr) minmax(82px, auto);
min-width: 0;
padding: var(--space-4);
}
.channel-group-config-item__identity {
align-items: center;
display: flex;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__icon {
align-items: center;
background: #eef5ff;
border: 1px solid #cfe2ff;
border-radius: var(--radius-sm);
color: var(--color-selected);
display: inline-flex;
flex: 0 0 auto;
height: 38px;
justify-content: center;
width: 38px;
}
.channel-group-config-item__identity div {
display: grid;
gap: var(--space-1);
min-width: 0;
}
.channel-group-config-item__identity strong {
color: var(--color-text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__identity span:last-child,
.channel-group-config-item__metrics span,
.channel-group-config-item__policy span,
.channel-group-config-item__channels span {
color: var(--color-text-muted);
}
.channel-group-config-item__metrics {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.channel-group-config-item__metrics div {
border-left: 1px solid var(--color-border);
display: grid;
gap: var(--space-1);
padding-left: var(--space-3);
}
.channel-group-config-item__metrics strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.channel-group-config-item__policy {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
min-width: 0;
}
.channel-group-config-item__policy span {
align-items: center;
display: inline-flex;
gap: var(--space-1);
white-space: nowrap;
}
.channel-group-config-item__channels {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
min-width: 0;
}
.channel-group-config-item__channels span {
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
max-width: 160px;
overflow: hidden;
padding: var(--space-1) var(--space-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-group-config-item__actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
justify-content: flex-end;
justify-self: end;
min-width: 82px;
}
.channel-group-config-item__actions button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
min-height: 32px;
padding: 0 var(--space-3);
padding: 0;
}
.channel-group-card footer button.is-danger {
.channel-group-config-item__actions button.is-danger {
color: var(--color-danger);
}
.channel-group-card footer button:hover {
background: var(--color-accent-soft);
border-color: currentColor;
}
.channel-group-form-page {
min-width: 1040px;
}
@@ -6950,6 +6986,7 @@ h3 {
}
.channel-group-base-form > .ui-field,
.channel-group-retry-limit,
.channel-group-radio-row,
.channel-group-switch-row {
grid-column: 2;
@@ -7010,11 +7047,14 @@ h3 {
}
.channel-group-row-actions button {
align-items: center;
background: transparent;
border: 0;
color: var(--color-selected);
cursor: pointer;
display: inline-flex;
font-weight: var(--font-weight-semibold);
gap: var(--space-1);
padding: 0;
}
@@ -7022,6 +7062,23 @@ h3 {
color: var(--color-danger);
}
.channel-group-retry-limit {
display: grid;
gap: var(--space-3);
grid-template-columns: 150px 150px minmax(220px, 1fr);
}
.channel-group-retry-limit > span {
color: var(--color-text-strong);
font-weight: var(--font-weight-semibold);
grid-column: 1 / -1;
}
.channel-group-retry-limit small {
align-self: center;
color: var(--color-text-muted);
}
.channel-route-card-grid {
display: grid;
gap: var(--space-4);
@@ -7104,6 +7161,25 @@ h3 {
.channel-route-modal {
display: grid;
gap: var(--space-5);
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
.channel-route-modal__channel-select {
grid-column: 1 / -1;
}
.channel-route-modal .ui-select__dropdown {
max-height: min(360px, calc(100vh - 360px));
}
.channel-route-modal .ui-select__dropdown button {
align-items: flex-start;
height: auto;
line-height: 1.45;
min-height: 42px;
padding: var(--space-2) var(--space-3);
white-space: normal;
}
.channel-route-modal__note {