refine Semi UI access governance

This commit is contained in:
lingniu
2026-07-18 19:12:07 +08:00
parent 84684f2dc1
commit 5d4aa92cc1
3 changed files with 327 additions and 8 deletions

View File

@@ -133,13 +133,28 @@ test('reports and independently retries unresolved identity and threshold failur
const identityTitle = await screen.findByText('来源身份待绑定'); const identityTitle = await screen.findByText('来源身份待绑定');
const identityCollapse = identityTitle.closest<HTMLElement>('.semi-collapse'); const identityCollapse = identityTitle.closest<HTMLElement>('.semi-collapse');
expect(identityCollapse).toHaveClass('v2-access-identity-queue-v3'); expect(identityCollapse).toHaveClass('v2-access-identity-queue-v3');
expect(identityTitle.closest('.semi-collapse-header')).toHaveAttribute('aria-expanded', 'true');
expect(within(identityCollapse!).getByText('1 条').closest('.semi-tag')).toBeTruthy(); expect(within(identityCollapse!).getByText('1 条').closest('.semi-tag')).toBeTruthy();
expect(within(identityCollapse!).getByText('138****0001').closest('.semi-card')).toHaveClass('v2-access-identity-card'); expect(within(identityCollapse!).getByText('138****0001').closest('.semi-card')).toHaveClass('v2-access-identity-card');
expect(within(identityCollapse!).getByText('来源标识')).toBeInTheDocument();
expect(within(identityCollapse!).getByText('关联车牌')).toBeInTheDocument();
expect(within(identityCollapse!).getByText('最后出现')).toBeInTheDocument();
expect(within(identityCollapse!).getByText('建议动作')).toBeInTheDocument();
const thresholdTitle = await screen.findByText('在线判定阈值'); const thresholdTitle = await screen.findByText('在线判定阈值');
const thresholdCollapse = thresholdTitle.closest<HTMLElement>('.semi-collapse'); const thresholdCollapse = thresholdTitle.closest<HTMLElement>('.semi-collapse');
expect(thresholdCollapse).toHaveClass('v2-access-settings'); expect(thresholdCollapse).toHaveClass('v2-access-settings');
expect(within(thresholdCollapse!).getByText('v1').closest('.semi-tag')).toBeTruthy(); expect(within(thresholdCollapse!).getByText('v1').closest('.semi-tag')).toBeTruthy();
expect(thresholdTitle.closest('.semi-collapse-header')).toHaveAttribute('aria-expanded', 'false');
expect(within(thresholdCollapse!).queryByRole('spinbutton')).not.toBeInTheDocument();
fireEvent.click(thresholdTitle.closest('.semi-collapse-header')!);
expect(thresholdTitle.closest('.semi-collapse-header')).toHaveAttribute('aria-expanded', 'true');
expect(within(thresholdCollapse!).getAllByRole('spinbutton')).toHaveLength(6);
expect(within(thresholdCollapse!).getByText('通用判定')).toBeInTheDocument();
expect(within(thresholdCollapse!).getByText('协议在线阈值')).toBeInTheDocument();
expect(within(thresholdCollapse!).getAllByText('5 分 0 秒').length).toBeGreaterThan(0);
expect(within(thresholdCollapse!).getByText('1 分 0 秒')).toBeInTheDocument();
expect(within(thresholdCollapse!).getByText('24 小时')).toBeInTheDocument();
expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument(); expect(screen.queryByText('待绑定身份服务超时')).not.toBeInTheDocument();
expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument(); expect(screen.queryByText('阈值配置读取失败')).not.toBeInTheDocument();
expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2); expect(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2);

View File

@@ -21,6 +21,11 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y'; import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const; const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
const protocolThresholdMeta: Record<(typeof PROTOCOLS)[number], { label: string; description: string }> = {
GB32960: { label: 'GB32960', description: '国标远程服务' },
JT808: { label: 'JT808', description: '道路运输终端' },
YUTONG_MQTT: { label: 'YUTONG MQTT', description: '宇通车辆平台' }
};
const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' }); const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' });
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' }; const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
type Filters = typeof EMPTY_FILTERS; type Filters = typeof EMPTY_FILTERS;
@@ -261,22 +266,52 @@ function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) { function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
if (!total) return null; if (!total) return null;
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" keepDOM> return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" defaultActiveKey="unresolved-identities" keepDOM={false} lazyRender>
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong></strong><small> VIN </small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} </Tag></span>}> <Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong></strong><small> VIN </small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} </Tag></span>}>
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}> <div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
<header><b>{item.identifierMasked}</b><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header> <header><span><small></small><b>{item.identifierMasked}</b></span><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
<span>{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span> <div className="v2-access-identity-facts">
<small>{item.recommendedAction}</small> <span><small></small><strong>{item.plate || '待核对'}</strong></span>
<span><small></small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
</div>
<footer><small></small><strong>{item.recommendedAction}</strong></footer>
</Card>)}</div> </Card>)}</div>
{total > 6 ? <p className="v2-access-identity-more"> 6 {(total - 6).toLocaleString('zh-CN')} </p> : null}
</Collapse.Panel> </Collapse.Panel>
</Collapse>; </Collapse>;
} }
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) { function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
if (!draft) return null; if (!draft) return null;
return <Collapse className="v2-access-settings" keepDOM> const thresholdField = (label: string, description: string, value: number, update: (next: number) => void) => <label>
<span><b>{label}</b><small>{description}</small></span>
<Input suffix="秒" type="number" value={String(value)} onChange={(next) => update(Number(next))} />
<em>{formatSeconds(value)}</em>
</label>;
return <Collapse className="v2-access-settings" keepDOM={false} lazyRender>
<Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>线</strong><small>线线</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}> <Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>线</strong><small>线线</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}>
<fieldset disabled={!editable}><label><span></span><Input suffix="秒" type="number" value={String(draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, defaultThresholdSec: Number(value) })} /></label><label><span>线</span><Input suffix="秒" type="number" value={String(draft.longOfflineSec)} onChange={(value) => onChange({ ...draft, longOfflineSec: Number(value) })} /></label>{PROTOCOLS.map((protocol) => <label key={protocol}><span>{protocol}</span><Input suffix="秒" type="number" value={String(draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec)} onChange={(value) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, Number(value)) })} /></label>)}{error ? <p>{error}</p> : null}{editable ? <Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button> : <small></small>}</fieldset> <fieldset disabled={!editable}>
<section className="v2-access-threshold-group">
<header><strong></strong><small></small></header>
<div className="v2-access-threshold-fields">
{thresholdField('全局在线阈值', '未单独配置协议时使用', draft.defaultThresholdSec, (value) => onChange({ ...draft, defaultThresholdSec: value }))}
{thresholdField('数据延迟异常', '事件时间与接收时间差值', draft.delayThresholdSec, (value) => onChange({ ...draft, delayThresholdSec: value }))}
{thresholdField('长离线阈值', '进入长期离线治理范围', draft.longOfflineSec, (value) => onChange({ ...draft, longOfflineSec: value }))}
</div>
</section>
<section className="v2-access-threshold-group">
<header><strong>线</strong><small></small></header>
<div className="v2-access-threshold-fields">
{PROTOCOLS.map((protocol) => {
const value = draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec;
const meta = protocolThresholdMeta[protocol];
return <span key={protocol}>{thresholdField(meta.label, meta.description, value, (next) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, next) }))}</span>;
})}
</div>
</section>
{error ? <p className="v2-access-threshold-error" role="alert">{error}</p> : null}
{editable ? <footer className="v2-access-threshold-footer"><span><strong></strong><small>线</small></span><Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button></footer> : <small></small>}
</fieldset>
</Collapse.Panel> </Collapse.Panel>
</Collapse>; </Collapse>;
} }
@@ -440,7 +475,7 @@ export default function AccessPage() {
> >
{governanceOpen ? <div className="v2-access-governance"> {governanceOpen ? <div className="v2-access-governance">
<Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}> <Card className="v2-access-governance-summary" bodyStyle={{ padding: 0 }}>
<div><small></small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong></div> <div className={(unresolvedQuery.data?.total ?? 0) > 0 ? 'is-attention' : ''}><small></small><strong>{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}</strong><span>{(unresolvedQuery.data?.total ?? 0) > 0 ? '优先处理' : '暂无待办'}</span></div>
<div><small></small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div> <div><small></small><strong>v{thresholdQuery.data?.version ?? '—'}</strong></div>
<div><small></small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div> <div><small></small><strong>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
</Card> </Card>

View File

@@ -1768,6 +1768,21 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.v2-access-governance-summary span {
color: #8a97a8;
font-size: 9px;
font-weight: 650;
}
.v2-access-governance-summary .is-attention {
background: linear-gradient(135deg, #fffaf3 0%, #fff4e7 100%);
}
.v2-access-governance-summary .is-attention strong,
.v2-access-governance-summary .is-attention span {
color: #b8670b;
}
.v2-access-governance .v2-access-identity-queue-v3, .v2-access-governance .v2-access-identity-queue-v3,
.v2-access-governance .v2-access-settings { .v2-access-governance .v2-access-settings {
border-radius: 10px; border-radius: 10px;
@@ -1778,9 +1793,13 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.v2-access-governance .v2-access-identity-grid {
grid-template-columns: minmax(0, 1fr);
}
.v2-access-governance .v2-access-settings fieldset { .v2-access-governance .v2-access-settings fieldset {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
} }
.v2-access-governance .v2-access-settings label, .v2-access-governance .v2-access-settings label,
@@ -1795,6 +1814,197 @@
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.v2-access-identity-card header > span {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-access-identity-card header small,
.v2-access-identity-facts small,
.v2-access-identity-card footer small {
color: #8a97a8;
font-size: 9px;
font-weight: 500;
}
.v2-access-identity-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
border: 1px solid #e7edf4;
border-radius: 7px;
background: #fff;
}
.v2-access-identity-facts > span {
display: grid;
min-width: 0;
gap: 4px;
padding: 8px 9px;
}
.v2-access-identity-facts > span + span {
border-left: 1px solid #e7edf4;
}
.v2-access-identity-facts strong {
overflow: hidden;
color: #42546b;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-access-identity-card footer {
display: grid;
gap: 3px;
border-top: 1px solid #e7edf4;
padding-top: 8px;
}
.v2-access-identity-card footer strong {
color: #53677f;
font-size: 10px;
font-weight: 650;
line-height: 1.45;
}
.v2-access-identity-more {
margin: 0;
border-top: 1px solid #e7edf4;
background: #fbfcfe;
padding: 9px 12px;
color: #7f8d9f;
font-size: 9px;
line-height: 1.5;
}
.v2-access-threshold-group {
display: grid;
gap: 10px;
border: 1px solid #e1e8f1;
border-radius: 9px;
background: #fbfcfe;
padding: 11px;
}
.v2-access-threshold-group > header {
display: flex;
min-width: 0;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.v2-access-threshold-group > header strong {
color: #34475e;
font-size: 11px;
}
.v2-access-threshold-group > header small {
overflow: hidden;
color: #8794a6;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-access-threshold-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.v2-access-threshold-fields > span {
display: contents;
}
.v2-access-settings .v2-access-threshold-fields label {
display: grid;
min-width: 0;
gap: 6px;
border: 1px solid #e5ebf2;
border-radius: 8px;
background: #fff;
padding: 9px;
}
.v2-access-settings .v2-access-threshold-fields label > span {
display: grid;
min-width: 0;
gap: 2px;
}
.v2-access-settings .v2-access-threshold-fields label b {
overflow: hidden;
color: #4a5c74;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-access-settings .v2-access-threshold-fields label small {
overflow: hidden;
color: #8b97a8;
font-size: 8px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-access-settings .v2-access-threshold-fields label > .semi-input-wrapper {
width: 100%;
}
.v2-access-settings .v2-access-threshold-fields label > em {
color: #63758c;
font-size: 9px;
font-style: normal;
font-variant-numeric: tabular-nums;
}
.v2-access-threshold-error {
margin: 0;
border: 1px solid #f1d0cd;
border-radius: 8px;
background: #fff6f5;
padding: 9px 11px;
color: #b54840;
font-size: 10px;
}
.v2-access-threshold-footer {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid #e4eaf2;
padding-top: 2px;
}
.v2-access-threshold-footer > span {
display: grid;
min-width: 0;
gap: 3px;
}
.v2-access-threshold-footer strong {
color: #43566e;
font-size: 10px;
}
.v2-access-threshold-footer small {
color: #8794a6;
font-size: 9px;
}
.v2-access-threshold-footer > .semi-button {
min-width: 108px;
flex: 0 0 auto;
}
@media (max-width: 1180px) { @media (max-width: 1180px) {
.v2-alert-filter.v2-alert-filter-primary { .v2-alert-filter.v2-alert-filter-primary {
grid-template-columns: minmax(250px, 1.35fr) minmax(130px, .65fr) minmax(140px, .7fr) auto auto auto; grid-template-columns: minmax(250px, 1.35fr) minmax(130px, .65fr) minmax(140px, .7fr) auto auto auto;
@@ -9760,6 +9970,7 @@
} }
.v2-access-table-scroll-v3 table { .v2-access-table-scroll-v3 table {
width: max(100%, 1005px) !important;
min-width: 1005px; min-width: 1005px;
} }
@@ -10400,6 +10611,64 @@
font-size: 16px; font-size: 16px;
} }
.v2-access-governance {
gap: 9px;
}
.v2-access-governance .v2-access-identity-queue-v3 .semi-collapse-header,
.v2-access-governance .v2-access-settings .semi-collapse-header {
min-height: 58px;
}
.v2-access-governance .v2-access-collapse-title small {
max-width: 245px;
}
.v2-access-identity-facts strong,
.v2-access-identity-card footer strong {
font-size: 11px;
}
.v2-access-threshold-group {
gap: 8px;
padding: 9px;
}
.v2-access-threshold-group > header {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.v2-access-threshold-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.v2-access-settings .v2-access-threshold-fields label {
gap: 5px;
padding: 8px;
}
.v2-access-settings .v2-access-threshold-fields label > .semi-input-wrapper {
height: 38px;
}
.v2-access-settings .v2-access-threshold-fields label .semi-input {
height: 36px;
font-size: 11px;
}
.v2-access-threshold-footer {
align-items: stretch;
flex-direction: column;
}
.v2-access-threshold-footer > .semi-button {
width: 100%;
min-height: 40px;
}
.v2-access-detail-sidesheet .v2-access-protocol-details.semi-card-group { .v2-access-detail-sidesheet .v2-access-protocol-details.semi-card-group {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);