feat: unify drainage targets and carrier status UI
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user