feat: refine client status and review views

This commit is contained in:
hectorzhao
2026-08-27 17:32:05 +08:00
parent d6edb88b76
commit 01cffa4758
31 changed files with 528 additions and 117 deletions
+1
View File
@@ -100,6 +100,7 @@ export function LoginPage({ portal }: LoginPageProps) {
</div>
{error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com"></a> : null}
</div>
</section>
<Modal
+3 -3
View File
@@ -295,7 +295,7 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.filter((item) => !deferredKeyword || [item.channelName, item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
@@ -325,9 +325,9 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
aria-label={`${title}搜索通道、企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业应用或签名'}
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
+2
View File
@@ -143,6 +143,7 @@ export function AdminSmsAuditPage() {
<div className="sms-audit-cell-stack">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<small>{record.application?.name ?? record.applicationId ?? '-'}</small>
<small>{record.batchTask?.taskNo ?? '-'}</small>
</div>
),
},
@@ -241,6 +242,7 @@ export function AdminSmsAuditPage() {
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}></Button>} onClose={() => setDetailTarget(null)} open title="短信审核详情">
<div className="detail-grid">
<div><span></span><strong>{detailTarget.taskNo}</strong></div>
<div><span></span><strong>{detailTarget.batchTask?.taskNo ?? '-'}</strong></div>
<div><span></span><strong>{detailTarget.tenant?.name ?? detailTarget.tenantId}</strong></div>
<div><span></span><strong>{detailTarget.application?.name ?? detailTarget.applicationId ?? '-'}</strong></div>
<div><span></span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
@@ -17,6 +17,7 @@ export function AdminSmsTaskProgressPage() {
const [keyword, setKeyword] = useState('');
const [enterprise, setEnterprise] = useState('all');
const [application, setApplication] = useState('all');
const [status, setStatus] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
@@ -35,6 +36,7 @@ export function AdminSmsTaskProgressPage() {
keyword: keyword || undefined,
enterpriseKeyword: enterprise === 'all' ? undefined : enterprise,
applicationKeyword: application === 'all' ? undefined : application,
status: status === 'all' ? undefined : status,
createdAtFrom: submittedDateRange.start || undefined,
createdAtTo: submittedDateRange.end || undefined,
page: targetPage,
@@ -84,6 +86,7 @@ export function AdminSmsTaskProgressPage() {
setKeyword('');
setEnterprise('all');
setApplication('all');
setStatus('all');
setSubmittedDateRange({});
}
@@ -112,6 +115,18 @@ export function AdminSmsTaskProgressPage() {
enterprise={enterprise}
enterpriseOptions={enterpriseOptions}
keyword={keyword}
status={status}
statusOptions={[
{ label: '全部状态', value: 'all' },
{ label: '待审核', value: 'pending_review' },
{ label: '等待定时发送', value: 'scheduled' },
{ label: '排队中', value: 'queued' },
{ label: '发送中', value: 'sending' },
{ label: '已完成', value: 'finished' },
{ label: '失败', value: 'failed' },
{ label: '已拒绝', value: 'rejected' },
{ label: '已终止', value: 'canceled' },
]}
submittedDateRange={submittedDateRange}
onApplicationChange={setApplication}
onEnterpriseChange={(value) => {
@@ -119,6 +134,7 @@ export function AdminSmsTaskProgressPage() {
setApplication('all');
}}
onKeywordChange={setKeyword}
onStatusChange={setStatus}
onQuery={() => {
if (page !== 1) setPage(1);
else loadTasks(1);
@@ -7,10 +7,13 @@ type SmsTaskFilterProps = {
enterprise: string;
enterpriseOptions: Array<{ label: string; value: string }>;
keyword: string;
status: string;
statusOptions: Array<{ label: string; value: string }>;
submittedDateRange: DateRangeValue;
onApplicationChange: (value: string) => void;
onEnterpriseChange: (value: string) => void;
onKeywordChange: (value: string) => void;
onStatusChange: (value: string) => void;
onQuery: () => void;
onReset: () => void;
onSubmittedDateRangeChange: (value: DateRangeValue) => void;
@@ -22,10 +25,13 @@ export function SmsTaskFilter({
enterprise,
enterpriseOptions,
keyword,
status,
statusOptions,
submittedDateRange,
onApplicationChange,
onEnterpriseChange,
onKeywordChange,
onStatusChange,
onQuery,
onReset,
onSubmittedDateRangeChange,
@@ -40,6 +46,7 @@ export function SmsTaskFilter({
value={enterprise}
/>
<Select label="选择应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<Select label="任务状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<DateRangeInput label="提交时间" onChange={onSubmittedDateRangeChange} value={submittedDateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
@@ -102,7 +102,7 @@ export function SmsTaskTable({
<strong>{progress}%</strong>
</div>
<div className="batch-progress__track">
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(record.status) ? 'terminated' : 'sending'}`} style={{ width: `${progress}%` }} />
</div>
</div>
</td>
@@ -111,7 +111,7 @@ export function SmsTaskTable({
<div className="admin-task-actions">
<Button icon={<Eye size={15} />} onClick={() => onOpenDetail(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
disabled={!['sending', 'submitted'].includes(record.rawStatus)}
icon={<StopCircle size={15} />}
onClick={() => onTerminate(record)}
size="sm"
@@ -88,9 +88,19 @@ export function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: ()
<span><strong>{formatNumber(task.submittedSuccess)}</strong></span>
<span><strong>{formatNumber(task.successCount)}</strong></span>
</div>
{task.status === 'pending_review' ? <p className="muted"></p> : null}
</div>
</section>
{(task.reviewReason || task.rejectReason || task.riskTaskId) ? <section className="admin-task-card">
<h3></h3>
<dl className="admin-task-info-list">
{task.riskTaskId ? <div><dt></dt><dd>{task.riskTaskId}</dd></div> : null}
{task.reviewReason ? <div><dt></dt><dd>{task.reviewReason}</dd></div> : null}
{task.rejectReason ? <div><dt></dt><dd>{task.rejectReason}</dd></div> : null}
</dl>
</section> : null}
<section className="admin-task-card">
<h3><BarChart3 size={18} /></h3>
<div className="admin-task-template-block">
+9 -21
View File
@@ -1,20 +1,11 @@
import type { SmsBatchTask, SmsMessageRecord } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import type { CarrierStat, RegionStat, SmsTask, TaskStatus } from './taskTypes';
import { batchTaskStatusMeta, normalizeBatchTaskStatus } from '@/utils/batchTaskStatus';
export const statusLabels: Record<TaskStatus, string> = {
sending: '发送中',
completed: '已完成',
terminated: '已终止',
failed: '失败',
};
export const statusLabels = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.label])) as Record<TaskStatus, string>;
export const statusTones: Record<TaskStatus, 'info' | 'success' | 'neutral' | 'danger'> = {
sending: 'info',
completed: 'success',
terminated: 'neutral',
failed: 'danger',
};
export const statusTones = Object.fromEntries(Object.entries(batchTaskStatusMeta).map(([key, value]) => [key, value.tone])) as Record<TaskStatus, 'warning' | 'info' | 'success' | 'neutral' | 'danger'>;
export const sendTypeLabels = {
immediate: '立即发送',
@@ -51,13 +42,6 @@ export function messageStatusLabel(status: string) {
}[status] ?? status;
}
function normalizeTaskStatus(status: string): TaskStatus {
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
if (['failed', 'rejected'].includes(status)) return 'failed';
return 'sending';
}
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
}
@@ -139,11 +123,15 @@ export function mapTask(task: SmsBatchTask): SmsTask {
scheduledAt: task.scheduledAt,
submittedCount,
submittedSuccess: submittedCount,
sentCount: Math.min(task.phoneTotal, processedCount),
sentCount: task.status === 'pending_review' ? 0 : Math.min(task.phoneTotal, processedCount),
successCount,
failedCount,
status: normalizeTaskStatus(task.status),
status: normalizeBatchTaskStatus(task.status),
rawStatus: task.status,
auditStatus: task.auditStatus,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
carriers: task.messageStats ? buildCarrierStatsFromAggregates(task.messageStats) : buildCarrierStats(messages),
regions: task.messageStats ? buildRegionStatsFromAggregates(task.messageStats) : buildRegionStats(messages),
};
@@ -1,4 +1,6 @@
export type TaskStatus = 'sending' | 'completed' | 'terminated' | 'failed';
import type { BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
export type TaskStatus = BatchTaskDisplayStatus;
export type SendType = 'immediate' | 'scheduled';
export type CarrierStat = {
@@ -33,6 +35,10 @@ export type SmsTask = {
failedCount: number;
status: TaskStatus;
rawStatus: string;
auditStatus?: string | null;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
carriers: CarrierStat[];
regions: RegionStat[];
};
+2 -2
View File
@@ -29,10 +29,10 @@ const statusToneMap: Record<LinkStatus, 'success' | 'warning' | 'danger' | 'info
};
function normalizeStatus(application: ClientSmsApplication): LinkStatus {
if (application.status !== 'active') {
if (application.status !== 'active' || application.interfaceEnabled === false || application.cmppStatus === 'inactive') {
return 'inactive';
}
return application.cmppStatus ?? 'inactive';
return application.cmppStatus === 'connected' ? 'connected' : 'disconnected';
}
function mapParams(params: ApplicationCmppParams): ParamRow[] {
+22 -31
View File
@@ -19,9 +19,8 @@ import {
type TableColumn,
} from '@/components/ui';
import { clientApi, type SmsBatchTask } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
type BatchTaskStatus = 'completed' | 'sending' | 'terminated';
import { formatDateTime, recentBeijingDateRange } from '@/utils/dateTime';
import { batchTaskStatusMeta, canClientCancelBatchTask, normalizeBatchTaskStatus, type BatchTaskDisplayStatus } from '@/utils/batchTaskStatus';
type BatchTask = {
id: string;
@@ -37,19 +36,11 @@ type BatchTask = {
failedCount: number;
totalCount: number;
templateContent: string;
status: BatchTaskStatus;
};
const statusToneMap: Record<BatchTaskStatus, 'success' | 'info' | 'danger'> = {
completed: 'success',
sending: 'info',
terminated: 'danger',
};
const statusLabelMap: Record<BatchTaskStatus, string> = {
completed: '已完成',
sending: '发送中',
terminated: '已终止',
status: BatchTaskDisplayStatus;
rawStatus: string;
reviewReason?: string | null;
rejectReason?: string | null;
riskTaskId?: string | null;
};
function splitSignature(content: string) {
@@ -72,13 +63,8 @@ function getDeliveredCount(task: BatchTask) {
return task.deliveredCount;
}
function normalizeTaskStatus(status: string): BatchTaskStatus {
if (['completed', 'done'].includes(status)) return 'completed';
if (['cancelled', 'terminated', 'rejected', 'failed'].includes(status)) return 'terminated';
return 'sending';
}
function mapTask(task: SmsBatchTask): BatchTask {
const displayStatus = normalizeBatchTaskStatus(task.status);
return {
id: task.taskNo || task.id,
backendId: task.id,
@@ -88,12 +74,16 @@ function mapTask(task: SmsBatchTask): BatchTask {
wordCount: [...task.content].length,
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
scheduledAt: task.scheduledAt,
sentCount: task.progressSent ?? task.submittedTotal ?? 0,
sentCount: displayStatus === 'pending_review' ? 0 : task.progressSent ?? task.submittedTotal ?? 0,
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
totalCount: task.progressTotal || task.phoneTotal,
templateContent: task.content,
status: normalizeTaskStatus(task.status),
status: displayStatus,
rawStatus: task.status,
reviewReason: task.reviewReason,
rejectReason: task.rejectReason,
riskTaskId: task.riskTaskId,
};
}
@@ -106,7 +96,7 @@ export function ClientBatchTasksPage() {
const [applications, setApplications] = useState<Array<{ id: string; name: string }>>([]);
const [keyword, setKeyword] = useState(() => searchParams.get('taskNo') ?? '');
const [application, setApplication] = useState('all');
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedTask, setSelectedTask] = useState<BatchTask | null>(null);
@@ -166,7 +156,7 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-task-id">
<strong>{record.id}</strong>
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
<Tag tone={batchTaskStatusMeta[record.status].tone}>{batchTaskStatusMeta[record.status].label}{record.status === 'unknown' ? `${record.rawStatus}` : ''}</Tag>
</div>
),
},
@@ -215,15 +205,14 @@ export function ClientBatchTasksPage() {
render: (record) => (
<div className="batch-actions">
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost"></Button>
<Button
disabled={record.status !== 'sending'}
{canClientCancelBatchTask(record.rawStatus) ? <Button
icon={<StopCircle size={14} />}
onClick={() => terminateTask(record.id)}
size="sm"
variant="ghost"
>
</Button>
</Button> : null}
</div>
),
},
@@ -325,7 +314,7 @@ export function ClientBatchTasksPage() {
>
{selectedTask ? (
<div className="task-detail">
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
<DetailSection title="基本信息" extra={<Tag tone={batchTaskStatusMeta[selectedTask.status].tone}>{batchTaskStatusMeta[selectedTask.status].label}{selectedTask.status === 'unknown' ? `${selectedTask.rawStatus}` : ''}</Tag>}>
<DetailInfoGrid
items={[
{ label: '发送批次号', value: selectedTask.id },
@@ -345,6 +334,8 @@ export function ClientBatchTasksPage() {
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')}`, tone: 'primary' },
{ label: '模板内容', value: selectedTask.templateContent, full: true },
...(selectedTask.reviewReason ? [{ label: '审核原因', value: selectedTask.reviewReason, full: true }] : []),
...(selectedTask.rejectReason ? [{ label: '驳回原因', value: selectedTask.rejectReason, full: true }] : []),
]}
/>
</DetailSection>
@@ -354,7 +345,7 @@ export function ClientBatchTasksPage() {
label="发送进度"
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
percent={getProgress(selectedTask)}
status={selectedTask.status}
status={selectedTask.status === 'completed' ? 'completed' : ['failed', 'rejected', 'canceled'].includes(selectedTask.status) ? 'terminated' : 'sending'}
stats={[
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
+3 -3
View File
@@ -33,18 +33,18 @@ export function ClientBillingPage() {
}, [page]);
return (
<section className="page-stack">
<section className="page-stack client-billing-page">
<div className="page-heading">
<div><p className="eyebrow"></p><h1></h1></div>
</div>
<div className="dashboard-grid enterprise-summary-grid">
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
<div><span></span><strong className="client-amount-value"><MoneyText>¥{formatCents(balanceCents)}</MoneyText></strong><small></small></div>
</div>
<div className="surface mini-status-card">
<WalletCards size={22} />
<div><span></span><strong><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
<div><span></span><strong className="client-amount-value"><MoneyText>¥{formatCents(balanceCents + creditCents)}</MoneyText></strong><small> 0 </small></div>
</div>
</div>
<div className="surface section-stack">
+4 -4
View File
@@ -101,7 +101,7 @@ export function ClientHome() {
<div className="dashboard-grid dashboard-grid--four">
<div className="surface metric-card metric-card--featured">
<span></span>
<strong><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatAmount(availableBalance)}</MoneyText></strong>
<small> <MoneyText>¥{formatCents(account?.balanceCents)}</MoneyText></small>
</div>
<div className="surface metric-card">
@@ -111,12 +111,12 @@ export function ClientHome() {
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatCents(dashboard?.today.spendCents)}</MoneyText></strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<strong className="client-amount-value"><MoneyText>¥{formatCents(todayRefundCents)}</MoneyText></strong>
<small>退</small>
</div>
</div>
@@ -175,7 +175,7 @@ export function ClientHome() {
</div>
<div>
<span></span>
<strong>{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
<strong className="client-amount-value client-amount-value--compact">{latestRecharge ? <MoneyText>¥{formatCents(latestRecharge.amountCents)}</MoneyText> : '暂无充值'}</strong>
</div>
</div>
<div>
+18 -2
View File
@@ -45,10 +45,22 @@ export function ClientHttpApiPage() {
setLoading(true);
setError('');
try {
const [nextConfig, nextCredentials, nextWebhooks, nextRequests, nextDeliveries] = await Promise.all([
setConfig(null);
setCredentials([]);
setWebhooks([]);
setRequests([]);
setDeliveries([]);
const results = await Promise.allSettled([
clientApi.getApplicationHttpApiConfig(id), clientApi.listHttpApiCredentials(id), clientApi.listHttpWebhooks(id),
clientApi.listHttpApiRequests(id), clientApi.listHttpWebhookDeliveries(id),
]);
const [configResult, credentialsResult, webhooksResult, requestsResult, deliveriesResult] = results;
if (configResult.status === 'rejected') throw configResult.reason;
const nextConfig = configResult.value;
const nextCredentials = credentialsResult.status === 'fulfilled' ? credentialsResult.value : [];
const nextWebhooks = webhooksResult.status === 'fulfilled' ? webhooksResult.value : [];
const nextRequests = requestsResult.status === 'fulfilled' ? requestsResult.value : [];
const nextDeliveries = deliveriesResult.status === 'fulfilled' ? deliveriesResult.value : [];
setConfig(nextConfig);
setCredentials(nextCredentials);
setWebhooks(nextWebhooks);
@@ -56,7 +68,11 @@ export function ClientHttpApiPage() {
setDeliveries(nextDeliveries);
setReceiptUrl(nextWebhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkUrl(nextWebhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
} catch (failure) { setError(failure instanceof Error ? failure.message : 'HTTP接口资料加载失败'); }
if (results.some((result) => result.status === 'rejected')) setError('部分接口记录暂时无法加载,请稍后刷新;接口概览仍可正常使用。');
} catch (failure) {
const message = failure instanceof Error ? failure.message : '';
setError(message.toLowerCase().includes('internal server error') ? '接口资料暂时加载失败,请稍后重试。' : message || 'HTTP接口资料加载失败');
}
finally { setLoading(false); }
}
+21 -3
View File
@@ -11,6 +11,7 @@ import {
Tag,
type DateRangeValue,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
const statusLabelMap: Record<string, string> = {
delivered: '成功',
@@ -21,6 +22,14 @@ const statusLabelMap: Record<string, string> = {
failed: '失败',
rejected: '失败',
timeout: '超时',
pending_review: '待审核',
scheduled: '等待定时发送',
submit_queued: '等待提交',
submit_failed: '提交失败',
processing: '处理中',
sending: '发送中',
canceled: '已取消',
cancelled: '已取消',
};
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
@@ -41,16 +50,25 @@ function getDate(value?: string | null) {
function getReceipt(record: SmsMessageRecord) {
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
return {
status: latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus ?? '-',
status: receiptStatusLabel(latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus),
time: latest?.deliveredAt ?? record.deliveredAt,
};
}
function receiptStatusLabel(status?: string | null) {
if (!status) return '-';
const normalized = status.trim().toUpperCase();
return {
DELIVRD: '送达成功', ACCEPTD: '已受理', UNDELIV: '未送达', REJECTD: '已拒绝',
EXPIRED: '已过期', DELETED: '已删除', UNKNOWN: '状态未知',
}[normalized] ?? statusLabelMap[status.toLowerCase()] ?? '状态未知';
}
export function ClientSendDetailPage() {
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
const [applicationId, setApplicationId] = useState('all');
const [status, setStatus] = useState('all');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [contentKeyword, setContentKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [page, setPage] = useState(1);
@@ -194,7 +212,7 @@ export function ClientSendDetailPage() {
<td><strong>{record.phoneNumber}</strong></td>
<td>{record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</td>
<td><span className="send-detail-region">{region}</span></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag></td>
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? '状态未知'}</Tag></td>
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
<td>
{receipt.time ? (
+13 -32
View File
@@ -19,15 +19,13 @@ const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
pageSize: 10,
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success', pending: 'info', rejected: 'danger', draft: 'warning',
};
const statusLabel: Record<string, string> = {
approved: '审核通过', pending: '资料审核中', rejected: '需修改', draft: '待提交',
};
type ClientDrainageInfo = ClientSmsSignatureView['drainageInfo']['links'][number];
type Carrier = 'mobile' | 'unicom' | 'telecom';
function CarrierAvailability({ summary }: { summary?: { status: string; approved: number; total: number } }) {
const available = Boolean(summary && (summary.approved > 0 || ['approved', 'partial_success'].includes(summary.status)));
return <Tag tone={available ? 'success' : 'neutral'}>{available ? '报备通过' : '暂不可用'}</Tag>;
}
function reportFileRef(value: unknown): FileRef | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -95,7 +93,6 @@ function SignatureModal({
}) {
const [applicationId, setApplicationId] = useState(signature?.applicationId ?? '');
const [name, setName] = useState(signature?.name ?? '');
const [purpose, setPurpose] = useState(signature?.purpose ?? '');
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [values, setValues] = useState<Record<string, unknown>>(signature?.reportValues ?? {});
const [uploadingCode, setUploadingCode] = useState('');
@@ -132,7 +129,7 @@ function SignatureModal({
setSaving(true);
setError('');
try {
const body = { applicationId: applicationId || undefined, name: name.trim(), purpose: purpose.trim(), drainageInfo: { signatureReportValues: values } };
const body = { applicationId: applicationId || undefined, name: name.trim(), drainageInfo: { signatureReportValues: values } };
if (signature) await clientApi.updateSignature(signature.id, body);
else {
const created = await clientApi.createSignature(body);
@@ -181,7 +178,6 @@ function SignatureModal({
required
value={name}
/>
<Input label="使用场景" onChange={(event) => setPurpose(event.target.value)} placeholder="例如:验证码、订单通知" value={purpose ?? ''} />
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
@@ -263,7 +259,6 @@ export function ClientSignaturesPage() {
const [workspace, setWorkspace] = useState<ClientSignatureWorkspace>(EMPTY_WORKSPACE);
const [keyword, setKeyword] = useState('');
const [applicationFilter, setApplicationFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [signatureModal, setSignatureModal] = useState<ClientSmsSignatureView | 'new'>();
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignatureView; item?: ClientDrainageInfo }>();
@@ -282,7 +277,6 @@ export function ClientSignaturesPage() {
clientApi.getSignatureWorkspace({
keyword: keyword.trim() || undefined,
applicationId: applicationFilter || undefined,
status: statusFilter || undefined,
page: targetPage,
pageSize,
}),
@@ -299,7 +293,7 @@ export function ClientSignaturesPage() {
useEffect(() => {
const timer = window.setTimeout(() => loadData(page), 300);
return () => window.clearTimeout(timer);
}, [applicationFilter, keyword, page, refreshVersion, statusFilter]);
}, [applicationFilter, keyword, page, refreshVersion]);
const filteredItems = workspace.items;
const totalPages = Math.max(1, Math.ceil(workspace.total / pageSize));
@@ -328,7 +322,6 @@ export function ClientSignaturesPage() {
const resetFilters = () => {
setKeyword('');
setApplicationFilter('');
setStatusFilter('');
setPage(1);
setRefreshVersion((version) => version + 1);
};
@@ -341,23 +334,15 @@ export function ClientSignaturesPage() {
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal('new')}></Button>
</header>
<section className="client-signature-overview" aria-label="签名审核概览">
<div><span></span><strong>{workspace.summary.total}</strong></div>
<div><span></span><strong>{workspace.summary.pending}</strong></div>
<div><span></span><strong>{workspace.summary.approved}</strong></div>
<div><span></span><strong>{workspace.summary.rejected}</strong></div>
</section>
<div className="client-signature-toolbar">
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名、用途或应用" prefix={<Search size={17} />} value={keyword} />
<Input onChange={(event) => { setKeyword(event.target.value); setPage(1); }} placeholder="搜索签名或应用" prefix={<Search size={17} />} value={keyword} />
<Select onChange={(event) => { setApplicationFilter(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]} value={applicationFilter} />
<Select onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} options={[{ label: '全部状态', value: '' }, { label: '资料审核中', value: 'pending' }, { label: '审核通过', value: 'approved' }, { label: '需修改', value: 'rejected' }, { label: '待提交', value: 'draft' }]} value={statusFilter} />
<Button icon={<RotateCcw size={15} />} onClick={resetFilters} variant="ghost"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<section className="client-signature-list-shell">
<div className="client-signature-list-head"><span /><span></span><span></span><span>使</span><span></span><span></span><span></span><span></span></div>
<div className="client-signature-list-head"><span /><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{loading ? <p className="client-signature-list-empty">...</p> : null}
{!loading && !visibleItems.length ? <p className="client-signature-list-empty"></p> : null}
{visibleItems.map((signature) => {
@@ -369,24 +354,20 @@ export function ClientSignaturesPage() {
<button aria-label={expanded ? '收起引流信息' : '展开引流信息'} className="client-signature-expand" onClick={() => toggleExpanded(signature.id)} type="button">{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button>
<strong>{signature.name}</strong>
<span>{signature.application?.name ?? '未绑定'}</span>
<span>{signature.purpose || '-'}</span>
<Tag tone={statusTone[signature.auditStatus] ?? 'info'}>{statusLabel[signature.auditStatus] ?? signature.auditStatus}</Tag>
<span>{signature.submittedMaterialCount} </span>
{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}
<span>{formatDate(signature.updatedAt)}</span>
<div className="table-actions">
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={signature.id} targetType="signature" />
</div>
</div>
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong></strong>{signature.rejectReason}</div> : null}
{expanded ? <div className="client-drainage-panel">
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> </h3><p>使</p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost"></Button></div>
{links.length ? <div className="client-drainage-table">
<div className="client-drainage-table__head"><span></span><span>访</span><span></span><span></span><span></span></div>
<div className="client-drainage-table__head"><span></span><span>访</span><span></span><span></span><span></span><span></span><span></span></div>
{links.map((item) => <div className="client-drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><span>{formatDate(item.updatedAt)}</span>
<strong>{item.siteName}</strong><a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.drainageCarrierReportSummary?.[item.id]?.[carrier]} />)}<span>{formatDate(item.updatedAt)}</span>
<div className="table-actions"><Button disabled={item.auditStatus === 'pending'} icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost"></Button><Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'drainage', id: item.id, name: item.siteName })} size="sm" variant="danger"></Button></div>
{item.rejectReason ? <p className="client-drainage-reason"><strong></strong>{item.rejectReason}</p> : null}
</div>)}
</div> : <p className="client-signature-empty-hint"></p>}
</div> : null}
+2 -1
View File
@@ -14,6 +14,7 @@ import {
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { recentBeijingDateRange } from '@/utils/dateTime';
function getDate(value?: string | null) {
return value ? value.slice(0, 10) : '';
@@ -28,7 +29,7 @@ export function ClientUplinkMessagesPage() {
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
const [phoneKeyword, setPhoneKeyword] = useState('');
const [contentKeyword, setContentKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [dateRange, setDateRange] = useState<DateRangeValue>(() => recentBeijingDateRange(7));
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);