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
+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) ? (