feat: enhance operations dashboard and reporting controls

This commit is contained in:
hectorzhao
2026-09-04 16:26:22 +08:00
parent bc18c7ff12
commit 48d0363920
23 changed files with 544 additions and 49 deletions
+7
View File
@@ -194,6 +194,8 @@ export const adminGovernanceApi = {
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) =>
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
@@ -208,6 +210,11 @@ export const adminGovernanceApi = {
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) =>
request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
+17
View File
@@ -140,6 +140,23 @@ describe('request tenant and error boundaries', () => {
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
});
it('marks string-body blob requests as JSON so download endpoints receive their payload', async () => {
let receivedContentType = '';
let receivedBody: unknown;
server.use(
http.post('http://localhost/api/admin/report-export', async ({ request: incoming }) => {
receivedContentType = incoming.headers.get('content-type') ?? '';
receivedBody = await incoming.json();
return new HttpResponse('xlsx-data', { status: 200 });
}),
);
await expect(
(await requestBlob('/admin/report-export', { method: 'POST', body: JSON.stringify({ reportType: 'signature' }) })).text(),
).resolves.toBe('xlsx-data');
expect(receivedContentType).toContain('application/json');
expect(receivedBody).toEqual({ reportType: 'signature' });
});
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
writeSession({
portal: 'admin',
+3
View File
@@ -114,6 +114,9 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
export async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const headers = new Headers(options.headers);
if (typeof options.body === 'string' && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
+6
View File
@@ -152,6 +152,12 @@ export type DashboardResponse = {
spendCents: number;
returnedCents: number;
billingUnits: number;
segmentCount: number;
deliveredSegmentCount: number;
arrivalRate: number;
billedCents: number;
profitCents: number;
profitRate: number;
};
uplinkCount: number;
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
+4 -6
View File
@@ -607,12 +607,10 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return (
<div className="signature-quality-matrix__metric">
<strong>{total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
<small>{formatDuration(metric?.averageArrivalMs)}</small>
<div className={`signature-quality-matrix__metric signature-quality-matrix__metric--${successRateTone(successRate)}`}>
<div><small></small><strong>{total.toLocaleString('zh-CN')} </strong></div>
<div><small></small><span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>{successRate.toFixed(1)}%</span></div>
<div><small></small><span>{formatDuration(metric?.averageArrivalMs)}</span></div>
{(metric?.submitFailureCount ?? 0) > 0 ? <em> {metric?.submitFailureCount}</em> : null}
</div>
);
@@ -2,16 +2,22 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminDrainageFieldsPage } from './AdminDrainageFieldsPage';
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), updateCommonReportField: vi.fn() } }));
const { adminApi } = vi.hoisted(() => ({ adminApi: { listDrainageFields: vi.fn(), listCommonReportFields: vi.fn(), reorderCommonReportFields: vi.fn(), updateCommonReportField: vi.fn(), updateDrainageField: vi.fn(), createDrainageField: vi.fn(), deleteDrainageField: vi.fn() } }));
vi.mock('@/api/adminApi', () => ({ adminApi }));
describe('common reporting configuration', () => {
beforeEach(() => {
Object.values(adminApi).forEach((method) => method.mockReset());
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
adminApi.listDrainageFields.mockResolvedValue([field]);
adminApi.listCommonReportFields.mockResolvedValue([{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, drainageField: field }]);
const secondField = { id: 'field-2', code: 'smsContent', name: '短信内容', fieldType: 'string', status: 'active' };
adminApi.listDrainageFields.mockResolvedValue([field, secondField]);
adminApi.listCommonReportFields.mockResolvedValue([
{ id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false, sortOrder: 10, drainageField: field },
{ id: 'common-2', drainageFieldId: 'field-2', reportType: 'signature', required: false, sortOrder: 20, drainageField: secondField },
]);
adminApi.reorderCommonReportFields.mockResolvedValue([]);
adminApi.updateCommonReportField.mockResolvedValue({});
adminApi.updateDrainageField.mockResolvedValue({});
});
it('opens existing values and saves the edited requirement with PUT API', async () => {
render(<AdminDrainageFieldsPage />);
@@ -24,4 +30,31 @@ describe('common reporting configuration', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2);
});
it('moves a common field within its own material type through the reorder API', async () => {
render(<AdminDrainageFieldsPage />);
fireEvent.click(await screen.findByRole('button', { name: '下移通用字段主体证明' }));
await waitFor(() => expect(adminApi.reorderCommonReportFields).toHaveBeenCalledWith({
reportType: 'signature',
ids: ['common-2', 'common-1'],
}));
await waitFor(() => expect(adminApi.listCommonReportFields).toHaveBeenCalledTimes(2));
});
it('edits a field definition and locks mapping keys for a referenced field', async () => {
adminApi.listDrainageFields.mockResolvedValueOnce([
{ id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active', usageCount: 1, description: '旧说明' },
]);
render(<AdminDrainageFieldsPage />);
fireEvent.click(await screen.findByRole('button', { name: '编辑主体证明' }));
expect(screen.getByRole('dialog')).toHaveTextContent('编辑报备字段');
expect(screen.getByLabelText('字段代码')).toBeDisabled();
expect(screen.getByRole('button', { name: '字段类型' })).toBeDisabled();
fireEvent.change(screen.getByLabelText('字段名称'), { target: { value: '企业主体证明' } });
fireEvent.change(screen.getByLabelText('描述'), { target: { value: '新说明' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(adminApi.updateDrainageField).toHaveBeenCalledWith('field-1', {
code: 'license', name: '企业主体证明', fieldType: 'file', description: '新说明',
}));
});
});
+66 -20
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
import { ArrowDown, ArrowUp, Database, Edit3, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
@@ -32,6 +32,8 @@ export function AdminDrainageFieldsPage() {
const [type, setType] = useState('all');
const [appliedType, setAppliedType] = useState('all');
const [creating, setCreating] = useState(false);
const [editingField, setEditingField] = useState<DrainageField | null>(null);
const [fieldSaving, setFieldSaving] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [fieldType, setFieldType] = useState<ReportFieldType>('string');
@@ -45,6 +47,7 @@ export function AdminDrainageFieldsPage() {
const [commonReportType, setCommonReportType] = useState<'signature' | 'drainage'>('signature');
const [commonRequired, setCommonRequired] = useState(false);
const [commonDeleteTarget, setCommonDeleteTarget] = useState<CommonReportField | null>(null);
const [commonOrderingId, setCommonOrderingId] = useState<string>();
const codeError = code && !/^[A-Za-z0-9]+$/.test(code) ? '字段代码只能包含阿拉伯数字和英文大小写字母' : '';
function loadData() {
@@ -70,17 +73,38 @@ export function AdminDrainageFieldsPage() {
[appliedKeyword, appliedType, fields],
);
function createField() {
adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' })
function closeFieldModal() {
setCreating(false);
setEditingField(null);
setCode('');
setName('');
setFieldType('string');
setDescription('');
}
function openFieldModal(field?: DrainageField) {
setEditingField(field ?? null);
setCode(field?.code ?? '');
setName(field?.name ?? '');
setFieldType((field?.fieldType as ReportFieldType | undefined) ?? 'string');
setDescription(field?.description ?? '');
setError('');
setCreating(true);
}
function saveField() {
if (fieldSaving) return;
setFieldSaving(true);
const request = editingField
? adminApi.updateDrainageField(editingField.id, { code, name, fieldType, description })
: adminApi.createDrainageField({ code, name, fieldType, description, status: 'active' });
request
.then(() => {
setCode('');
setName('');
setFieldType('string');
setDescription('');
setCreating(false);
closeFieldModal();
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备字段新增失败'));
.catch((failure: Error) => setError(failure.message || '报备字段保存失败'))
.finally(() => setFieldSaving(false));
}
function deleteField() {
@@ -130,6 +154,26 @@ export function AdminDrainageFieldsPage() {
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
}
async function moveCommonField(field: CommonReportField, direction: -1 | 1) {
if (commonOrderingId) return;
const group = commonFields.filter((item) => item.reportType === field.reportType);
const currentIndex = group.findIndex((item) => item.id === field.id);
const targetIndex = currentIndex + direction;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= group.length) return;
const ids = group.map((item) => item.id);
[ids[currentIndex], ids[targetIndex]] = [ids[targetIndex], ids[currentIndex]];
setCommonOrderingId(field.id);
setError('');
try {
await adminApi.reorderCommonReportFields({ reportType: field.reportType, ids });
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '通用字段顺序调整失败');
} finally {
setCommonOrderingId(undefined);
}
}
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
@@ -142,7 +186,7 @@ export function AdminDrainageFieldsPage() {
<h1></h1>
<p></p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)} size="sm"></Button>
<Button icon={<Plus size={16} />} onClick={() => openFieldModal()} size="sm"></Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -170,8 +214,8 @@ export function AdminDrainageFieldsPage() {
<Button icon={<Plus size={16} />} onClick={() => openCommonField()} size="sm" variant="secondary"></Button>
</div>
<div className="admin-drainage-common-grid">
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} tone="warning" />
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="info" />
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onEdit={openCommonField} onDelete={setCommonDeleteTarget} onMove={moveCommonField} orderingId={commonOrderingId} tone="warning" />
</div>
</div>
@@ -180,7 +224,7 @@ export function AdminDrainageFieldsPage() {
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
return <article className="admin-drainage-field-card" key={field.id}>
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost"></Button></div>
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><div className="admin-drainage-field-card__actions"><Button aria-label={`编辑${field.name}`} icon={<Edit3 size={14} />} iconOnly onClick={() => openFieldModal(field)} size="sm" variant="ghost"></Button><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost"></Button></div></div>
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
<div className="admin-drainage-field-card__meta"><span> <strong>{field.usageCount ?? 0}</strong></span><span> <strong>{field.commonUsageCount ?? 0}</strong></span></div>
</article>;
@@ -205,23 +249,25 @@ export function AdminDrainageFieldsPage() {
<Modal
footer={(
<>
<Button onClick={() => setCreating(false)} variant="ghost"></Button>
<Button disabled={!code || !name || Boolean(codeError)} onClick={createField}></Button>
<Button disabled={fieldSaving} onClick={closeFieldModal} variant="ghost"></Button>
<Button disabled={!code || !name || Boolean(codeError) || fieldSaving} onClick={saveField}>{fieldSaving ? '保存中...' : '保存'}</Button>
</>
)}
onClose={() => setCreating(false)}
onClose={closeFieldModal}
open={creating}
title="添加报备字段"
title={editingField ? '编辑报备字段' : '添加报备字段'}
>
<div className="admin-system-modal-form">
<Input error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
<Input disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))} error={codeError} label="字段代码" onChange={(event) => setCode(event.target.value)} placeholder="仅允许数字和英文字母" value={code} />
<Input label="字段名称" onChange={(event) => setName(event.target.value)} value={name} />
<Select
disabled={Boolean(editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0))}
label="字段类型"
onChange={(event) => setFieldType(event.target.value as ReportFieldType)}
options={typeOptions.filter((option) => option.value !== 'all')}
value={fieldType}
/>
{editingField && ((editingField.usageCount ?? 0) > 0 || (editingField.commonUsageCount ?? 0) > 0) ? <p className="admin-system-modal-form__wide"></p> : null}
<Textarea
className="admin-system-modal-form__wide"
label="描述"
@@ -255,6 +301,6 @@ export function AdminDrainageFieldsPage() {
);
}
function CommonFieldGroup({ fields, label, onEdit, onDelete, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost"></Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
function CommonFieldGroup({ fields, label, onEdit, onDelete, onMove, orderingId, tone }: { fields: CommonReportField[]; label: string; onEdit: (field: CommonReportField) => void; onDelete: (field: CommonReportField) => void; onMove: (field: CommonReportField, direction: -1 | 1) => void; orderingId?: string; tone: 'info' | 'warning' }) {
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} </span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field, index) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><div className="admin-drainage-common-order"><Button aria-label={`上移通用字段${field.drainageField.name}`} disabled={index === 0 || Boolean(orderingId)} icon={<ArrowUp size={14} />} iconOnly onClick={() => onMove(field, -1)} size="sm" variant="ghost"></Button><Button aria-label={`下移通用字段${field.drainageField.name}`} disabled={index === fields.length - 1 || Boolean(orderingId)} icon={<ArrowDown size={14} />} iconOnly onClick={() => onMove(field, 1)} size="sm" variant="ghost"></Button></div><Button aria-label={`修改通用字段${field.drainageField.name}`} icon={<Edit3 size={14} />} onClick={() => onEdit(field)} size="sm" variant="ghost"></Button><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost"></Button></div>)}</div> : <p className="admin-drainage-common-empty"></p>}</section>;
}
+33
View File
@@ -78,6 +78,9 @@ export function AdminHome() {
const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const todayReturned = moneyUnitsToYuan(dashboard?.today.returnedCents);
const todayBilled = moneyUnitsToYuan(dashboard?.today.billedCents);
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
@@ -175,6 +178,36 @@ export function AdminHome() {
<strong>{activeSignatureCount}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{formatCount(dashboard?.today.segmentCount ?? 0)} </strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{(dashboard?.today.arrivalRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayReturned)}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>¥{formatCurrency(todayBilled)}</strong>
<small> × </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={todayProfit < 0 ? 'metric-card__value--danger' : undefined}>¥{formatCurrency(todayProfit)}</strong>
<small> - </small>
</div>
<div className="surface metric-card">
<span></span>
<strong className={(dashboard?.today.profitRate ?? 0) < 0 ? 'metric-card__value--danger' : undefined}>{(dashboard?.today.profitRate ?? 0).toFixed(1)}%</strong>
<small> / </small>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
@@ -91,4 +91,10 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
expect(screen.queryByRole('button', { name: '签名降序' })).not.toBeInTheDocument();
expect(screen.queryByText('待生成明细')).not.toBeInTheDocument();
});
it('shows the complete signature context in the signature hover text', () => {
renderTable({ expandedSignatureId: '' });
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('企业:深圳市聆界科技有限公司'));
expect(screen.getByLabelText(/签名:【聆界科技】/)).toHaveAttribute('title', expect.stringContaining('应用:营销通知应用'));
});
});
@@ -71,6 +71,15 @@ export function EnterpriseSignaturesTable({
: payload.links;
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
const signatureTooltip = [
`签名:${formatSignatureName(signature.name)}`,
`企业:${signature.tenant?.name ?? signature.tenantId}`,
`应用:${signature.application?.name ?? '未绑定应用'}`,
`用途:${signature.purpose || '未填写'}`,
`审核状态:${signature.auditStatus}`,
`创建时间:${signature.createdAt ? new Date(signature.createdAt).toLocaleString('zh-CN') : '未知'}`,
`更新时间:${signature.updatedAt ? new Date(signature.updatedAt).toLocaleString('zh-CN') : '未知'}`,
].join('\n');
return (
<article
aria-label={`签名总体状态:${cardVisual.label}`}
@@ -86,7 +95,7 @@ export function EnterpriseSignaturesTable({
>
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="enterprise-signature-table__signature" data-label="签名">
<div aria-label={signatureTooltip} className="enterprise-signature-table__signature" data-label="签名" title={signatureTooltip}>
<strong>{formatSignatureName(signature.name)}</strong>
</div>
<div data-label="企业">
+44 -7
View File
@@ -4302,7 +4302,17 @@
}
.admin-metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.metric-card__value--danger {
color: var(--color-danger) !important;
}
@media (max-width: 1380px) {
.admin-metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.admin-workload-grid {
@@ -7273,8 +7283,9 @@
}
.signature-quality-matrix {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
border: 1px solid color-mix(in srgb, var(--color-border) 80%, var(--color-primary));
border-radius: var(--radius-lg);
max-width: 100%;
overflow: auto;
width: 100%;
@@ -7290,7 +7301,7 @@
.signature-quality-matrix td {
border-bottom: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
padding: var(--space-3);
padding: 10px;
text-align: left;
vertical-align: top;
}
@@ -7306,19 +7317,42 @@
}
.signature-quality-matrix thead th {
background: var(--color-surface-muted);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface));
color: var(--color-text-muted);
font-size: 13px;
position: sticky;
top: 0;
z-index: 2;
}
.signature-quality-matrix tbody th {
background: var(--color-surface);
color: var(--color-text-strong);
min-width: 210px;
position: sticky;
left: 0;
z-index: 1;
}
.signature-quality-matrix__metric {
background: var(--color-surface);
border-left: 3px solid var(--color-border);
border-radius: var(--radius-sm);
display: grid;
gap: 4px;
gap: 7px;
min-width: 150px;
padding: 10px 12px;
}
.signature-quality-matrix__metric--success { border-left-color: var(--color-success); }
.signature-quality-matrix__metric--warning { border-left-color: var(--color-warning); }
.signature-quality-matrix__metric--danger { border-left-color: var(--color-danger); }
.signature-quality-matrix__metric > div {
align-items: center;
display: flex;
gap: var(--space-2);
justify-content: space-between;
}
.signature-quality-matrix__metric strong {
@@ -7327,6 +7361,7 @@
.signature-quality-matrix__metric small {
color: var(--color-text-muted);
font-size: 12px;
}
.signature-quality-matrix__metric em {
@@ -8741,12 +8776,14 @@
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
.admin-drainage-common-list { display: grid; gap: 8px; }
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto; padding: 11px 12px; }
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto auto auto; padding: 11px 12px; }
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
.admin-drainage-common-order { display: inline-flex; gap: 2px; }
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
.admin-drainage-field-card__actions { display: inline-flex; gap: 4px; }
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
+7 -7
View File
@@ -19,9 +19,9 @@
color: var(--color-text);
display: flex;
flex-direction: column;
gap: var(--space-5);
gap: var(--space-4);
height: 100vh;
padding: var(--space-6) var(--space-4);
padding: var(--space-4) var(--space-4);
min-width: 0;
overflow: hidden;
}
@@ -86,7 +86,7 @@
align-content: start;
display: grid;
flex: 1;
gap: var(--space-5);
gap: var(--space-3);
overflow-y: auto;
overflow-x: hidden;
margin-right: calc(var(--space-4) * -1);
@@ -129,7 +129,7 @@
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
letter-spacing: 0;
margin-bottom: var(--space-2);
margin-bottom: var(--space-1);
padding: 0 var(--space-3);
}
@@ -145,8 +145,8 @@
font-weight: var(--font-weight-semibold);
gap: var(--space-3);
margin-bottom: var(--space-1);
min-height: 42px;
padding: 10px var(--space-3);
min-height: 36px;
padding: 7px var(--space-3);
text-align: left;
transition: background var(--transition-fast), color var(--transition-fast);
width: 100%;
@@ -190,7 +190,7 @@
display: flex;
gap: var(--space-3);
font-weight: var(--font-weight-medium);
padding: 10px var(--space-3);
padding: 7px var(--space-3);
transition: background var(--transition-fast), color var(--transition-fast);
min-width: 0;
}