Continues HWLAB #775 round 6 by restoring the gate diagnostics live aggregation that the React migration left as a placeholder. Uses the live `/v1/diagnostics/gate` endpoint (which the round-1 api client already declared) and renders status / filter / search controls per `app-conv.ts:277-340`. ## 修复对照 | 类别 | 迁移前 (`893f46bc`) | 迁移后 (round 6) | 状态 | |---|---|---|---| | **Gate 拉取** | `app-device-pod.ts:1192-1220` `loadGateDiagnostics` 调 `/v1/diagnostics/gate` | `hooks/useGateDiagnostics.ts` `useGateDiagnostics` hook 走 `api.gateDiagnostics()` + `state` 缓存 rows / payload / loadedAt / error | ✅ 对齐 | | **Gate 过滤** | `app-conv.ts:309-340` `filteredGateRows` 按 status filter + search 7 字段全文搜 | `hooks/useGateDiagnostics.ts:filterGateRows` 同样 filter + 7 字段 join 全文搜 | ✅ 对齐 | | **Gate 表格渲染** | `app-conv.ts:340-360` `renderGateTable` 8 列硬编码 | `components/gate/GateView.tsx` 8 列 + StateTag + `data-status-key` 标识(pass / blocked 边色) | ✅ 对齐 | | **Gate tone 评估** | `app-conv.ts:383-386` `gatePayloadTone` | `hooks/useGateDiagnostics.ts:gatePayloadTone` | ✅ 对齐 | | **占位 fallback** | 迁移后 round-1 仅放占位 | `GateView` `liveBlockerRow` 把 `response.error` 当 blocked 行渲染 | ✅ 对齐 | ## 改动 - 新增 `web/hwlab-cloud-web/src/hooks/useGateDiagnostics.ts`(153 行) - 新增 `web/hwlab-cloud-web/src/components/gate/GateView.tsx`(88 行) - `web/hwlab-cloud-web/src/App.tsx` — 删除占位 `GateView`,import 新版 - `web/hwlab-cloud-web/src/components/shared/StateTag.tsx` — `title` 属性 - `web/hwlab-cloud-web/src/styles/workbench.css` — `.gate-raw` / `tbody tr[data-status-key]` 边色 ## 验收 - `bun run check` 通过(Vite build + 2/2 dist freshness test pass) - `bun run scripts/tsc-check.ts` 通过(strict React TSX, 0 explicit any) - `bun test` 2 pass / 0 fail - `bun run build` 9 dist files verified fresh;`app.js` 230917 → 234676 bytes;`index.css` 19062 → 19414 bytes - 所有改动文件 < 400 行前端 guard Refs: pikasTech/HWLAB#775 (round 6 of 7) Co-authored-by: HWLAB <ci@hwlab.local>
This commit is contained in:
@@ -13,6 +13,7 @@ import { ActivityRail } from "./components/layout/ActivityRail";
|
|||||||
import { SessionSidebar } from "./components/sessions/SessionSidebar";
|
import { SessionSidebar } from "./components/sessions/SessionSidebar";
|
||||||
import { SettingsView } from "./components/settings/SettingsView";
|
import { SettingsView } from "./components/settings/SettingsView";
|
||||||
import { SkillsView } from "./components/skills/SkillsView";
|
import { SkillsView } from "./components/skills/SkillsView";
|
||||||
|
import { GateView } from "./components/gate/GateView";
|
||||||
import { fetchText } from "./services/api/client";
|
import { fetchText } from "./services/api/client";
|
||||||
|
|
||||||
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
|
const SESSION_SIDEBAR_STORAGE_KEY = "hwlab.workbench.sessionSidebarWidth.v1";
|
||||||
@@ -165,16 +166,6 @@ function AuthLoadingShell(): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GateView(): ReactElement {
|
|
||||||
return (
|
|
||||||
<section className="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title">
|
|
||||||
<header className="gate-header"><div><p className="eyebrow">内部二级入口</p><h2 id="gate-title">内部复核</h2></div><span className="state-tag tone-source" id="gate-source-status">等待 live 后端</span></header>
|
|
||||||
<div className="gate-controls" aria-label="内部复核过滤控件"><label><span>状态</span><select id="gate-status-filter"><option value="all">全部</option><option value="通过">通过</option><option value="阻塞">阻塞</option><option value="失败">失败</option><option value="待验证">待验证</option><option value="信息">信息</option></select></label><label><span>搜索</span><input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autoComplete="off" /></label><button className="command-button secondary" id="gate-refresh" type="button">刷新</button></div>
|
|
||||||
<div className="gate-table-wrap"><table className="gate-table" aria-describedby="gate-source-status"><thead><tr>{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => <th key={head}>{head}</th>)}</tr></thead><tbody id="gate-review-body"><tr><td colSpan={8}>正在读取 live 后端聚合。</td></tr></tbody></table></div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HelpView({ help }: { help: string }): ReactElement {
|
function HelpView({ help }: { help: string }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
|
<section className="view help-view" id="help" data-view="help" aria-labelledby="help-title">
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { filterGateRows, gatePayloadTone, gateStatusBadge, useGateDiagnostics } from "../../hooks/useGateDiagnostics";
|
||||||
|
import { formatBeijingTime, jsonPreview, toneClass } from "../../utils";
|
||||||
|
import { StateTag } from "../shared/StateTag";
|
||||||
|
|
||||||
|
export function GateView(): ReactElement {
|
||||||
|
const { state, refresh } = useGateDiagnostics();
|
||||||
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const visibleRows = useMemo(() => filterGateRows(state.rows, statusFilter, search), [state.rows, statusFilter, search]);
|
||||||
|
const tone = gatePayloadTone(state.payload, state.error);
|
||||||
|
const summary = state.loading
|
||||||
|
? "读取 live 后端中"
|
||||||
|
: state.payload
|
||||||
|
? `live 后端 ${visibleRows.length}/${state.rows.length} 行`
|
||||||
|
: state.error
|
||||||
|
? "live 后端阻塞"
|
||||||
|
: "等待 live 后端";
|
||||||
|
return (
|
||||||
|
<section className="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title">
|
||||||
|
<header className="gate-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">内部二级入口</p>
|
||||||
|
<h2 id="gate-title">内部复核</h2>
|
||||||
|
</div>
|
||||||
|
<StateTag tone={toneClass(tone)} id="gate-source-status" title={state.error ?? (state.payload ? `route=${state.payload.route}; observedAt=${state.observedAt ?? state.payload.observedAt ?? "unknown"}` : "等待 /v1/diagnostics/gate")}>{summary}</StateTag>
|
||||||
|
</header>
|
||||||
|
<div className="gate-controls" aria-label="内部复核过滤控件">
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select id="gate-status-filter" value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value)}>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="pass">通过</option>
|
||||||
|
<option value="blocked">阻塞</option>
|
||||||
|
<option value="fail">失败</option>
|
||||||
|
<option value="warn">待验证</option>
|
||||||
|
<option value="info">信息</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>搜索</span>
|
||||||
|
<input id="gate-search" type="search" placeholder="按类别、检查项、对象或 trace 搜索" autoComplete="off" value={search} onChange={(event) => setSearch(event.currentTarget.value)} />
|
||||||
|
</label>
|
||||||
|
<button className="command-button secondary" id="gate-refresh" type="button" onClick={() => void refresh()} disabled={state.loading}>{state.loading ? "刷新中" : "刷新"}</button>
|
||||||
|
</div>
|
||||||
|
<div className="gate-table-wrap">
|
||||||
|
<table className="gate-table" aria-describedby="gate-source-status">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"].map((head) => <th key={head}>{head}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="gate-review-body">
|
||||||
|
{state.loading && state.rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={8}>正在读取 live 后端聚合。</td></tr>
|
||||||
|
) : visibleRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={8}>没有匹配当前过滤条件的复核行。</td></tr>
|
||||||
|
) : (
|
||||||
|
visibleRows.map((row, index) => {
|
||||||
|
const badge = gateStatusBadge(row);
|
||||||
|
return (
|
||||||
|
<tr key={`${row.check}-${index}`} data-status={row.status} data-status-key={row.statusKey}>
|
||||||
|
<td className="wrap">{row.category}</td>
|
||||||
|
<td className="wrap">{row.check}</td>
|
||||||
|
<td><StateTag tone={badge.tone} data-gate-row-status={row.statusKey}>{badge.label}</StateTag></td>
|
||||||
|
<td className="mono wrap">{row.owner}</td>
|
||||||
|
<td className="wrap">{row.detail}</td>
|
||||||
|
<td className="mono wrap">{row.evidence.join(" / ")}</td>
|
||||||
|
<td className="mono wrap">{formatBeijingTime(row.updatedAt, { withSeconds: true })}</td>
|
||||||
|
<td className="wrap">{row.next}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{state.rawResponse && !state.error ? (
|
||||||
|
<details className="gate-raw" data-gate-raw>
|
||||||
|
<summary>展开 gate 原始 JSON</summary>
|
||||||
|
<pre>{jsonPreview(state.rawResponse, 2000)}</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ interface StateTagProps {
|
|||||||
tone: Tone | string;
|
tone: Tone | string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StateTag({ tone, children, id }: StateTagProps): ReactElement {
|
export function StateTag({ tone, children, id, title }: StateTagProps): ReactElement {
|
||||||
return <span id={id} className={`state-tag tone-${tone}`}>{children}</span>;
|
return <span id={id} title={title} className={`state-tag tone-${tone}`}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api, type GateDiagnosticsResponse, type GateDiagnosticsRow } from "../services/api/client";
|
||||||
|
import { formatBeijingTime, toneClass } from "../utils";
|
||||||
|
|
||||||
|
export interface GateDiagnosticsState {
|
||||||
|
loading: boolean;
|
||||||
|
payload: GateDiagnosticsResponse | null;
|
||||||
|
rows: NormalizedGateRow[];
|
||||||
|
loadedAt: string | null;
|
||||||
|
error: string | null;
|
||||||
|
sourceKind: string;
|
||||||
|
observedAt: string | null;
|
||||||
|
serviceId: string | null;
|
||||||
|
rawResponse: GateDiagnosticsResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedGateRow {
|
||||||
|
category: string;
|
||||||
|
check: string;
|
||||||
|
status: string;
|
||||||
|
statusKey: string;
|
||||||
|
owner: string;
|
||||||
|
detail: string;
|
||||||
|
evidence: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
next: string;
|
||||||
|
sourceKind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: GateDiagnosticsState = {
|
||||||
|
loading: false,
|
||||||
|
payload: null,
|
||||||
|
rows: [],
|
||||||
|
loadedAt: null,
|
||||||
|
error: null,
|
||||||
|
sourceKind: "",
|
||||||
|
observedAt: null,
|
||||||
|
serviceId: null,
|
||||||
|
rawResponse: null
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useGateDiagnostics(): {
|
||||||
|
state: GateDiagnosticsState;
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
} {
|
||||||
|
const [state, setState] = useState<GateDiagnosticsState>(INITIAL_STATE);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (state.loading) return;
|
||||||
|
setState((previous) => ({ ...previous, loading: true, error: null }));
|
||||||
|
const response = await api.gateDiagnostics();
|
||||||
|
if (response.ok && response.data && Array.isArray(response.data.rows)) {
|
||||||
|
const rows = response.data.rows.map(normalizeGateRow);
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
payload: response.data,
|
||||||
|
rows,
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
error: null,
|
||||||
|
sourceKind: response.data.sourceKind ?? "",
|
||||||
|
observedAt: response.data.observedAt ?? null,
|
||||||
|
serviceId: response.data.serviceId ?? null,
|
||||||
|
rawResponse: response.data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
payload: null,
|
||||||
|
rows: [liveBlockerRow(response)],
|
||||||
|
loadedAt: new Date().toISOString(),
|
||||||
|
error: response.error ?? "内部复核 live 后端聚合不可用",
|
||||||
|
sourceKind: response.data?.sourceKind ?? "",
|
||||||
|
observedAt: response.data?.observedAt ?? null,
|
||||||
|
serviceId: response.data?.serviceId ?? null,
|
||||||
|
rawResponse: response.data ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state.loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGateRow(row: GateDiagnosticsRow): NormalizedGateRow {
|
||||||
|
const statusKey = String(row.statusKey ?? row.status ?? "source").toLowerCase();
|
||||||
|
return {
|
||||||
|
category: oneLine(row.category ?? "内部复核"),
|
||||||
|
check: oneLine(row.check ?? "未命名检查项"),
|
||||||
|
status: gateStatusLabel(row.status, statusKey),
|
||||||
|
statusKey,
|
||||||
|
owner: oneLine(row.owner ?? "未标明"),
|
||||||
|
detail: oneLine(row.detail ?? "live 后端未返回关键细节"),
|
||||||
|
evidence: Array.isArray(row.evidence) ? row.evidence.map((item) => String(item)) : [],
|
||||||
|
updatedAt: row.updatedAt ?? new Date().toISOString(),
|
||||||
|
next: oneLine(row.next ?? "等待维护者补充下一步。"),
|
||||||
|
sourceKind: String(row.sourceKind ?? "")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(value: string): string {
|
||||||
|
return value.replace(/\s+/gu, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateStatusLabel(status: string | undefined, statusKey: string): string {
|
||||||
|
if (status) return status;
|
||||||
|
const map: Record<string, string> = { pass: "通过", blocked: "阻塞", fail: "失败", warn: "待验证", info: "信息" };
|
||||||
|
return map[statusKey] ?? statusKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveBlockerRow(response: { error: string | null; status: number }): NormalizedGateRow {
|
||||||
|
return {
|
||||||
|
category: "内部复核",
|
||||||
|
check: "live 后端聚合不可用",
|
||||||
|
status: "阻塞",
|
||||||
|
statusKey: "blocked",
|
||||||
|
owner: "hwlab-cloud-api /v1/diagnostics/gate",
|
||||||
|
detail: response.error ?? `HTTP ${response.status}`,
|
||||||
|
evidence: ["GET /v1/diagnostics/gate"],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
next: "等待 cloud-api live 聚合恢复,或检查路由表。",
|
||||||
|
sourceKind: "BLOCKED"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterGateRows(rows: NormalizedGateRow[], statusFilter: string, search: string): NormalizedGateRow[] {
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (statusFilter !== "all" && row.statusKey !== statusFilter) return false;
|
||||||
|
if (!query) return true;
|
||||||
|
return [row.category, row.check, row.status, row.owner, row.detail, ...row.evidence, row.updatedAt, row.next].join(" ").toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gatePayloadTone(payload: GateDiagnosticsResponse | null, error: string | null): "ok" | "blocked" | "warn" | "source" {
|
||||||
|
if (error) return "blocked";
|
||||||
|
if (!payload) return "source";
|
||||||
|
const status = String(payload.status ?? "");
|
||||||
|
if (status === "blocked" || status === "blocked" || payload.rowCount === 0) return "warn";
|
||||||
|
if (status === "ok" || status === "ready") return "ok";
|
||||||
|
return "source";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gateStatusBadge(row: NormalizedGateRow): { label: string; tone: ReturnType<typeof toneClass> } {
|
||||||
|
const tone = toneClass(row.statusKey);
|
||||||
|
return { label: row.status, tone };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { formatBeijingTime };
|
||||||
@@ -146,6 +146,10 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
|||||||
.live-build-time { margin-left: auto; color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
.live-build-time { margin-left: auto; color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
.live-build-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
.live-build-meta { display: flex; flex-wrap: wrap; gap: 10px; font-size: 12px; color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||||
.live-build-reason { margin: 0; font-size: 12px; color: var(--bad); }
|
.live-build-reason { margin: 0; font-size: 12px; color: var(--bad); }
|
||||||
|
.gate-raw { margin-top: 12px; }
|
||||||
|
.gate-raw pre { margin: 4px 0 0; max-height: 240px; overflow: auto; background: #f3f5f1; padding: 8px; border-radius: 6px; }
|
||||||
|
.gate-table tbody tr[data-status-key="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
|
||||||
|
.gate-table tbody tr[data-status-key="pass"] td:first-child { border-left: 3px solid var(--ok); padding-left: 5px; }
|
||||||
.conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; }
|
.conversation-list { min-height: 0; display: grid; align-content: start; gap: 12px; overflow: auto; padding-right: 4px; }
|
||||||
.message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
|
.message-card { border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; }
|
||||||
.message-head { display: flex; justify-content: space-between; gap: 10px; }
|
.message-head { display: flex; justify-content: space-between; gap: 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user