From 5d4aa92cc19c644cbdcc8cb89296118912f0a68a Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 18 Jul 2026 19:12:07 +0800 Subject: [PATCH] refine Semi UI access governance --- .../apps/web/src/v2/pages/AccessPage.test.tsx | 15 + .../apps/web/src/v2/pages/AccessPage.tsx | 49 +++- .../apps/web/src/v2/styles/workspace.css | 271 +++++++++++++++++- 3 files changed, 327 insertions(+), 8 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx index cf295813..e5388479 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.test.tsx @@ -133,13 +133,28 @@ test('reports and independently retries unresolved identity and threshold failur const identityTitle = await screen.findByText('来源身份待绑定'); const identityCollapse = identityTitle.closest('.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('.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); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx index aa266f00..96df85cc 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/AccessPage.tsx @@ -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 + return 来源身份待绑定这些来源不计入主车辆,绑定权威 VIN 后再归档{total.toLocaleString('zh-CN')} 条}>
{items.slice(0, 6).map((item) => -
{item.identifierMasked}{item.protocol}
- {item.plate || '车牌待核对'} · {formatAccessTime(item.latestSeenAt)} - {item.recommendedAction} +
来源标识{item.identifierMasked}{item.protocol}
+
+ 关联车牌{item.plate || '待核对'} + 最后出现 +
+
建议动作{item.recommendedAction}
)}
+ {total > 6 ?

当前展示最近 6 条,剩余 {(total - 6).toLocaleString('zh-CN')} 条可通过来源检索继续核对。

: null}
; } 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 + const thresholdField = (label: string, description: string, value: number, update: (next: number) => void) => ; + return 在线判定阈值统一控制来源在线、长离线和协议新鲜度判定v{config?.version ?? '—'}}> -
{PROTOCOLS.map((protocol) => )}{error ?

{error}

: null}{editable ? : 当前账户为只读角色}
+
+
+
通用判定先定义全局兜底,再按协议覆盖
+
+ {thresholdField('全局在线阈值', '未单独配置协议时使用', draft.defaultThresholdSec, (value) => onChange({ ...draft, defaultThresholdSec: value }))} + {thresholdField('数据延迟异常', '事件时间与接收时间差值', draft.delayThresholdSec, (value) => onChange({ ...draft, delayThresholdSec: value }))} + {thresholdField('长离线阈值', '进入长期离线治理范围', draft.longOfflineSec, (value) => onChange({ ...draft, longOfflineSec: value }))} +
+
+
+
协议在线阈值匹配不同协议的真实上报周期
+
+ {PROTOCOLS.map((protocol) => { + const value = draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec; + const meta = protocolThresholdMeta[protocol]; + return {thresholdField(meta.label, meta.description, value, (next) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, next) }))}; + })} +
+
+ {error ?

{error}

: null} + {editable ?
保存后立即生效列表在线状态与治理统计会按新版本重新计算
: 当前账户为只读角色} +
; } @@ -440,7 +475,7 @@ export default function AccessPage() { > {governanceOpen ?
-
待绑定来源{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}
+
0 ? 'is-attention' : ''}>待绑定来源{(unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN')}{(unresolvedQuery.data?.total ?? 0) > 0 ? '优先处理' : '暂无待办'}
阈值版本v{thresholdQuery.data?.version ?? '—'}
主车辆口径{(summary?.totalVehicles ?? 0).toLocaleString('zh-CN')}
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index ac829650..a0cdaf21 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -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);