fix: polish channel groups and add production deployment
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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) ? (
|
||||
|
||||
Reference in New Issue
Block a user