feat: refine client status and review views
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user