feat: densify enterprise signature management

This commit is contained in:
hectorzhao
2026-09-02 10:04:58 +08:00
parent 1f2dfb5caf
commit 9e34757d6f
14 changed files with 240 additions and 119 deletions
@@ -6,7 +6,7 @@ import { ReportMaterialImportModal } from './ReportMaterialImportModal';
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportModal, DrainageReportStatusModal, SignatureReportModal } from './enterprise-signatures/SignatureReportModals';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportStatusModal } from './enterprise-signatures/SignatureReportModals';
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
@@ -16,7 +16,6 @@ export function AdminEnterpriseSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
@@ -27,9 +26,9 @@ export function AdminEnterpriseSignaturesPage() {
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
const [signatureSort, setSignatureSort] = useState<'asc' | 'desc'>('asc');
const [appliedSignatureKeyword, setAppliedSignatureKeyword] = useState('');
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
const [reportStatusTarget, setReportStatusTarget] = useState<ClientSmsSignature | null>(null);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [total, setTotal] = useState(0);
@@ -40,10 +39,10 @@ export function AdminEnterpriseSignaturesPage() {
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page) {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page, targetSort = signatureSort) {
try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, page: targetPage, pageSize }),
adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(),
]);
@@ -130,13 +129,17 @@ export function AdminEnterpriseSignaturesPage() {
loadData={loadData}
setDeleteTarget={setDeleteTarget}
setDrainageModal={setDrainageModal}
setDrainageReport={setDrainageReport}
setDrainageStatusTarget={setDrainageStatusTarget}
setExpandedSignatureId={setExpandedSignatureId}
setPage={setPage}
setSignatureSort={(nextSort) => {
setSignatureSort(nextSort);
if (page === 1) void loadData(undefined, 1, nextSort);
else setPage(1);
}}
setReportStatusTarget={setReportStatusTarget}
setSignatureModal={setSignatureModal}
setSignatureReport={setSignatureReport}
signatureSort={signatureSort}
total={total}
totalPages={totalPages}
visibleSignatures={visibleSignatures}
@@ -159,7 +162,7 @@ export function AdminEnterpriseSignaturesPage() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="签名" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
@@ -214,7 +217,6 @@ export function AdminEnterpriseSignaturesPage() {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}} /> : null}
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{drainageModal ? (
<DrainageFormModal
@@ -224,7 +226,6 @@ export function AdminEnterpriseSignaturesPage() {
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
/>
) : null}
{drainageReport ? <DrainageReportModal item={drainageReport.item} onClose={() => setDrainageReport(null)} signature={drainageReport.signature} /> : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? (
<ConfirmModal
@@ -0,0 +1,65 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import type { ClientSmsSignature } from '@/api/adminApi';
import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable';
const signature = {
id: 'signature-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
name: '【聆界科技】',
auditStatus: 'approved',
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T01:00:00.000Z',
tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' },
application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' },
carrierReportSummary: {
mobile: { status: 'approved', approved: 3, total: 3 },
unicom: { status: 'reporting', approved: 2, total: 3 },
telecom: { status: 'waiting_material', approved: 1, total: 3 },
},
drainageInfo: { links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }] },
drainageCarrierReportSummary: {
'drainage-1': {
mobile: { status: 'approved', approved: 3, total: 3 },
unicom: { status: 'reporting', approved: 1, total: 3 },
telecom: { status: 'waiting_material', approved: 0, total: 3 },
},
},
} as unknown as ClientSmsSignature;
function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) {
const props: Parameters<typeof EnterpriseSignaturesTable>[0] = {
appliedDrainageKeyword: '', currentPage: 1, expandedSignatureId: signature.id,
filteredSignatures: [signature], visibleSignatures: [signature], total: 1, totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined), setDeleteTarget: vi.fn(), setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(), setExpandedSignatureId: vi.fn(), setPage: vi.fn(),
setReportStatusTarget: vi.fn(), setSignatureModal: vi.fn(), setSignatureSort: vi.fn(), signatureSort: 'asc',
...overrides,
};
return { ...render(<EnterpriseSignaturesTable {...props} />), props };
}
describe('EnterpriseSignaturesTable dense presentation', () => {
it('uses compact headers, bracketed signatures, channel counts and only the requested row actions', () => {
renderTable();
expect(screen.getByText('【聆界科技】')).toBeVisible();
expect(screen.getAllByText('审核状态')).toHaveLength(2);
expect(screen.queryByText('签名名称')).not.toBeInTheDocument();
expect(screen.queryByText('签名审核')).not.toBeInTheDocument();
expect(screen.queryByText('报备详情')).not.toBeInTheDocument();
expect(screen.getAllByLabelText('已报备通过3个,总通道数3个')).toHaveLength(2);
expect(screen.getByLabelText('已报备通过2个,总通道数3个')).toHaveTextContent('2/3');
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
});
it('requests a real descending sort from the signature column control', async () => {
const setSignatureSort = vi.fn();
renderTable({ expandedSignatureId: '', setSignatureSort });
await userEvent.click(screen.getByRole('button', { name: '签名降序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
});
});
@@ -1,8 +1,8 @@
import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, FileText, Plus } from 'lucide-react';
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, Edit3, Plus } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportTag, readDrainagePayload, signatureCardVisual } from './signature.helpers';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportCount, formatSignatureName, readDrainagePayload, signatureCardVisual } from './signature.helpers';
import type { DrainageInfo } from './signature.types';
type EnterpriseSignaturesTableProps = {
@@ -13,13 +13,13 @@ type EnterpriseSignaturesTableProps = {
loadData: () => Promise<void>;
setDeleteTarget: Dispatch<SetStateAction<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>>;
setDrainageModal: Dispatch<SetStateAction<{ signatureId: string; item?: DrainageInfo } | null>>;
setDrainageReport: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
setDrainageStatusTarget: Dispatch<SetStateAction<{ signature: ClientSmsSignature; item: DrainageInfo } | null>>;
setExpandedSignatureId: Dispatch<SetStateAction<string>>;
setPage: Dispatch<SetStateAction<number>>;
setSignatureSort: (sort: 'asc' | 'desc') => void;
setReportStatusTarget: Dispatch<SetStateAction<ClientSmsSignature | null>>;
setSignatureModal: Dispatch<SetStateAction<ClientSmsSignature | 'new' | null>>;
setSignatureReport: Dispatch<SetStateAction<ClientSmsSignature | null>>;
signatureSort: 'asc' | 'desc';
total: number;
totalPages: number;
visibleSignatures: ClientSmsSignature[];
@@ -33,19 +33,37 @@ export function EnterpriseSignaturesTable({
loadData,
setDeleteTarget,
setDrainageModal,
setDrainageReport,
setDrainageStatusTarget,
setExpandedSignatureId,
setPage,
setSignatureSort,
setReportStatusTarget,
setSignatureModal,
setSignatureReport,
signatureSort,
total,
totalPages,
visibleSignatures,
}: EnterpriseSignaturesTableProps) {
return (
<div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
<span aria-hidden="true" />
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button aria-pressed={signatureSort === 'asc'} icon={<ArrowUp size={13} />} iconOnly onClick={() => setSignatureSort('asc')} size="sm" variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}></Button>
<Button aria-pressed={signatureSort === 'desc'} icon={<ArrowDown size={13} />} iconOnly onClick={() => setSignatureSort('desc')} size="sm" variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}></Button>
</span>
</span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword
@@ -59,16 +77,15 @@ export function EnterpriseSignaturesTable({
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div><span></span><strong>{signature.name}</strong></div>
<div><span></span><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div><span></span><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div><span></span><AuditStatusTag status={signature.auditStatus} /></div>
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={signature.carrierReportSummary?.mobile} /></div>
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={signature.carrierReportSummary?.unicom} /></div>
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={signature.carrierReportSummary?.telecom} /></div>
<div><span></span><strong>{payload.links.length} </strong></div>
<div className="enterprise-signature-table__signature" data-label="签名"><strong>{formatSignatureName(signature.name)}</strong></div>
<div data-label="企业"><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div data-label="应用"><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div data-label="审核状态"><AuditStatusTag status={signature.auditStatus} /></div>
<div data-label="移动"><CarrierReportCount summary={signature.carrierReportSummary?.mobile} /></div>
<div data-label="联通"><CarrierReportCount summary={signature.carrierReportSummary?.unicom} /></div>
<div data-label="电信"><CarrierReportCount summary={signature.carrierReportSummary?.telecom} /></div>
<div data-label="引流信息"><strong>{payload.links.length} </strong></div>
<div className="signature-actions">
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
@@ -82,9 +99,9 @@ export function EnterpriseSignaturesTable({
<div className="drainage-table__head">
<span>url或号码</span>
<span></span>
<CarrierTag carrier="mobile" />
<CarrierTag carrier="unicom" />
<CarrierTag carrier="telecom" />
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleDrainageLinks.map((item) => {
@@ -93,11 +110,10 @@ export function EnterpriseSignaturesTable({
<div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportTag summary={summary?.mobile} />
<CarrierReportTag summary={summary?.unicom} />
<CarrierReportTag summary={summary?.telecom} />
<CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button>
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Info } from 'lucide-react';
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
import { Button, CarrierTag, Modal, Select, Textarea } from '@/components/ui';
import { AuditStatusTag, carrierLabel, CarrierReportTag, formatDate } from './signature.helpers';
import { carrierLabel, CarrierReportTag } from './signature.helpers';
import type { DrainageInfo } from './signature.types';
const reportStatusOptions = [
@@ -11,28 +11,6 @@ const reportStatusOptions = [
{ label: '报备失败', value: 'failed' }, { label: '放弃报备', value: 'abandoned' },
];
export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="签名报备详情">
<div className="admin-report-detail">
<div className="detail-grid">
<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"><CarrierTag carrier="mobile" /><span><CarrierReportTag summary={item.carrierReportSummary?.mobile} /></span></button>
<button className="admin-report-carrier--unicom active" type="button"><CarrierTag carrier="unicom" /><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
<button className="admin-report-carrier--telecom active" type="button"><CarrierTag carrier="telecom" /><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
</div>
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name} <CarrierTag carrier={target.carrier} /></span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</div>
</Modal>
);
}
export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsSignature; onClose: () => void; onSaved: () => void }) {
const targets = item.reportTargets ?? [];
const carriers = ['mobile', 'unicom', 'telecom'] as const;
@@ -56,24 +34,6 @@ export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: Cli
</Modal>;
}
export function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; onClose: () => void; signature: ClientSmsSignature }) {
const summary = signature.drainageCarrierReportSummary?.[item.id];
const targets = signature.drainageReportTargets?.[item.id] ?? [];
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息报备详情">
<div className="detail-grid">
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><span>url或号码</span><strong>{item.url}</strong></div>
<div><CarrierTag carrier="mobile" /><CarrierReportTag summary={summary?.mobile} /></div>
<div><CarrierTag carrier="unicom" /><CarrierReportTag summary={summary?.unicom} /></div>
<div><CarrierTag carrier="telecom" /><CarrierReportTag summary={summary?.telecom} /></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
</div>
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}{carrierLabel(target.channel.carrier)}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
</Modal>
);
}
export function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item: DrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const targets = signature.drainageReportTargets?.[item.id] ?? [];
const [statuses, setStatuses] = useState<Record<string, string>>(() => Object.fromEntries(targets.map((target) => [target.channelId, target.status])));
@@ -15,6 +15,11 @@ export function carrierLabel(carrier?: string | null) {
return carrierLabels[carrier] ?? carrier;
}
export function formatSignatureName(name: string) {
const value = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
return value ? `${value}` : '—';
}
export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral"></Tag>;
let label = '未报备';
@@ -27,6 +32,24 @@ export function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }
return <span className="carrier-report-summary"><Tag tone={tone}>{label}</Tag><small>{summary.approved}/{summary.total}</small></span>;
}
export function CarrierReportCount({ summary }: { summary?: CarrierReportSummary }) {
if (!summary || summary.status === 'not_applicable' || summary.total === 0) {
return <span className="carrier-report-count carrier-report-count--neutral" title="没有适用的目标通道"></span>;
}
const tone = summary.approved === summary.total
? 'success'
: ['failed', 'rejected'].includes(summary.status) ? 'danger' : 'warning';
return (
<span
aria-label={`已报备通过${summary.approved}个,总通道数${summary.total}`}
className={`carrier-report-count carrier-report-count--${tone}`}
title={`已报备通过 ${summary.approved} 个 / 总通道数 ${summary.total}`}
>
{summary.approved}/{summary.total}
</span>
);
}
export function signatureCardVisual(auditStatus: string, summaries?: Record<string, CarrierReportSummary>) {
if (auditStatus === 'rejected') return { label: '签名审核已驳回', tone: 'red' as SignatureCardTone };
if (auditStatus === 'pending') return { label: '签名待审核', tone: 'amber' as SignatureCardTone };