fix: close documented platform polish gaps
This commit is contained in:
@@ -73,7 +73,7 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
<div className="login-brand">
|
||||
<img alt={isAdmin ? 'CMPP 运营端 logo' : 'CMPP 客户端 logo'} src="/logo/logo1.png" />
|
||||
<div>
|
||||
<h1>短信平台</h1>
|
||||
<h1>短信服务平台</h1>
|
||||
<p>{isAdmin ? '运营端登录' : '客户端登录'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Po
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all';
|
||||
type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed';
|
||||
@@ -97,13 +98,6 @@ const regionOptions = [
|
||||
...'北京,天津,河北,山西,内蒙古,辽宁,吉林,黑龙江,上海,江苏,浙江,安徽,福建,江西,山东,河南,湖北,湖南,广东,广西,海南,重庆,四川,贵州,云南,西藏,陕西,甘肃,青海,宁夏,新疆,香港,澳门,台湾'.split(',').map((province) => ({ label: province, value: province })),
|
||||
];
|
||||
|
||||
const extensionOptions = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '6', value: '6' },
|
||||
];
|
||||
|
||||
const carrierLabelMap: Record<Carrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
@@ -321,7 +315,7 @@ function ChannelFormModal({
|
||||
/>
|
||||
<div className="sms-channel-inline-field">
|
||||
<Input label="* 接入号" onChange={(event) => setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} />
|
||||
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
|
||||
<Input label="扩展位数" max="20" min="0" onChange={(event) => setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
|
||||
</div>
|
||||
<Input label="* 通道流速" max="2000" min="1" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} />
|
||||
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
|
||||
@@ -732,7 +726,7 @@ export function AdminChannelsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>最近心跳</span>
|
||||
<strong>{connection.lastHeartbeatAt ? new Date(connection.lastHeartbeatAt).toLocaleString('zh-CN', { hour12: false }) : '-'}</strong>
|
||||
<strong>{formatDateTime(connection.lastHeartbeatAt)}</strong>
|
||||
</div>
|
||||
{connection.lastError ? <p>{connection.lastError}</p> : null}
|
||||
</article>
|
||||
@@ -749,7 +743,7 @@ export function AdminChannelsPage() {
|
||||
<article className="channel-log-item" key={log.id}>
|
||||
<div>
|
||||
<strong>{log.event}</strong>
|
||||
<span>{new Date(log.time).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
<span>{formatDateTime(log.time)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{log.resourceId}</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, FileSearch, Search, X } from 'lucide-react';
|
||||
import { adminApi, type EnterpriseCertification } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type EnterpriseAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
@@ -59,7 +60,7 @@ function mapCertification(record: EnterpriseCertification): EnterpriseAuditRecor
|
||||
contactName: record.contactName ?? '',
|
||||
contactPhone: record.contactPhone ?? '',
|
||||
contactEmail: String(materials.contactEmail ?? ''),
|
||||
submittedAt: new Date(record.submittedAt).toLocaleString('zh-CN', { hour12: false }),
|
||||
submittedAt: formatDateTime(record.submittedAt),
|
||||
reviewRemark: record.rejectReason ?? String(materials.reviewRemark ?? ''),
|
||||
status: record.status as EnterpriseAuditStatus,
|
||||
};
|
||||
|
||||
@@ -455,7 +455,7 @@ function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||
<div><span>网站链接</span><strong>{item.url}</strong></div>
|
||||
<div><span>引流信息</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
||||
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
||||
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
||||
|
||||
@@ -85,6 +85,7 @@ export function AdminHome() {
|
||||
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
||||
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
||||
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
||||
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 };
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
@@ -99,12 +100,12 @@ export function AdminHome() {
|
||||
|
||||
const auditTrendOption = useMemo(
|
||||
() => createBarOption({
|
||||
labels: ['待审核'],
|
||||
labels: ['企业认证', '短信审核', '模板', '签名'],
|
||||
series: [
|
||||
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
|
||||
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] },
|
||||
],
|
||||
}),
|
||||
[dashboard],
|
||||
[pendingAudits],
|
||||
);
|
||||
|
||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||
@@ -230,14 +231,26 @@ export function AdminHome() {
|
||||
<BarChart3 size={20} className="status-info" />
|
||||
</div>
|
||||
<div className="overview-grid overview-grid--three">
|
||||
<div className="mini-status-card">
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<div>
|
||||
<span>待审核</span>
|
||||
<strong>{dashboard?.pendingAuditCount ?? 0} 条</strong>
|
||||
<small>模板、签名和企业认证。</small>
|
||||
</div>
|
||||
</div>
|
||||
<span>企业认证待审</span>
|
||||
<strong>{pendingAudits.enterpriseCertifications} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>短信审核待审</span>
|
||||
<strong>{pendingAudits.smsAudits} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>模板待审</span>
|
||||
<strong>{pendingAudits.templates} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-signatures')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>签名待审</span>
|
||||
<strong>{pendingAudits.signatures} 条</strong>
|
||||
</Button>
|
||||
<div className="mini-status-card">
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
|
||||
@@ -20,6 +20,7 @@ type CarrierRule = DictionaryItem & {
|
||||
export function AdminPhoneSegmentsPage() {
|
||||
const pageSize = 25;
|
||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||
const [segmentTotal, setSegmentTotal] = useState(0);
|
||||
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||
const [ruleTotal, setRuleTotal] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||
@@ -39,16 +40,12 @@ export function AdminPhoneSegmentsPage() {
|
||||
const [segmentQuery, setSegmentQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [rulePage, setRulePage] = useState(1);
|
||||
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setSegmentQuery(keyword.trim());
|
||||
setPage(1);
|
||||
setPageCursors([undefined]);
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
@@ -57,14 +54,13 @@ export function AdminPhoneSegmentsPage() {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
|
||||
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, page, pageSize }),
|
||||
adminApi.listPhoneCarrierRules({ keyword: activeTab === 'rules' ? segmentQuery || undefined : undefined, page: rulePage, pageSize }),
|
||||
])
|
||||
.then(([segmentPage, ruleResponse]) => {
|
||||
if (cancelled) return;
|
||||
setSegments(segmentPage.items as PhoneSegment[]);
|
||||
setHasMore(segmentPage.hasMore);
|
||||
setNextCursor(segmentPage.nextCursor);
|
||||
setSegmentTotal(segmentPage.total);
|
||||
setRules(ruleResponse.items as CarrierRule[]);
|
||||
setRuleTotal(ruleResponse.total);
|
||||
setError('');
|
||||
@@ -78,8 +74,9 @@ export function AdminPhoneSegmentsPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeTab, page, pageCursors, reloadKey, rulePage, segmentQuery]);
|
||||
}, [activeTab, page, reloadKey, rulePage, segmentQuery]);
|
||||
|
||||
const segmentTotalPages = Math.max(1, Math.ceil(segmentTotal / pageSize));
|
||||
const ruleTotalPages = Math.max(1, Math.ceil(ruleTotal / pageSize));
|
||||
|
||||
function createSegment() {
|
||||
@@ -90,7 +87,6 @@ export function AdminPhoneSegmentsPage() {
|
||||
setCity('');
|
||||
setCreating(false);
|
||||
setPage(1);
|
||||
setPageCursors([undefined]);
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
||||
@@ -157,17 +153,12 @@ export function AdminPhoneSegmentsPage() {
|
||||
<Pagination
|
||||
page={page}
|
||||
previousDisabled={page <= 1 || loading}
|
||||
nextDisabled={!hasMore || loading}
|
||||
nextDisabled={page >= segmentTotalPages || loading}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
onNext={() => {
|
||||
if (!nextCursor) return;
|
||||
setPageCursors((current) => {
|
||||
const updated = [...current];
|
||||
updated[page] = nextCursor;
|
||||
return updated;
|
||||
});
|
||||
setPage((current) => current + 1);
|
||||
}}
|
||||
onNext={() => setPage((current) => Math.min(segmentTotalPages, current + 1))}
|
||||
onPageChange={setPage}
|
||||
total={segmentTotal}
|
||||
totalPages={segmentTotalPages}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -146,7 +146,6 @@ export function AdminRechargeRecordsPage() {
|
||||
<th style={{ width: '130px' }}>充值金额</th>
|
||||
<th style={{ width: '140px' }}>充值后余额</th>
|
||||
<th style={{ width: '120px' }}>充值类型</th>
|
||||
<th style={{ width: '140px' }}>操作人</th>
|
||||
<th style={{ width: '300px' }}>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -166,7 +165,6 @@ export function AdminRechargeRecordsPage() {
|
||||
<td>¥{formatAmount(record.amountCents / 100)}</td>
|
||||
<td>{record.balanceAfterCents === null || record.balanceAfterCents === undefined ? '-' : `¥${formatAmount(record.balanceAfterCents / 100)}`}</td>
|
||||
<td><Tag tone="warning">人工充值</Tag></td>
|
||||
<td>{record.operatorId || '运营'}</td>
|
||||
<td><RemarkCell value={record.remark ?? undefined} /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -267,6 +267,7 @@ function SendDetailModal({
|
||||
}
|
||||
|
||||
export function AdminSmsRecordsPage() {
|
||||
const pageSize = 25;
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
@@ -279,6 +280,7 @@ export function AdminSmsRecordsPage() {
|
||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||
const [segmentLoading, setSegmentLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listOperationMessages({
|
||||
@@ -293,6 +295,7 @@ export function AdminSmsRecordsPage() {
|
||||
})
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setPage(1);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'));
|
||||
@@ -336,6 +339,9 @@ export function AdminSmsRecordsPage() {
|
||||
}, [enterprise, records]);
|
||||
|
||||
const filteredRows = records;
|
||||
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);
|
||||
|
||||
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
|
||||
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
|
||||
@@ -421,7 +427,7 @@ export function AdminSmsRecordsPage() {
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={5}>暂无短信记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
) : visibleRows.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td>
|
||||
<div className="admin-sms-record-sender">
|
||||
@@ -453,7 +459,16 @@ export function AdminSmsRecordsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredRows.length} />
|
||||
<Pagination
|
||||
nextDisabled={currentPage >= totalPages}
|
||||
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
|
||||
onPageChange={setPage}
|
||||
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
|
||||
page={currentPage}
|
||||
previousDisabled={currentPage <= 1}
|
||||
total={filteredRows.length}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRecord ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@@ -85,7 +86,7 @@ function formatNumber(value: number) {
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-';
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(status: string): TaskStatus {
|
||||
@@ -468,7 +469,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
<span>{record.application}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span></td>
|
||||
<td><span>{formatTime(record.submittedAt)}</span></td>
|
||||
<td>
|
||||
<div className="admin-task-counts">
|
||||
<strong>{formatNumber(record.phoneCount)}</strong>
|
||||
@@ -481,7 +482,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
|
||||
{record.scheduledAt ? <span>{formatTime(record.scheduledAt)}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const auditStatusLabelMap: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
@@ -41,7 +42,7 @@ export function AdminTemplateAuditPage() {
|
||||
{ key: 'customer', title: '客户', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '短信应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'content', title: '短信模板内容', render: (record) => record.content },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => new Date(record.createdAt).toLocaleString('zh-CN', { hour12: false }) },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.createdAt) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { KeyRound, Plus, Search } from 'lucide-react';
|
||||
import { Eye, EyeOff, KeyRound, Plus, RefreshCw, Search } from 'lucide-react';
|
||||
import { adminApi, type ManagedUser, type TenantOption, type UserPayload } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { readSession } from '@/api/session';
|
||||
@@ -51,6 +51,13 @@ function toForm(user?: ManagedUser): UserForm {
|
||||
} : emptyForm;
|
||||
}
|
||||
|
||||
function generateInitialPassword() {
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%';
|
||||
const values = new Uint32Array(14);
|
||||
crypto.getRandomValues(values);
|
||||
return Array.from(values, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const session = readSession();
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
@@ -61,6 +68,7 @@ export function AdminUsersPage() {
|
||||
const [form, setForm] = useState<UserForm>(emptyForm);
|
||||
const [passwordUser, setPasswordUser] = useState<ManagedUser | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showInitialPassword, setShowInitialPassword] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -84,7 +92,8 @@ export function AdminUsersPage() {
|
||||
}, [keyword, users]);
|
||||
|
||||
function openCreate() {
|
||||
setForm({ ...emptyForm, tenantId: tenants[0]?.id ?? '' });
|
||||
setForm({ ...emptyForm, password: generateInitialPassword(), tenantId: tenants[0]?.id ?? '' });
|
||||
setShowInitialPassword(false);
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
@@ -223,8 +232,32 @@ export function AdminUsersPage() {
|
||||
value={form.tenantId}
|
||||
/>
|
||||
) : null}
|
||||
{creating ? <Input label="初始密码" onChange={(event) => updateField('password', event.target.value)} required type="password" value={form.password} /> : null}
|
||||
<Select label="状态" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
{creating ? (
|
||||
<Input
|
||||
label="初始密码"
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
required
|
||||
suffix={(
|
||||
<>
|
||||
<button aria-label={showInitialPassword ? '隐藏初始密码' : '显示初始密码'} className="icon-button" onClick={() => setShowInitialPassword((current) => !current)} type="button">
|
||||
{showInitialPassword ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
<button aria-label="随机生成初始密码" className="icon-button" onClick={() => updateField('password', generateInitialPassword())} type="button">
|
||||
<RefreshCw size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
type={showInitialPassword ? 'text' : 'password'}
|
||||
value={form.password}
|
||||
/>
|
||||
) : null}
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>状态</span>
|
||||
<div className="radio-row">
|
||||
<label><input checked={form.status === 'active'} onChange={() => updateField('status', 'active')} type="radio" />启用</label>
|
||||
<label><input checked={form.status === 'disabled'} onChange={() => updateField('status', 'disabled')} type="radio" />禁用</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Download, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
@@ -59,6 +60,10 @@ export function ClientSendPage() {
|
||||
() => templates.find((item) => item.id === templateId),
|
||||
[templates, templateId],
|
||||
);
|
||||
const selectedApplication = useMemo(
|
||||
() => applications.find((item) => item.id === applicationId),
|
||||
[applicationId, applications],
|
||||
);
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
||||
));
|
||||
@@ -378,7 +383,7 @@ export function ClientSendPage() {
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥0.05 / 人</strong>
|
||||
<strong>¥{formatCents(selectedApplication?.customerUnitPrice)} / 人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">短信按 70 字/条计费,超出部分按 67 字/条计算</div>
|
||||
|
||||
Reference in New Issue
Block a user