feat: unify drainage targets and carrier status UI

This commit is contained in:
hectorzhao
2026-08-28 17:24:01 +08:00
parent 9ac41e9308
commit c68ac7a3db
25 changed files with 265 additions and 65 deletions
+2 -2
View File
@@ -54,9 +54,9 @@ export const adminGovernanceApi = {
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
approveDrainageInfo: (id: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
+2 -2
View File
@@ -182,7 +182,7 @@ export const clientApi = {
}),
createDrainageInfo: (
signatureId: string,
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
body: { url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, {
method: 'POST',
@@ -190,7 +190,7 @@ export const clientApi = {
}),
updateDrainageInfo: (
id: string,
body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> },
body: { url?: string; remark?: string; reportValues?: Record<string, unknown> },
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
+3 -3
View File
@@ -73,14 +73,14 @@ function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drain
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 || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<strong>{drainage ? String(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>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
<p><span></span><DateTime value={reportedAt} /></p>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></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}
{drainage ? <><p><span></span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{drainage ? <><p><span> URL </span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
<section><h3></h3><DeliveryStats task={task} /></section>
</div>
@@ -185,7 +185,7 @@ export function AdminChannelReportPage() {
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
+3 -4
View File
@@ -30,8 +30,7 @@ function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: ()
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.signature?.name ?? item.signatureId}</strong></div>
<div><span></span><Tag tone={meta.tone}>{meta.label}</Tag></div>
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div className="detail-grid__wide"><span> URL </span><strong>{item.url}</strong></div>
<div><span></span><strong>{formatDateTime(item.submittedAt)}</strong></div>
<AuditReviewInfo targetId={item.id} targetType="sms_drainage_info" />
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
@@ -83,7 +82,7 @@ export function AdminDrainageAuditPage() {
}
const columns = useMemo<Array<TableColumn<SmsDrainageInfo>>>(() => [
{ key: 'siteName', title: '站名称/引流地址', render: (record) => <div className="admin-task-enterprise"><strong>{record.siteName}</strong><span>{record.url}</span></div> },
{ key: 'url', title: '引流 URL 或号码', render: (record) => <strong>{record.url}</strong> },
{ key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId },
{ key: 'tenant', title: '企业/应用', render: (record) => <div className="admin-task-enterprise"><strong>{record.tenant?.name ?? record.tenantId}</strong><span>{record.application?.name ?? '-'}</span></div> },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.submittedAt) },
@@ -95,7 +94,7 @@ export function AdminDrainageAuditPage() {
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<Tabs items={[
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
{ label: '单条引流信息审核', value: 'single', content: <div className="page-stack"><div className="surface audit-filter-card"><div className="ui-filter-row"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、URL或号码" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} /><div className="audit-filter-actions ui-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); setSubmittedDateRange({}); }} variant="ghost"></Button></div></div></div><div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div></div> },
{ label: '导入批次审核', value: 'import', content: <ReportImportAuditPanel reportType="drainage" /> },
]} />
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
@@ -104,7 +104,7 @@ export function AdminEnterpriseSignaturesPage() {
return;
}
const exists = readDrainagePayload(signature).links.some((current) => current.id === item.id);
const body = { siteName: item.url.trim(), url: item.url.trim(), remark: item.remark, reportValues: item.reportValues };
const body = { url: item.url.trim(), remark: item.remark, reportValues: item.reportValues };
if (exists) await adminApi.updateDrainageInfo(item.id, body);
else await adminApi.createDrainageInfo(signatureId, body);
setDrainageModal(null);
+2 -2
View File
@@ -40,7 +40,7 @@ function recordSource(record: ReportRecord) {
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
const isDrainage = record.task?.reportType === 'drainage';
const target = isDrainage ? record.task?.drainageInfo?.siteName ?? record.task?.drainageInfo?.url : record.task?.signature?.name;
const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<div className="report-record-detail">
@@ -100,7 +100,7 @@ export function AdminReportRecordsPage() {
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> },
+1 -1
View File
@@ -25,7 +25,7 @@ const actionLabels: Record<string, string> = {
function taskTargetLabel(task: ReportTask) {
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
return task.drainageInfo?.siteName || task.drainageInfo?.url || task.drainageItemId || '引流信息';
return task.drainageInfo?.url || task.drainageItemId || '引流信息';
}
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
+1 -1
View File
@@ -22,7 +22,7 @@ const itemStatusLabels: Record<string, string> = {
function itemName(item: ReportImportReviewItem) {
if (item.reportType === 'signature') return String(item.payload.name ?? '-');
return String(item.payload.siteName ?? item.payload.url ?? '-');
return String(item.payload.url ?? '-');
}
function tone(status: string): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
+1 -1
View File
@@ -18,7 +18,7 @@ const transforms = [{ label: '保持原值', value: '' }, { label: '去除首尾
function coreTargets(reportType: ReportType) {
return reportType === 'signature'
? [{ label: '短信签名', value: 'signatureName:signature_name:string' }, { label: '签名用途/依据', value: 'purpose:purpose:string' }]
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '站点名称', value: 'siteName:site_name:string' }, { label: '引流地址', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
: [{ label: '所属短信签名', value: 'signatureName:signature_name:string' }, { label: '引流 URL 或号码', value: 'url:url:string' }, { label: '备注', value: 'remark:remark:string' }];
}
export function ReportMaterialImportModal({ onClose, onCompleted }: { onClose: () => void; onCompleted: () => void }) {
@@ -0,0 +1,85 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientSignaturesPage } from './ClientSignaturesPage';
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listApplicationOptions: vi.fn(),
getSignatureWorkspace: vi.fn(),
listApplicationReportFields: vi.fn(),
listCommonApplicationReportFields: vi.fn(),
uploadFileObject: vi.fn(),
createSignature: vi.fn(),
updateSignature: vi.fn(),
submitSignature: vi.fn(),
createDrainageInfo: vi.fn(),
updateDrainageInfo: vi.fn(),
changeDrainageInfoStatus: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
vi.mock('@/components/ui', () => ({
Button: ({ children, icon: _icon, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => <button {...props}>{children}</button>,
CarrierTag: ({ carrier }: { carrier: string }) => <span>{({ mobile: '移动', unicom: '联通', telecom: '电信' } as Record<string, string>)[carrier]}</span>,
DeleteRiskAction: () => <button type="button"></button>,
FileActions: () => null,
Input: ({ hint, label, prefix: _prefix, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hint?: string; label?: string; prefix?: React.ReactNode }) => <label>{label}<input aria-label={label} {...props} />{hint ? <small>{hint}</small> : null}</label>,
Modal: ({ children, footer, open, title }: { children: React.ReactNode; footer: React.ReactNode; open: boolean; title: string }) => open ? <div aria-label={title} role="dialog">{children}{footer}</div> : null,
Pagination: () => null,
Select: ({ label, options, ...props }: React.SelectHTMLAttributes<HTMLSelectElement> & { label?: string; options: Array<{ label: string; value: string }> }) => <label>{label}<select aria-label={label} {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>,
Textarea: ({ label, ...props }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label?: string }) => <label>{label}<textarea aria-label={label} {...props} /></label>,
}));
const signature = {
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '【测试签名】',
auditStatus: 'approved',
createdAt: '2026-08-28T00:00:00.000Z',
updatedAt: '2026-08-28T01:00:00.000Z',
application: { id: 'app-1', name: '测试应用', status: 'active' },
submittedMaterialCount: 0,
reportValues: {},
carrierReportSummary: {
mobile: { status: 'approved', approved: 1, total: 1 },
unicom: { status: 'pending', approved: 0, total: 1 },
telecom: { status: 'partial_success', approved: 1, total: 2 },
},
drainageCarrierReportSummary: {},
drainageInfo: { links: [] },
};
describe('ClientSignaturesPage drainage presentation', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
clientApi.listApplicationOptions.mockResolvedValue([{ id: 'app-1', name: '测试应用', status: 'active' }]);
clientApi.getSignatureWorkspace.mockResolvedValue({ items: [signature], summary: { total: 1, pending: 0, approved: 1, rejected: 0, draft: 0 }, total: 1, page: 1, pageSize: 10 });
clientApi.listApplicationReportFields.mockResolvedValue([]);
});
it('groups the three real carrier summaries into a readable status area', async () => {
render(<ClientSignaturesPage />);
expect(await screen.findByText('【测试签名】')).toBeVisible();
expect(screen.getByText('三网报备状态')).toBeVisible();
const row = screen.getByText('【测试签名】').closest('.client-signature-list-row');
expect(row).not.toBeNull();
expect(within(row as HTMLElement).getByText('移动')).toBeVisible();
expect(within(row as HTMLElement).getByText('联通')).toBeVisible();
expect(within(row as HTMLElement).getByText('电信')).toBeVisible();
expect(within(row as HTMLElement).getAllByText('报备通过')).toHaveLength(2);
expect(within(row as HTMLElement).getByText('暂不可用')).toBeVisible();
});
it('uses one URL-or-number field and removes the redundant name field', async () => {
render(<ClientSignaturesPage />);
await screen.findByText('【测试签名】');
fireEvent.click(screen.getByRole('button', { name: '展开引流信息' }));
fireEvent.click(screen.getByRole('button', { name: '新增引流信息' }));
const dialog = screen.getByRole('dialog', { name: '新增引流信息' });
expect(within(dialog).getByLabelText('引流 URL 或号码')).toBeVisible();
expect(within(dialog).queryByLabelText('名称')).not.toBeInTheDocument();
expect(within(dialog).queryByLabelText('访问地址')).not.toBeInTheDocument();
});
});
+24 -16
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { Button, CarrierTag, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Textarea } from '@/components/ui';
import {
clientApi,
type ClientApplicationReportField,
@@ -22,9 +22,19 @@ const EMPTY_WORKSPACE: ClientSignatureWorkspace = {
type ClientDrainageInfo = ClientSmsSignatureView['drainageInfo']['links'][number];
type Carrier = 'mobile' | 'unicom' | 'telecom';
function CarrierAvailability({ summary }: { summary?: { status: string; approved: number; total: number } }) {
function CarrierAvailability({ carrier, summary }: { carrier: Carrier; 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>;
return <div className={`client-carrier-status client-carrier-status--${available ? 'available' : 'unavailable'}`}>
<CarrierTag carrier={carrier} />
<span aria-hidden className="client-carrier-status__dot" />
<strong>{available ? '报备通过' : '暂不可用'}</strong>
</div>;
}
function DrainageTarget({ value }: { value: string }) {
return /^https?:\/\//i.test(value)
? <a className="client-drainage-target" href={value} rel="noreferrer" target="_blank" title={value}>{value}</a>
: <span className="client-drainage-target" title={value}>{value}</span>;
}
function reportFileRef(value: unknown): FileRef | null {
@@ -189,7 +199,6 @@ function SignatureModal({
function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDrainageInfo; signature: ClientSmsSignatureView; onClose: () => void; onSaved: () => void }) {
const [fields, setFields] = useState<ClientApplicationReportField[]>([]);
const [siteName, setSiteName] = useState(item?.siteName ?? '');
const [url, setUrl] = useState(item?.url ?? '');
const [remark, setRemark] = useState(item?.remark ?? '');
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
@@ -221,7 +230,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
setSaving(true);
setError('');
try {
const body = { siteName: siteName.trim(), url: url.trim(), remark: remark?.trim(), reportValues: values };
const body = { url: url.trim(), remark: remark?.trim(), reportValues: values };
if (item) await clientApi.updateDrainageInfo(item.id, body);
else await clientApi.createDrainageInfo(signature.id, body);
onSaved();
@@ -234,7 +243,7 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
const missingRequired = fields.some((field) => field.required && !values[field.code]);
return <Modal
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>}
onClose={onClose}
open
size="xl"
@@ -242,11 +251,10 @@ function DrainageModal({ item, signature, onClose, onSaved }: { item?: ClientDra
>
<div className="signature-form">
{item?.rejectReason ? <div className="client-signature-reason"><strong></strong><span>{item.rejectReason}</span></div> : null}
<Input label="名称" onChange={(event) => setSiteName(event.target.value)} placeholder="例如:品牌官网" required value={siteName} />
<Input label="访问地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
<Input hint="支持 http/https URL、手机号码或固定电话号码" label="引流 URL 或号码" onChange={(event) => setUrl(event.target.value)} placeholder="例如:https://example.com 或 13800138000" required value={url} />
<Textarea label="说明" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark ?? ''} />
<section className="client-signature-form-section">
<div><h3></h3><p></p></div>
<div><h3></h3><p> URL </p></div>
<ReviewFields fields={fields} onChange={(code, value) => setValues((current) => ({ ...current, [code]: value }))} onUpload={(field, file) => void upload(field, file)} uploadingCode={uploadingCode} values={values} />
</section>
{error ? <p className="form-error">{error}</p> : null}
@@ -329,7 +337,7 @@ export function ClientSignaturesPage() {
<header className="client-signature-heading">
<div className="client-signature-title">
<span className="client-signature-title__icon"><FileCheck2 size={23} /></span>
<div><h1></h1><p>使</p></div>
<div><h1></h1><p>使 URL</p></div>
</div>
<Button icon={<Plus size={17} />} onClick={() => setSignatureModal('new')}></Button>
</header>
@@ -342,7 +350,7 @@ export function ClientSignaturesPage() {
{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></div>
{loading ? <p className="client-signature-list-empty">...</p> : null}
{!loading && !visibleItems.length ? <p className="client-signature-list-empty"></p> : null}
{visibleItems.map((signature) => {
@@ -354,7 +362,7 @@ 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>
{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}
<div className="client-carrier-status-group">{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability carrier={carrier} key={carrier} summary={signature.carrierReportSummary?.[carrier]} />)}</div>
<span>{formatDate(signature.updatedAt)}</span>
<div className="table-actions">
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
@@ -362,12 +370,12 @@ export function ClientSignaturesPage() {
</div>
</div>
{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>
<div className="client-drainage-panel__head"><div><h3><Globe2 size={17} /> </h3><p>使 URL</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><span></span><span></span></div>
<div className="client-drainage-table__head"><span> URL </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>{(['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>
<DrainageTarget value={item.url} /><div className="client-carrier-status-group">{(['mobile', 'unicom', 'telecom'] as Carrier[]).map((carrier) => <CarrierAvailability carrier={carrier} key={carrier} summary={signature.drainageCarrierReportSummary?.[item.id]?.[carrier]} />)}</div><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.url })} size="sm" variant="danger"></Button></div>
</div>)}
</div> : <p className="client-signature-empty-hint"></p>}
</div> : null}
+54 -5
View File
@@ -8745,8 +8745,8 @@
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: 24px minmax(120px, 1.1fr) minmax(110px, 1fr) 92px 92px 92px 142px minmax(144px, auto);
min-width: 1050px;
grid-template-columns: 24px minmax(120px, 1.05fr) minmax(110px, .85fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
min-width: 1030px;
}
.client-signature-list-head {
@@ -8804,6 +8804,54 @@
color: var(--primary);
}
.client-carrier-status-group {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-width: 0;
}
.client-carrier-status {
align-items: center;
background: #f8faf9;
border: 1px solid #e3e9e6;
border-radius: 8px;
display: grid;
gap: 6px;
grid-template-columns: auto 7px minmax(0, 1fr);
min-height: 34px;
padding: 5px 8px;
}
.client-carrier-status strong {
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
min-width: 0;
white-space: nowrap;
}
.client-carrier-status__dot {
background: #b5c0bb;
border-radius: 50%;
height: 7px;
width: 7px;
}
.client-carrier-status--available {
background: #f1faf6;
border-color: #cfe8dc;
}
.client-carrier-status--available strong {
color: #176b49;
}
.client-carrier-status--available .client-carrier-status__dot {
background: #24a06b;
box-shadow: 0 0 0 3px rgba(36, 160, 107, .12);
}
.client-signature-inline-reason,
.client-signature-reason,
.client-drainage-reason {
@@ -8855,8 +8903,8 @@
align-items: center;
display: grid;
gap: 14px;
grid-template-columns: minmax(120px, 1fr) minmax(200px, 1.7fr) 92px 92px 92px 142px minmax(144px, auto);
min-width: 980px;
grid-template-columns: minmax(220px, 1.35fr) minmax(360px, 2.2fr) 142px minmax(144px, auto);
min-width: 940px;
}
.client-drainage-table__head {
@@ -8873,8 +8921,9 @@
padding: 9px 14px;
}
.client-drainage-table__row > a {
.client-drainage-target {
color: var(--primary);
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;