diff --git a/AGENTS.md b/AGENTS.md index 768132137..83a3b649b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ - **i18n**:所有用户文案使用 `tr('中文', 'English')`,包含占位符、空态、错误、Tooltip 和可访问名称;不在同一字符串中英并排,检查较长英文与长选项是否挤压布局。 - **验收与维护**:按设计规范末尾清单检查布局、交互、窄屏、中英文及明暗主题;视觉改动提交前必须通过真实浏览器截图实看,light / dark 各一张,纯文档变更无需截图。运行相关交互测试、构建与 lint,如实记录未验证项和既有失败。公共规则变更在同一 PR 更新设计规范与相关测试,避免页面各自演变。 +- **表格操作列**:遵循设计规范「表格操作列」:按内容收紧,表头与按钮组左对齐;固定列复用 `.og-action-table` 不透明底色。验证横向滚动、深浅主题和不同权限下的按钮数量,不能仅检查满按钮状态。 + ### API - 所有 API 变更先更新 `.proto`,禁止改生成代码 - Handler 必须有 Swagger 注释:`@Summary`、`@Router`、`@Success` 缺一不可 diff --git a/docs/design/frontend-design-language.md b/docs/design/frontend-design-language.md index bfc5cffc3..258d77e7b 100644 --- a/docs/design/frontend-design-language.md +++ b/docs/design/frontend-design-language.md @@ -19,6 +19,12 @@ 6. 资源清单可将筛选栏与表格放在同一 `Card`,表格复用 `.og-resource-table` 的表头、行距与悬停样式。列表与配置详情随内容区宽度伸展,页头与正文保持对齐,不以固定最大宽度造成宽屏两侧大面积留白;阅读宽度限制仅用于长文本或表单。已保存目标按列展示,新增操作保留在列表底部。发现结果选择使用 `Radio` 配合 `.og-choice-row`,整行 `label` 可点击,选中和禁用状态保持一致;已配置范围明确标注,不能只显示一个不可点击的单选框。 7. 采集配置详情的表格使用 `.og-resource-table[data-variant="quiet"]`:表头与正文共用卡片底色,仅保留淡化的行间分隔;卡片标题和底部新增入口通过间距区分,不重复添加横线。 +### 表格操作列 + +- 操作列表头与按钮组统一左对齐,按内容收紧;自动布局使用 `w-px` 配合 `whitespace-nowrap`,不为预估的按钮数量写死 320/340px 宽度。按钮过多时收进公共下拉菜单,不扩大操作列挤压信息列。 +- 带固定操作列的表格使用 `.og-action-table`,固定单元格保留 `sticky right-0` 与层级。普通行和固定列共用不透明底色,表头也保持一致;不能用透明固定列透出下面的文字,也不能独立选一个更亮的底色。 +- 验证深浅主题、默认与悬停状态、横向滚动、中英文、不同权限与按钮数量。新增选中态时必须同步整行与固定单元格底色。 + ## 配色、文字与图标 | 用途 | 约定 | diff --git a/web/src/components/XTerminal.test.tsx b/web/src/components/XTerminal.test.tsx index 56fb1db95..80e4374d8 100644 --- a/web/src/components/XTerminal.test.tsx +++ b/web/src/components/XTerminal.test.tsx @@ -1,15 +1,19 @@ import { act, render } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { XTerminal } from './XTerminal'; +import { XTerminal, type XTerminalApi } from './XTerminal'; import { setThemePreference } from '@/store/mode'; const terminalMock = vi.hoisted(() => ({ instances: [] as Array<{ options: { theme?: { background?: string; foreground?: string; white?: string } } }>, + dimensions: { cols: 132, rows: 42 }, })); vi.mock('xterm', () => ({ Terminal: class Terminal { + cols = 80; + rows = 24; + resizeListener?: () => void; options: { theme?: { background?: string; foreground?: string; white?: string } }; constructor(options: { theme?: { background?: string; foreground?: string; white?: string } }) { @@ -17,10 +21,16 @@ vi.mock('xterm', () => ({ terminalMock.instances.push(this); } - loadAddon() {} + loadAddon(addon: { activate?(terminal: Terminal): void }) { addon.activate?.(this); } open() {} onData() { return { dispose() {} }; } - onResize() { return { dispose() {} }; } + onResize(listener: () => void) { this.resizeListener = listener; return { dispose() {} }; } + resize(cols: number, rows: number) { + if (this.cols === cols && this.rows === rows) return; + this.cols = cols; + this.rows = rows; + this.resizeListener?.(); + } attachCustomKeyEventHandler() {} write() {} writeln() {} @@ -31,7 +41,11 @@ vi.mock('xterm', () => ({ })); vi.mock('xterm-addon-fit', () => ({ - FitAddon: class FitAddon { fit() {} }, + FitAddon: class FitAddon { + terminal?: { resize(cols: number, rows: number): void }; + activate(terminal: { resize(cols: number, rows: number): void }) { this.terminal = terminal; } + fit() { this.terminal?.resize(terminalMock.dimensions.cols, terminalMock.dimensions.rows); } + }, })); vi.mock('xterm-addon-web-links', () => ({ @@ -46,9 +60,36 @@ vi.stubGlobal('ResizeObserver', class ResizeObserver { describe('XTerminal', () => { beforeEach(() => { terminalMock.instances.length = 0; + terminalMock.dimensions = { cols: 132, rows: 42 }; localStorage.clear(); }); + it('reports initial fitted dimensions before any observer callback', () => { + const onResize = vi.fn(); + render( {}} onResize={onResize} />); + expect(onResize.mock.calls).toEqual([[132, 42]]); + }); + + it('reports the initial default grid even without an xterm resize event', () => { + terminalMock.dimensions = { cols: 80, rows: 24 }; + const onResize = vi.fn(); + render( {}} onResize={onResize} />); + expect(onResize).toHaveBeenCalledWith(80, 24); + }); + + it('explicit fit re-publishes unchanged dimensions for reconnect', () => { + const onResize = vi.fn(); + let api!: XTerminalApi; + render( { api = value; }} onResize={onResize} />); + onResize.mockClear(); + act(() => api.fit()); + expect(onResize.mock.calls).toEqual([[132, 42]]); + terminalMock.dimensions = { cols: 100, rows: 30 }; + onResize.mockClear(); + act(() => api.fit()); + expect(onResize.mock.calls).toEqual([[100, 30]]); + }); + it('只读日志启用应用主题后会响应 light 和 dark 切换', () => { setThemePreference('light'); const { container } = render( {}} readOnly followAppTheme />); diff --git a/web/src/components/XTerminal.tsx b/web/src/components/XTerminal.tsx index a845d41a7..c5f40cec3 100644 --- a/web/src/components/XTerminal.tsx +++ b/web/src/components/XTerminal.tsx @@ -130,17 +130,26 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class term.open(el); terminalRef.current = term; - // Initial fit must happen after open() lays out the DOM. - try { - fitAddon.fit(); - } catch { - /* container not laid out yet — ResizeObserver will catch up */ - } - - const dataDisposable = readOnly ? null : term.onData((d) => onData?.(d)); - const resizeDisposable = term.onResize(({ cols, rows }) => { + let lastSize: { cols: number; rows: number } | undefined; + const publishSize = () => { + const { cols, rows } = term; + if (lastSize?.cols === cols && lastSize.rows === rows) return; + lastSize = { cols, rows }; onResize?.(cols, rows); - }); + }; + const dataDisposable = readOnly ? null : term.onData((d) => onData?.(d)); + const resizeDisposable = term.onResize(publishSize); + const fit = (forceReport = false) => { + if (forceReport) lastSize = undefined; + try { + fitAddon.fit(); + publishSize(); + } catch { + /* container not laid out yet — ResizeObserver will catch up */ + } + }; + // Subscribe before fitting, including grids that stay at xterm's default size. + fit(); if (readOnly) { term.attachCustomKeyEventHandler((event) => { @@ -150,17 +159,12 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class }); } - // Re-fit on container size changes (sidebar collapse, window resize). - // We debounce nothing — fit() is cheap and the resize control frame - // is throttled by SSH itself. - const ro = new ResizeObserver(() => { - try { - fitAddon.fit(); - } catch { - /* dom temporarily detached during route change */ - } - }); + const ro = new ResizeObserver(() => fit()); ro.observe(el); + let disposed = false; + void document.fonts?.ready.then(() => { + if (!disposed) fit(); + }); // Hand the imperative API back to the parent. The decoder is created // once and reused so we don't churn allocations per inbound chunk. @@ -179,13 +183,8 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class writeln: (line) => term.writeln(line), clear: () => term.clear(), focus: () => term.focus(), - fit: () => { - try { - fitAddon.fit(); - } catch { - /* noop */ - } - }, + // A new SSH session must receive dimensions even when the grid is unchanged. + fit: () => fit(true), dispose: () => term.dispose(), }; attachRef(api); @@ -195,6 +194,7 @@ export function XTerminal({ onData, onResize, attachRef, readOnly = false, class if (!readOnly) term.focus(); return () => { + disposed = true; terminalRef.current = null; ro.disconnect(); dataDisposable?.dispose(); diff --git a/web/src/pages/Clusters.test.tsx b/web/src/pages/Clusters.test.tsx index e285252de..2097bbc06 100644 --- a/web/src/pages/Clusters.test.tsx +++ b/web/src/pages/Clusters.test.tsx @@ -109,6 +109,8 @@ describe("device cluster pages", () => { name: "bare-metal-prod", }); expect(clusterLink).toHaveAttribute("href", "/clusters/501"); + expect(screen.getByRole('columnheader', { name: '操作' })).toHaveClass('w-px', 'text-left'); + expect(clusterLink.closest('table')).toHaveClass('table-auto'); expect(screen.getByRole("link", { name: "k8s-prod" })).toHaveAttribute("href", "/clusters/901"); expect(screen.getByText("接入类型")).toBeInTheDocument(); expect(screen.getByText("Host")).toBeInTheDocument(); diff --git a/web/src/pages/Clusters.tsx b/web/src/pages/Clusters.tsx index 0240b2600..f5d7cdf36 100644 --- a/web/src/pages/Clusters.tsx +++ b/web/src/pages/Clusters.tsx @@ -277,7 +277,7 @@ export default function ClustersPage() { /> ) : (
- +
- @@ -438,7 +438,7 @@ function ClusterRow({ -
@@ -299,7 +299,7 @@ export default function ClustersPage() { {tr("更新时间", "Updated")} + {tr("操作", "Actions")}
{relativeTime(summary.cluster.updated_at)} event.stopPropagation()}> + event.stopPropagation()}>
vi.fn()); -vi.mock('@/components/XTerminal', () => ({ XTerminal: () => null })); +const terminalSize = vi.hoisted(() => ({ cols: 132, rows: 42 })); +vi.mock('@/components/XTerminal', () => ({ XTerminal: ({ attachRef, onResize }: { + attachRef(api: Partial): void; + onResize(cols: number, rows: number): void; +}) => { + useEffect(() => { + attachRef({ fit: () => onResize(terminalSize.cols, terminalSize.rows), write: vi.fn() }); + }, [attachRef, onResize]); + return null; +} })); vi.mock('@/api/webshell', async () => ({ ...await vi.importActual('@/api/webshell'), openShellSocket, @@ -39,6 +49,8 @@ describe('ConnectModal', () => { beforeEach(() => { localStorage.setItem('ongrid-locale', 'zh-CN'); openShellSocket.mockReset(); + terminalSize.cols = 132; + terminalSize.rows = 42; server.use( http.get('/api/v1/edges', () => HttpResponse.json({ items: [edge], total: 1 })), http.get('/api/v1/edges/70', () => HttpResponse.json(edge)), @@ -54,6 +66,32 @@ describe('ConnectModal', () => { vi.restoreAllMocks(); }); + it('sends measured open size first and re-synchronizes unchanged size on ready and reconnect', async () => { + const first = { readyState: WebSocket.OPEN, send: vi.fn(), close: vi.fn() } as unknown as WebSocket; + const second = { readyState: WebSocket.OPEN, send: vi.fn(), close: vi.fn() } as unknown as WebSocket; + openShellSocket.mockReturnValueOnce(first).mockReturnValueOnce(second); + renderShell(); + fireEvent.change(await screen.findByLabelText('密码'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: '连接' })); + await waitFor(() => expect(openShellSocket).toHaveBeenCalledOnce()); + act(() => { first.onopen?.(new Event('open')); }); + expect(first.send).toHaveBeenCalledTimes(1); + expect(first.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'open', cols: 132, rows: 42, term: 'xterm-256color' })); + terminalSize.rows = 38; + await act(async () => { await first.onmessage?.({ data: JSON.stringify({ type: 'ready' }) } as MessageEvent); }); + expect(first.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'resize', cols: 132, rows: 38 })); + + fireEvent.click(screen.getByRole('button', { name: '重新连接' })); + fireEvent.change(await screen.findByLabelText('密码'), { target: { value: 'secret' } }); + fireEvent.click(screen.getByRole('button', { name: '连接' })); + await waitFor(() => expect(openShellSocket).toHaveBeenCalledTimes(2)); + act(() => { second.onopen?.(new Event('open')); }); + expect(second.send).toHaveBeenCalledTimes(1); + expect(second.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'open', cols: 132, rows: 38, term: 'xterm-256color' })); + await act(async () => { await second.onmessage?.({ data: JSON.stringify({ type: 'ready' }) } as MessageEvent); }); + expect(second.send).toHaveBeenLastCalledWith(JSON.stringify({ type: 'resize', cols: 132, rows: 38 })); + }); + it('先展示已保存账户,新增账户时直接连接并延后保存', async () => { server.use( http.get('/api/v1/devices/42/shell/credentials', () => diff --git a/web/src/pages/DeviceShell.tsx b/web/src/pages/DeviceShell.tsx index f916f99e3..9a8d9a42c 100644 --- a/web/src/pages/DeviceShell.tsx +++ b/web/src/pages/DeviceShell.tsx @@ -117,6 +117,7 @@ export function DeviceShell() { // outliving any single render is the whole point of this page. const termRef = useRef(null); const wsRef = useRef(null); + const shellReadyRef = useRef(false); // Latest cols/rows reported by xterm. We need them when sending the // first `open` frame (called from a callback that doesn't have direct // access to the terminal's geometry). @@ -196,6 +197,7 @@ export function DeviceShell() { // Tear down the socket. Caller decides whether to also dispose the // terminal — usually we keep it so the user can read final output. const teardown = useCallback(() => { + shellReadyRef.current = false; sendCloseOnce(); const ws = wsRef.current; wsRef.current = null; @@ -275,6 +277,7 @@ export function DeviceShell() { ws.onopen = () => { if (wsRef.current !== ws) return; + termRef.current?.fit(); const { cols, rows } = sizeRef.current; sendControl(ws, { type: 'open', @@ -302,6 +305,8 @@ export function DeviceShell() { switch (frame.type) { case 'ready': { inputs.password = ''; + shellReadyRef.current = true; + termRef.current?.fit(); setConn({ kind: 'open' }); writeBanner(ansiDim(tr(`-- SSH 已连接 (${inputs.user}@${edge?.name ?? deviceId}) --`, `-- SSH connected (${inputs.user}@${edge?.name ?? deviceId}) --`))); break; @@ -376,6 +381,7 @@ export function DeviceShell() { ws.onclose = (ev) => { if (wsRef.current !== ws) return; + shellReadyRef.current = false; inputs.password = ''; if (!closedSentRef.current && ev.code === 1006) { const message = tr('连接异常断开', 'Connection dropped unexpectedly'); @@ -416,7 +422,7 @@ export function DeviceShell() { const onTermResize = useCallback((cols: number, rows: number) => { sizeRef.current = { cols, rows }; const ws = wsRef.current; - if (ws && ws.readyState === WebSocket.OPEN) { + if (shellReadyRef.current && ws && ws.readyState === WebSocket.OPEN) { sendControl(ws, { type: 'resize', cols, rows }); } }, []); @@ -457,8 +463,8 @@ export function DeviceShell() { extractHostname(edge?.host_info) || edge?.name || deviceId || tr('设备', 'device'); return ( - <>{dialog}
-
+ <>{dialog}
+
{hostname} @@ -498,7 +504,7 @@ export function DeviceShell() {
-
+
- + )} @@ -827,7 +827,7 @@ export default function EdgesPage() { )} - @@ -961,10 +961,10 @@ export default function EdgesPage() { : "—"}
Edge + {tr("操作", "Actions")}
event.stopPropagation()} > -
+
ev.stopPropagation()} > -
+
{networkDevice ? ( <>