refine Semi UI access governance
This commit is contained in:
@@ -133,13 +133,28 @@ test('reports and independently retries unresolved identity and threshold failur
|
||||
const identityTitle = await screen.findByText('来源身份待绑定');
|
||||
const identityCollapse = identityTitle.closest<HTMLElement>('.semi-collapse');
|
||||
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('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 thresholdCollapse = thresholdTitle.closest<HTMLElement>('.semi-collapse');
|
||||
expect(thresholdCollapse).toHaveClass('v2-access-settings');
|
||||
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(mocks.accessUnresolvedIdentities).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -21,6 +21,11 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
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 EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||||
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 }) {
|
||||
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>}>
|
||||
<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>
|
||||
<span>{item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)}</span>
|
||||
<small>{item.recommendedAction}</small>
|
||||
<header><span><small>来源标识</small><b>{item.identifierMasked}</b></span><Tag color="blue" type="light" size="small">{item.protocol}</Tag></header>
|
||||
<div className="v2-access-identity-facts">
|
||||
<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>
|
||||
{total > 6 ? <p className="v2-access-identity-more">当前展示最近 6 条,剩余 {(total - 6).toLocaleString('zh-CN')} 条可通过来源检索继续核对。</p> : null}
|
||||
</Collapse.Panel>
|
||||
</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 }) {
|
||||
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>}>
|
||||
<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>;
|
||||
}
|
||||
@@ -440,7 +475,7 @@ export default function AccessPage() {
|
||||
>
|
||||
{governanceOpen ? <div className="v2-access-governance">
|
||||
<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>{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}</strong></div>
|
||||
</Card>
|
||||
|
||||
@@ -1768,6 +1768,21 @@
|
||||
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-settings {
|
||||
border-radius: 10px;
|
||||
@@ -1778,9 +1793,13 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.v2-access-governance .v2-access-identity-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-access-governance .v2-access-settings fieldset {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.v2-access-governance .v2-access-settings label,
|
||||
@@ -1795,6 +1814,197 @@
|
||||
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) {
|
||||
.v2-alert-filter.v2-alert-filter-primary {
|
||||
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 {
|
||||
width: max(100%, 1005px) !important;
|
||||
min-width: 1005px;
|
||||
}
|
||||
|
||||
@@ -10400,6 +10611,64 @@
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user