fix: clarify signature reporting status
This commit is contained in:
@@ -26,6 +26,11 @@ function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function formatSignatureName(value?: string | null) {
|
||||
const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, '');
|
||||
return `【${name || '-'}】`;
|
||||
}
|
||||
|
||||
function drainageItems(signature?: ClientSmsSignature) {
|
||||
const payload = asRecord(signature?.drainageInfo);
|
||||
return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : [];
|
||||
@@ -47,7 +52,7 @@ function DetailModal({ drainage, signature, task, onClose }: { drainage?: Draina
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
|
||||
<div className="channel-report-detail">
|
||||
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : `【${signature?.name ?? task.signature?.name ?? '-'}】`}</strong>
|
||||
<strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
|
||||
<p><span>企业</span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
|
||||
<p><span>企业应用</span><span>{signature?.application?.name ?? '-'}</span></p>
|
||||
{!drainage ? <><p><span>签名依据</span><span>{String(profile.basis ?? '-')}</span></p><p><span>公司名称</span><span>{String(profile.companyName ?? '-')}</span></p><p><span>统一社会信用代码</span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
|
||||
@@ -170,7 +175,7 @@ export function AdminChannelReportPage() {
|
||||
return <div key={task.id}>
|
||||
<div className="channel-report-row channel-report-row--signature">
|
||||
<span />
|
||||
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>【{signature?.name ?? task.signature?.name ?? '-'}】</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
|
||||
<div className="channel-report-name"><button disabled={links.length === 0} onClick={() => toggle(task.signatureId)} type="button">{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}</button><span><strong>{formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{signature?.tenant?.name ?? task.tenantId}{links.length ? <b>{links.length}</b> : null}</small></span></div>
|
||||
<ReportStatus value={task.status} />
|
||||
<DateTime value={task.createdAt} />
|
||||
<DateTime value={imported?.createdAt ?? exported?.createdAt} />
|
||||
|
||||
@@ -34,6 +34,7 @@ export function AdminDrainageFieldsPage() {
|
||||
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
|
||||
const [description, setDescription] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
|
||||
|
||||
function loadData() {
|
||||
adminApi.listDrainageFields()
|
||||
@@ -106,7 +107,7 @@ export function AdminDrainageFieldsPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setCreating(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!code || !name} onClick={createField}>保存</Button>
|
||||
<Button disabled={!code || !name || Boolean(codeError)} onClick={createField}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setCreating(false)}
|
||||
@@ -114,7 +115,7 @@ export function AdminDrainageFieldsPage() {
|
||||
title="添加报备字段"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="字段代码" onChange={(event) => setCode(event.target.value)} value={code} />
|
||||
<Input error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
|
||||
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||
<Select
|
||||
label="字段类型"
|
||||
|
||||
@@ -331,7 +331,6 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
|
||||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||
|
||||
@@ -78,6 +78,26 @@ function StatusTag({ status }: { status: CarrierStatus }) {
|
||||
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||
}
|
||||
|
||||
type CarrierReportSummary = { status: string; approved: number; total: number };
|
||||
|
||||
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
|
||||
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral">不适用</Tag>;
|
||||
if (summary.status === 'approved') return <Tag tone="success">全部通过({summary.approved}/{summary.total})</Tag>;
|
||||
if (summary.status === 'failed' || summary.status === 'rejected') return <Tag tone="danger">报备失败({summary.approved}/{summary.total})</Tag>;
|
||||
if (summary.status === 'waiting_material') return <Tag tone="warning">资料待补充({summary.approved}/{summary.total})</Tag>;
|
||||
if (summary.approved > 0) return <Tag tone="info">部分通过({summary.approved}/{summary.total})</Tag>;
|
||||
if (summary.status === 'reporting' || summary.status === 'exporting') return <Tag tone="info">报备中(0/{summary.total})</Tag>;
|
||||
return <Tag tone="neutral">未报备(0/{summary.total})</Tag>;
|
||||
}
|
||||
|
||||
function AuditStatusTag({ status }: { status: string }) {
|
||||
const meta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
draft: { label: '草稿', tone: 'neutral' }, pending: { label: '待审核', tone: 'info' }, approved: { label: '已通过', tone: 'success' }, rejected: { label: '已驳回', tone: 'danger' },
|
||||
};
|
||||
const current = meta[status] ?? { label: status || '-', tone: 'neutral' as const };
|
||||
return <Tag tone={current.tone}>{current.label}</Tag>;
|
||||
}
|
||||
|
||||
function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
|
||||
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
|
||||
@@ -524,7 +544,6 @@ function DrainageFormModal({ applicationId, item, onClose, onSubmit }: { applica
|
||||
}
|
||||
|
||||
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
const payload = readDrainagePayload(item);
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="签名报备详情">
|
||||
<div className="admin-report-detail">
|
||||
@@ -532,13 +551,15 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
|
||||
<div><span>企业名称</span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用名称</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>签名名称</span><strong>{item.name}</strong></div>
|
||||
<div><span>签名审核</span><AuditStatusTag status={item.auditStatus} /></div>
|
||||
<div><span>更新时间</span><strong>{formatDate(item.updatedAt)}</strong></div>
|
||||
</div>
|
||||
<div className="admin-report-tabs">
|
||||
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><StatusTag status={payload.carrierStatus.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><StatusTag status={payload.carrierStatus.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><StatusTag status={payload.carrierStatus.telecom} /></span></button>
|
||||
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -572,11 +593,6 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function summaryStatus(signature: ClientSmsSignature, carrier: 'mobile' | 'unicom' | 'telecom'): CarrierStatus {
|
||||
const status = signature.carrierReportSummary?.[carrier]?.status;
|
||||
return status === 'approved' ? 'approved' : status === 'failed' ? 'rejected' : status === 'reporting' ? 'pending' : 'filing';
|
||||
}
|
||||
|
||||
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||
@@ -737,10 +753,11 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{visibleSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const effectiveCarrierStatus = { mobile: summaryStatus(signature, 'mobile'), unicom: summaryStatus(signature, 'unicom'), telecom: summaryStatus(signature, 'telecom') };
|
||||
const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status);
|
||||
const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray';
|
||||
const expanded = expandedSignatureId === signature.id;
|
||||
return (
|
||||
<article className={`signature-card signature-card--${signatureCardTone(effectiveCarrierStatus)}`} key={signature.id}>
|
||||
<article className={`signature-card signature-card--${cardTone}`} key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
@@ -748,9 +765,10 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<div><span>签名名称</span><strong>{signature.name}</strong></div>
|
||||
<div><span>企业</span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{signature.application?.name ?? '-'}</strong></div>
|
||||
<div><span>移动</span><StatusTag status={effectiveCarrierStatus.mobile} /></div>
|
||||
<div><span>联通</span><StatusTag status={effectiveCarrierStatus.unicom} /></div>
|
||||
<div><span>电信</span><StatusTag status={effectiveCarrierStatus.telecom} /></div>
|
||||
<div><span>签名审核</span><AuditStatusTag status={signature.auditStatus} /></div>
|
||||
<div><span>移动</span><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
|
||||
<div><span>联通</span><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
|
||||
<div><span>电信</span><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
|
||||
<div><span>引流信息</span><strong>{payload.links.length} 条</strong></div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
export type SelectOption = {
|
||||
label: string;
|
||||
@@ -20,6 +20,8 @@ type SelectProps = NativeSelectProps & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
};
|
||||
|
||||
@@ -35,23 +37,33 @@ export function Select({
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
searchable,
|
||||
searchPlaceholder,
|
||||
required,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectId = id ?? props.name;
|
||||
const rootRef = useRef<HTMLLabelElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
|
||||
const selectedValue = value ?? internalValue;
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === selectedValue),
|
||||
[options, selectedValue],
|
||||
);
|
||||
const searchEnabled = searchable ?? (typeof label === 'string' && (label.includes('企业') || label.includes('应用')));
|
||||
const visibleOptions = useMemo(() => {
|
||||
const normalizedKeyword = searchKeyword.trim().toLocaleLowerCase();
|
||||
if (!searchEnabled || !normalizedKeyword) return options;
|
||||
return options.filter((option) => option.label.toLocaleLowerCase().includes(normalizedKeyword));
|
||||
}, [options, searchEnabled, searchKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +75,7 @@ export function Select({
|
||||
setInternalValue(nextValue);
|
||||
onChange?.({ target: { value: nextValue } });
|
||||
setOpen(false);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -90,7 +103,10 @@ export function Select({
|
||||
aria-haspopup="listbox"
|
||||
disabled={disabled}
|
||||
id={selectId}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onClick={() => setOpen((current) => {
|
||||
if (current) setSearchKeyword('');
|
||||
return !current;
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<span className={selectedOption?.value ? '' : 'ui-select__placeholder'}>
|
||||
@@ -100,7 +116,13 @@ export function Select({
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-select__dropdown" role="listbox">
|
||||
{options.map((option) => (
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
aria-selected={option.value === selectedValue}
|
||||
key={option.value}
|
||||
@@ -111,6 +133,7 @@ export function Select({
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <span className="ui-select__empty">无匹配选项</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -664,6 +664,36 @@
|
||||
color: var(--color-text-inverse);
|
||||
}
|
||||
|
||||
.ui-select__search {
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-1);
|
||||
min-height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.ui-select__search:focus-within {
|
||||
border-color: var(--color-selected);
|
||||
}
|
||||
|
||||
.ui-select__search input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
outline: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select__empty {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-tag {
|
||||
align-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
|
||||
@@ -3648,7 +3648,8 @@ h3 {
|
||||
.template-card-grid {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: repeat(3, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
@@ -3658,6 +3659,7 @@ h3 {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-height: 300px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: var(--space-6);
|
||||
position: relative;
|
||||
@@ -3726,6 +3728,8 @@ h3 {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-4);
|
||||
@@ -3737,6 +3741,7 @@ h3 {
|
||||
|
||||
.template-card-footer div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user