From bbd94b5672e014d80c2fccf7f0f5377a07bbbd6b Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 22 Jul 2026 00:20:32 +0800 Subject: [PATCH] feat(ui): simplify fleet cards by moving edit/delete into detail Hide instance IDs and config/delete controls on overview cards; keep service/system restart only, with edit still available from detail. --- apps/web/src/app-shell.integration.test.tsx | 39 ++---- apps/web/src/app-shell.tsx | 1 - apps/web/src/fleet/fleet-page.test.tsx | 11 +- apps/web/src/fleet/fleet-page.tsx | 117 +----------------- .../src/instances/instance-detail.test.tsx | 3 + apps/web/src/styles.css | 11 +- 6 files changed, 21 insertions(+), 161 deletions(-) diff --git a/apps/web/src/app-shell.integration.test.tsx b/apps/web/src/app-shell.integration.test.tsx index 05f7dcf..13a6787 100644 --- a/apps/web/src/app-shell.integration.test.tsx +++ b/apps/web/src/app-shell.integration.test.tsx @@ -100,24 +100,7 @@ describe('React AppShell and Fleet vertical slice', () => { ).toBe(false); }); - it('makes cards, add, detail, edit, and safe deletion available from the overview', async () => { - const user = userEvent.setup(); - const remove = vi.fn().mockResolvedValue(undefined); - const instanceDataSource: InstanceDataSource = { - get: vi.fn().mockResolvedValue({ - id: 'bravo', - name: 'Bravo', - origin: 'http://bravo.example:8080', - tags: ['west'], - revision: 7, - credentialConfigured: true, - }), - create: vi.fn(), - update: vi.fn(), - testConnection: vi.fn(), - delete: remove, - }; - + it('keeps overview cards compact: dashboard entry and ops only; edit/delete live in detail', async () => { const fleetMessagesDataSource: FleetMessagesDataSource = { load: vi.fn(async (instanceId) => instanceId === 'bravo' @@ -139,7 +122,6 @@ describe('React AppShell and Fleet vertical slice', () => { pathname="/fleet" fleetDataSource={source(async () => snapshot)} fleetMessagesDataSource={fleetMessagesDataSource} - instanceDataSource={instanceDataSource} />, ); @@ -167,16 +149,12 @@ describe('React AppShell and Fleet vertical slice', () => { within(card).getByRole('link', { name: '打开 Bravo 实例仪表盘' }).getAttribute('href'), ).toBe('/instances/bravo/overview'); expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull(); - expect(within(card).getByRole('link', { name: '编辑 Bravo' }).getAttribute('href')).toBe( - '/settings/instances/bravo', - ); - - await user.click(within(card).getByRole('button', { name: '删除 Bravo' })); - expect(within(card).getByText('删除后无法撤销。')).toBeTruthy(); - await user.type(within(card).getByRole('textbox', { name: '输入 bravo 以确认删除' }), 'bravo'); - await user.click(within(card).getByRole('button', { name: '确认删除 Bravo' })); - expect(remove).toHaveBeenCalledWith('bravo', 7); - expect(within(card).getByRole('status').textContent).toContain('删除请求已提交'); + expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull(); + expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull(); + expect(within(card).queryByText('bravo')).toBeNull(); + expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy(); + expect(within(card).getByRole('button', { name: '重启服务 Bravo' })).toBeTruthy(); + expect(within(card).getByRole('button', { name: '系统重启 Bravo' })).toBeTruthy(); }); it('keeps message read failures separate from instance reachability and reports each once', async () => { @@ -302,8 +280,9 @@ describe('React AppShell and Fleet vertical slice', () => { cards = screen.getAllByRole('article'); expect(cards[0]?.textContent).toContain('Bravo'); - await user.selectOptions(screen.getByRole('combobox', { name: '状态' }), 'auth'); + await user.click(screen.getByRole('button', { name: /需认证/ })); expect(screen.queryByRole('article', { name: /Alpha 实例概览/ })).toBeNull(); + expect(screen.getByRole('article', { name: /Bravo 实例概览/ })).toBeTruthy(); await user.click(screen.getByRole('button', { name: '全选本页' })); expect((screen.getByRole('checkbox', { name: '选择 Bravo' }) as HTMLInputElement).checked).toBe( true, diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 1ba3001..fb37be4 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -218,7 +218,6 @@ function Page({ diff --git a/apps/web/src/fleet/fleet-page.test.tsx b/apps/web/src/fleet/fleet-page.test.tsx index 95c7369..dda19eb 100644 --- a/apps/web/src/fleet/fleet-page.test.tsx +++ b/apps/web/src/fleet/fleet-page.test.tsx @@ -29,15 +29,16 @@ const snapshot: FleetSnapshot = { afterEach(cleanup); describe('FleetPage card navigation', () => { - it('makes the whole instance card open its dashboard while admin controls stay separately interactive', () => { + it('makes the whole instance card open its dashboard while ops stay separate and config stays out of the card', () => { render(); const card = screen.getByRole('article', { name: 'Alpha modem 实例概览' }); const entry = within(card).getByRole('link', { name: '打开 Alpha modem 实例仪表盘' }); expect(entry.getAttribute('href')).toBe('/instances/alpha/overview'); expect(entry.classList.contains('fleet-card-entry')).toBe(true); - expect(within(card).getByRole('link', { name: '编辑 Alpha modem' })).toBeTruthy(); - expect(within(card).getByRole('button', { name: '删除 Alpha modem' })).toBeTruthy(); + expect(within(card).queryByRole('link', { name: /编辑/ })).toBeNull(); + expect(within(card).queryByRole('button', { name: /删除/ })).toBeNull(); + expect(within(card).queryByText('alpha')).toBeNull(); expect(within(card).queryByRole('link', { name: /查看.*短信/ })).toBeNull(); expect(screen.getByRole('region', { name: '实例状态摘要' }).textContent).toMatch( /实例总数\s*1.*在线\s*1.*需处理\s*0/s, @@ -49,7 +50,9 @@ describe('FleetPage card navigation', () => { '51', ); expect(within(card).getByText('进入仪表盘')).toBeTruthy(); - expect(within(card).getByRole('group', { name: '实例管理操作' })).toBeTruthy(); + expect(within(card).getByRole('group', { name: '实例运维操作' })).toBeTruthy(); + expect(within(card).getByRole('button', { name: '重启服务 Alpha modem' })).toBeTruthy(); + expect(within(card).getByRole('button', { name: '系统重启 Alpha modem' })).toBeTruthy(); }); }); diff --git a/apps/web/src/fleet/fleet-page.tsx b/apps/web/src/fleet/fleet-page.tsx index e4abd1d..ca56376 100644 --- a/apps/web/src/fleet/fleet-page.tsx +++ b/apps/web/src/fleet/fleet-page.tsx @@ -10,7 +10,6 @@ import { type FleetStatus, type SortDirection, } from './fleet-table-view-model.js'; -import type { InstanceDataSource } from '../instances/instance-crud.js'; import type { FleetMessageLoadState, FleetMessagesDataSource, @@ -35,7 +34,6 @@ export interface FleetDataSource { export interface FleetPageProps { readonly dataSource?: FleetDataSource; readonly messagesDataSource?: FleetMessagesDataSource; - readonly instanceDataSource?: InstanceDataSource; readonly initialData?: FleetSnapshot; readonly refreshSignal?: number; readonly operationClient?: OperationClient; @@ -128,7 +126,6 @@ const SYSTEM_REBOOT = { export function FleetPage({ dataSource, messagesDataSource, - instanceDataSource, initialData, refreshSignal = 0, operationClient, @@ -157,13 +154,6 @@ export function FleetPage({ message?: string; error?: string; } | null>(null); - const [deleteId, setDeleteId] = useState(); - const [deleteOwner, setDeleteOwner] = useState>(); - const [deleteConfirmation, setDeleteConfirmation] = useState(''); - const [deleteBusy, setDeleteBusy] = useState(false); - const deleteRequestRef = useRef(0); - const [deleteStatus, setDeleteStatus] = - useState>(); const messagesOwner = useRef(0); const [messageStates, setMessageStates] = useState>( new Map(), @@ -382,47 +372,6 @@ export function FleetPage({ }); } - async function beginDelete(id: string): Promise { - const request = ++deleteRequestRef.current; - setDeleteId(id); - setDeleteOwner(undefined); - setDeleteConfirmation(''); - setDeleteStatus(undefined); - if (!instanceDataSource) { - setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' }); - return; - } - setDeleteBusy(true); - try { - const current = await instanceDataSource.get(id); - if (request !== deleteRequestRef.current) return; - if (current.id !== id) throw new Error('owner mismatch'); - setDeleteOwner({ id: current.id, revision: current.revision }); - } catch { - if (request === deleteRequestRef.current) { - setDeleteStatus({ id, kind: 'error', message: '无法准备删除,请稍后重试。' }); - } - } finally { - if (request === deleteRequestRef.current) setDeleteBusy(false); - } - } - - async function remove(id: string): Promise { - if (!instanceDataSource || deleteOwner?.id !== id || deleteConfirmation !== id) return; - setDeleteBusy(true); - setDeleteStatus(undefined); - try { - await instanceDataSource.delete(id, deleteOwner.revision); - setDeleteStatus({ id, kind: 'success', message: '删除请求已提交。' }); - setDeleteId(undefined); - setDeleteConfirmation(''); - } catch { - setDeleteStatus({ id, kind: 'error', message: '删除请求失败,请稍后重试。' }); - } finally { - setDeleteBusy(false); - } - } - function selectedTargets(): Array<{ instanceId: string; revision: number }> { const instances = snapshot?.instances ?? []; return model.selectedIds.flatMap((id) => { @@ -870,7 +819,6 @@ export function FleetPage({ aria-label={`打开 ${row.displayName} 实例仪表盘`} >

{row.displayName}

- {row.id} 进入仪表盘 @@ -971,7 +919,7 @@ export function FleetPage({
- - 编辑 - -
{cardAction?.id === row.id ? (

) : null} - {deleteId === row.id ? ( -

-

删除后无法撤销。

- -
- - -
-
- ) : null} - {deleteStatus?.id === row.id ? ( -

- {deleteStatus.message} -

- ) : null} ))} diff --git a/apps/web/src/instances/instance-detail.test.tsx b/apps/web/src/instances/instance-detail.test.tsx index 71be7c2..e0c1c91 100644 --- a/apps/web/src/instances/instance-detail.test.tsx +++ b/apps/web/src/instances/instance-detail.test.tsx @@ -60,6 +60,9 @@ describe('InstanceDetail', () => { expect(screen.getByText('已认证')).toBeTruthy(); expect(screen.getByText('数据最新')).toBeTruthy(); expect(links.map((link) => link.textContent)).toEqual(['仪表盘', '短信']); + expect(screen.getByRole('link', { name: '编辑实例' }).getAttribute('href')).toBe( + '/settings/instances/owner', + ); }); it('uses the business titles for both primary pages', () => { diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b9673f4..7f9c5bc 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -412,15 +412,10 @@ small { gap: 0.75rem; } .fleet-card h2 { - margin: 0 0 0.2rem; + margin: 0; font-size: 1.18rem; letter-spacing: -0.02em; } -.fleet-card code { - color: var(--muted); - overflow-wrap: anywhere; - font-size: 0.82rem; -} .fleet-card-metrics { margin: 1rem 0; display: grid; @@ -950,10 +945,6 @@ tbody th small { text-decoration: underline; text-underline-offset: 0.2em; } -.fleet-card-entry code { - display: block; - overflow-wrap: anywhere; -} .fleet-card-admin-actions { position: relative; z-index: 1;