From d675b950ee2d3a3e102f396c37415f69deacae76 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Thu, 30 Jul 2026 12:23:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=85=A8=E9=A1=B5=E9=87=8D=E6=9E=84:?= =?UTF-8?q?=E5=85=8D=E5=AF=86=E7=99=BB=E5=BD=95=E4=B8=8E=E6=B4=BB=E8=B7=83?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D;=E6=8C=89=E9=92=AE=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 + package.json | 1 + src/api/auth.ts | 167 +++++- src/api/instances.test.ts | 77 +++ src/api/instances.ts | 36 +- src/api/request.test.ts | 164 ++++++ src/api/request.ts | 150 +++++- src/api/tenant.ts | 12 +- src/assets/main.css | 13 + src/components/ChoiceChip.vue | 22 + src/components/FormModal.vue | 1 + src/components/ai/AiChannelModelsModal.vue | 51 +- src/components/ai/AiChannelPanel.vue | 44 +- src/components/ai/AiKeyPanel.vue | 45 +- src/components/network/SecurityRuleEditor.vue | 3 +- .../objectstorage/ObjectBrowser.vue | 25 +- src/components/objectstorage/ParPanel.vue | 4 +- .../objectstorage/viewer/TextPreviewPane.vue | 20 +- src/components/proxy/ProxyTenantsModal.vue | 2 +- .../settings/AccountSecurityCard.vue | 504 ++++++++++++------ .../settings/ActiveSessionsCard.vue | 201 +++++++ src/components/settings/NotifyChannelCard.vue | 1 - src/components/task/TaskCard.vue | 10 +- src/components/tenant/ApiKeysModal.vue | 42 +- src/components/tenant/IdpTab.vue | 102 ++-- src/components/tenant/MiscTab.vue | 2 +- src/components/tenant/UsersTab.vue | 31 +- src/composables/useFormat.ts | 26 + src/composables/useWallet.ts | 79 +++ src/theme/naive.ts | 5 +- src/theme/tokens.ts | 6 + src/types/api.ts | 27 + src/views/InstanceDetailView.vue | 104 +++- src/views/InstanceListView.vue | 122 ++++- src/views/LoginView.vue | 174 +++++- src/views/NetworkListView.vue | 2 +- src/views/ObjectStorageView.vue | 2 +- src/views/ProxyListView.vue | 4 +- src/views/SettingsView.vue | 44 +- src/views/TaskDetailView.vue | 65 ++- src/views/TaskListView.vue | 18 +- src/views/TenantListView.vue | 2 +- src/views/errors/BlockedView.vue | 2 +- 43 files changed, 1944 insertions(+), 475 deletions(-) create mode 100644 src/api/instances.test.ts create mode 100644 src/components/ChoiceChip.vue create mode 100644 src/components/settings/ActiveSessionsCard.vue create mode 100644 src/composables/useWallet.ts diff --git a/package-lock.json b/package-lock.json index 8b5655e..72de8fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@codemirror/legacy-modes": "^6.5.3", "@novnc/novnc": "^1.7.0", "@prettier/plugin-xml": "^3.4.2", + "@simplewebauthn/browser": "^13.3.0", "@vue-office/docx": "^1.6.3", "@vue-office/excel": "^1.7.14", "@vue-office/pdf": "^2.0.10", @@ -3414,6 +3415,12 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/package.json b/package.json index d81851a..b9a17a9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@codemirror/legacy-modes": "^6.5.3", "@novnc/novnc": "^1.7.0", "@prettier/plugin-xml": "^3.4.2", + "@simplewebauthn/browser": "^13.3.0", "@vue-office/docx": "^1.6.3", "@vue-office/excel": "^1.7.14", "@vue-office/pdf": "^2.0.10", diff --git a/src/api/auth.ts b/src/api/auth.ts index efc5397..004a116 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,3 +1,10 @@ +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/browser' + import { mockOn, mocked, request } from './request' import type { CredentialsInfo, @@ -5,6 +12,9 @@ import type { LoginRequest, LoginResponse, OAuthSettings, + PasskeyCeremonyOptions, + PasskeyInfo, + SessionItem, SessionRefresh, TotpSetup, TotpStatus, @@ -32,7 +42,36 @@ export function logout(): Promise { /** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */ export function revokeSessions(): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) - return request('/auth/revoke-sessions', { method: 'POST' }) + return request('/auth/revoke-sessions', { method: 'POST', refreshesSession: true }) +} + +/** mock 会话样例:相对当前时间偏移,保证「最近活跃」展示合理 */ +function mockSessions(): { items: SessionItem[] } { + const ago = (min: number) => new Date(Date.now() - min * 60000).toISOString() + const later = new Date(Date.now() + 23 * 3600000).toISOString() + const mk = (id: number, method: string, clientIp: string, userAgent: string, seen: number, created: number, current = false): SessionItem => ({ + id, method, clientIp, userAgent, + createdAt: ago(created), lastSeenAt: ago(seen), expiresAt: later, current, + }) + return { + items: [ + mk(1, 'password', '198.51.100.7', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36', 0, 180, true), + mk(2, 'passkey', '203.0.113.24', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', 185, 205), + mk(3, 'wallet', '192.0.2.88', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0', 780, 800), + ], + } +} + +/** 活跃会话列表:未撤销、未过期、版本为当前,最近活跃在前 */ +export function listSessions(): Promise<{ items: SessionItem[] }> { + if (mockOn) return mocked(mockSessions(), 300) + return request('/auth/sessions') +} + +/** 定点撤销一个其他会话;当前会话不可撤销(请走退出登录) */ +export function revokeSession(id: number): Promise { + if (mockOn) return mocked(undefined, 250) + return request(`/auth/sessions/${id}`, { method: 'DELETE' }) } // ---- 登录凭据 ---- @@ -45,13 +84,17 @@ export function getCredentials(): Promise { /** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */ export function updateCredentials(body: UpdateCredentialsRequest): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400) - return request('/auth/credentials', { method: 'PUT', body }) + return request('/auth/credentials', { method: 'PUT', body, refreshesSession: true }) } /** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */ export function updatePasswordLogin(disabled: boolean): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) - return request('/auth/password-login', { method: 'PUT', body: { disabled } }) + return request('/auth/password-login', { + method: 'PUT', + body: { disabled }, + refreshesSession: true, + }) } // ---- 两步验证(TOTP) ---- @@ -74,22 +117,29 @@ export function setupTotp(): Promise { /** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */ export function activateTotp(code: string): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) - return request('/auth/totp/activate', { method: 'POST', body: { code } }) + return request('/auth/totp/activate', { + method: 'POST', + body: { code }, + refreshesSession: true, + }) } /** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */ export function disableTotp(body: { password?: string; code?: string }): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) - return request('/auth/totp/disable', { method: 'POST', body }) + return request('/auth/totp/disable', { method: 'POST', body, refreshesSession: true }) } // ---- 外部身份(OAuth) ---- /** 可登录的 provider 列表(登录页公开接口;已禁用的不返回); - * passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */ + * passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单; + * passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */ export function getOauthProviders(): Promise<{ providers: OauthProviderInfo[] passwordLoginDisabled: boolean + passkeyLogin: boolean + walletLogin: boolean }> { if (mockOn) return mocked({ @@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{ { provider: 'oidc', displayName: 'OIDC SSO' }, ], passwordLoginDisabled: false, + passkeyLogin: true, + walletLogin: true, }) return request('/auth/oauth/providers') } @@ -124,7 +176,108 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> { /** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */ export function unbindIdentity(id: number): Promise { if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) - return request(`/auth/identities/${id}`, { method: 'DELETE' }) + return request(`/auth/identities/${id}`, { method: 'DELETE', refreshesSession: true }) +} + +// ---- 通行密钥(Passkey) ---- + +const mockPasskeyRegisterOptions: PasskeyCeremonyOptions = { + sessionId: 'mock-session', + options: { + publicKey: { + challenge: 'bW9jaw', + rp: { id: 'localhost', name: 'OCI Portal' }, + user: { id: 'AQ', name: 'admin', displayName: 'admin' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }, +} + +/** 发起通行密钥注册;sessionId 需原样带回 finish */ +export function beginPasskeyRegister(): Promise< + PasskeyCeremonyOptions +> { + if (mockOn) return mocked(mockPasskeyRegisterOptions, 300) + return request('/auth/passkey/register/begin', { method: 'POST' }) +} + +/** 完成通行密钥注册;成功后旧 token 全部失效,响应带新会话 */ +export function finishPasskeyRegister( + sessionId: string, + name: string, + credential: RegistrationResponseJSON, +): Promise { + if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) + return request('/auth/passkey/register/finish', { + method: 'POST', + body: { sessionId, name, credential }, + refreshesSession: true, + }) +} + +export function listPasskeys(): Promise<{ items: PasskeyInfo[] }> { + if (mockOn) + return mocked({ + items: [ + { id: 1, name: 'MacBook Touch ID', createdAt: '2026-07-20T10:00:00+08:00', lastUsedAt: null }, + ], + }) + return request('/auth/passkeys') +} + +/** 删除通行密钥;成功后旧 token 全部失效,响应带新会话 */ +export function removePasskey(id: number): Promise { + if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) + return request(`/auth/passkeys/${id}`, { method: 'DELETE', refreshesSession: true }) +} + +/** 发起通行密钥登录(公开接口) */ +export function beginPasskeyLogin(): Promise< + PasskeyCeremonyOptions +> { + if (mockOn) + return mocked({ + sessionId: 'mock-session', + options: { publicKey: { challenge: 'bW9jaw', rpId: 'localhost' } }, + }) + return request('/auth/passkey/login/begin', { method: 'POST' }) +} + +/** 完成通行密钥登录;UV 通过后豁免 TOTP */ +export function finishPasskeyLogin( + sessionId: string, + credential: AuthenticationResponseJSON, +): Promise { + if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300) + return request('/auth/passkey/login/finish', { + method: 'POST', + body: { sessionId, credential }, + }) +} + +// ---- Web3 钱包(SIWE) ---- + +/** 发起钱包签名挑战;mode=bind 要求已登录,message 需原样 personal_sign */ +export function getWalletChallenge( + address: string, + mode: 'login' | 'bind', +): Promise<{ nonce: string; message: string }> { + if (mockOn) + return mocked({ + nonce: 'mock-nonce', + message: `localhost wants you to sign in with your Ethereum account:\n${address}\n\n登录 OCI Portal 面板\n\nNonce: mock-nonce`, + }) + return request('/auth/wallet/challenge', { method: 'POST', body: { address, mode } }) +} + +/** 校验钱包签名:login 返回新会话;bind 返回换发的新 token(旧会话已失效) */ +export function verifyWallet(nonce: string, signature: string): Promise { + if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300) + return request('/auth/wallet/verify', { + method: 'POST', + body: { nonce, signature }, + refreshesSession: true, + }) } // ---- OAuth provider 配置(设置页) ---- diff --git a/src/api/instances.test.ts b/src/api/instances.test.ts new file mode 100644 index 0000000..1099d7a --- /dev/null +++ b/src/api/instances.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { terminateInstance, terminatingInstances, terminatingKey } from './instances' + +/** 可控完成时机的 fetch stub;fail 控制 resolve 后是成功还是 500 */ +function stubTerminateFetch(fail = false) { + const calls = { count: 0 } + let release!: () => void + const gate = new Promise((r) => (release = r)) + vi.stubGlobal('fetch', async () => { + calls.count++ + await gate + if (fail) { + return { + ok: false, + status: 500, + url: '/api/v1/x', + json: async () => ({ error: '终止失败' }), + } as unknown as Response + } + return { ok: true, status: 200, text: async () => '' } as unknown as Response + }) + return { calls, release } +} + +beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + terminatingInstances.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('共享终止锁', () => { + it('请求飞行中 key 在集合内,完成后移除', async () => { + const { release } = stubTerminateFetch() + const p = terminateInstance(3, 'ocid1.instance..x', false) + expect(terminatingInstances.has(terminatingKey(3, 'ocid1.instance..x'))).toBe(true) + release() + await p + expect(terminatingInstances.size).toBe(0) + }) + + it('请求失败同样释放锁,不留脏状态', async () => { + const { release } = stubTerminateFetch(true) + const p = terminateInstance(3, 'ocid1.instance..x', true) + expect(terminatingInstances.size).toBe(1) + release() + await expect(p).rejects.toMatchObject({ message: '终止失败' }) + expect(terminatingInstances.size).toBe(0) + }) + + it('同一物理实例跨配置共享 key,后到调用被拒', async () => { + const { calls, release } = stubTerminateFetch() + const instanceId = 'ocid1.instance.oc1..same' + const first = terminateInstance(1, instanceId, false) + await expect(terminateInstance(2, instanceId, true)).rejects.toMatchObject({ status: 409 }) + expect(terminatingKey(1, instanceId)).toBe(terminatingKey(2, instanceId)) + expect(calls.count).toBe(1) + release() + await first + }) + + it('不同实例互不影响', async () => { + const { release } = stubTerminateFetch() + const pa = terminateInstance(1, 'a', false) + const pb = terminateInstance(2, 'b', false) + expect(terminatingInstances.has(terminatingKey(1, 'a'))).toBe(true) + expect(terminatingInstances.has(terminatingKey(2, 'b'))).toBe(true) + expect(terminatingKey(1, 'a')).not.toBe(terminatingKey(2, 'b')) + release() + await Promise.all([pa, pb]) + expect(terminatingInstances.size).toBe(0) + }) +}) diff --git a/src/api/instances.ts b/src/api/instances.ts index eec7a31..4da4571 100644 --- a/src/api/instances.ts +++ b/src/api/instances.ts @@ -8,7 +8,9 @@ import { mockVolAttachments, mockVolumes, } from './mock' -import { mockOn, mocked, request } from './request' +import { reactive } from 'vue' + +import { ApiError, mockOn, mocked, request } from './request' import type { AttachVnicRequest, BootVolumeAttachment, @@ -118,13 +120,41 @@ export function updateInstance( }) } -export function terminateInstance( +/** 终止请求飞行中的实例(instance OCID):模块级响应式集合,列表页、 + * 详情页与组件重挂载共用一把锁,防「关闭重开对话框后用相反的 + * preserveBootVolume 并发提交」;跨浏览器页签由服务端同键 guard 兜底 */ +export const terminatingInstances = reactive(new Set()) + +/** Instance OCID 唯一标识物理实例;cfgId 不得拆成两把终止锁 */ +export function terminatingKey(_cfgId: number, instanceId: string): string { + return instanceId +} + +export async function terminateInstance( + cfgId: number, + instanceId: string, + preserveBootVolume: boolean, + region?: string, +): Promise { + const key = terminatingKey(cfgId, instanceId) + if (terminatingInstances.has(key)) { + throw new ApiError(409, '该实例的终止请求正在处理中') + } + terminatingInstances.add(key) + try { + if (mockOn) return await mocked(undefined, 600) + return await requestTerminate(cfgId, instanceId, preserveBootVolume, region) + } finally { + terminatingInstances.delete(key) + } +} + +function requestTerminate( cfgId: number, instanceId: string, preserveBootVolume: boolean, region?: string, ): Promise { - if (mockOn) return mocked(undefined, 600) return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, { method: 'DELETE', query: { preserveBootVolume, region }, diff --git a/src/api/request.test.ts b/src/api/request.test.ts index 1c2aa78..8e1618c 100644 --- a/src/api/request.test.ts +++ b/src/api/request.test.ts @@ -21,6 +21,27 @@ function stub401(rotateTo?: string) { }) } +function stubRefreshThen401() { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { + if (String(input).endsWith('/refresh')) { + await gate + return { + ok: true, + status: 200, + text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}', + } as unknown as Response + } + return { + ok: false, + status: 401, + json: async () => ({ error: '令牌已失效' }), + } as unknown as Response + }) + return release +} + let assignSpy: ReturnType beforeEach(() => { @@ -34,6 +55,89 @@ afterEach(() => { vi.restoreAllMocks() }) +/** 可控完成时机的 fetch stub,记录调用次数 */ +function stubSlowFetch() { + const calls = { count: 0 } + let release!: () => void + const gate = new Promise((r) => (release = r)) + vi.stubGlobal('fetch', async () => { + calls.count++ + await gate + return { ok: true, status: 200, text: async () => '{"ok":true}' } as unknown as Response + }) + return { calls, release } +} + +describe('写请求 in-flight 去重', () => { + it('并发相同写请求只发一次,共享同一结果', async () => { + const { calls, release } = stubSlowFetch() + const p1 = request('/y', { method: 'POST', body: { a: 1 } }) + const p2 = request('/y', { method: 'POST', body: { a: 1 } }) + release() + expect(await Promise.all([p1, p2])).toEqual([{ ok: true }, { ok: true }]) + expect(calls.count).toBe(1) + }) + + it('body 不同不去重;完成后同 key 可再发', async () => { + const { calls, release } = stubSlowFetch() + const p1 = request('/y', { method: 'POST', body: { a: 1 } }) + const p2 = request('/y', { method: 'POST', body: { a: 2 } }) + release() + await Promise.all([p1, p2]) + expect(calls.count).toBe(2) + await request('/y', { method: 'POST', body: { a: 1 } }) + expect(calls.count).toBe(3) + }) + + it('GET 不去重', async () => { + const { calls, release } = stubSlowFetch() + const p1 = request('/y') + const p2 = request('/y') + release() + await Promise.all([p1, p2]) + expect(calls.count).toBe(2) + }) +}) + +/** 构造 429 响应;body 由调用方给定(带不带 code 决定分流) */ +function stub429(body: Record) { + vi.stubGlobal('fetch', async () => { + return { + ok: false, + status: 429, + url: '/api/v1/x', + json: async () => body, + } as unknown as Response + }) +} + +describe('429 分流', () => { + it('全局 IP 限流(code=RateLimited):整页跳转 /blocked', async () => { + setPageURL('http://localhost/instances') + stub429({ error: 'rate limit exceeded', code: 'RateLimited' }) + await expect(request('/x')).rejects.toMatchObject({ status: 429 }) + expect(assignSpy).toHaveBeenCalledWith('/blocked') + }) + + it('登录守卫锁定(无 code):不跳转,留在表单内提示', async () => { + setPageURL('http://localhost/login') + stub429({ error: '尝试过于频繁,请稍后再试' }) + await expect(request('/auth/wallet/verify', { method: 'POST', body: {} })).rejects.toMatchObject( + { status: 429, message: '尝试过于频繁,请稍后再试' }, + ) + expect(assignSpy).not.toHaveBeenCalled() + }) + + it('设置页绑定遇全局限流:同样进入 /blocked(不按 URL 判断)', async () => { + setPageURL('http://localhost/settings') + stub429({ error: 'rate limit exceeded', code: 'RateLimited' }) + await expect( + request('/auth/wallet/challenge', { method: 'POST', body: {} }), + ).rejects.toMatchObject({ status: 429 }) + expect(assignSpy).toHaveBeenCalledWith('/blocked') + }) +}) + describe('401 令牌快照', () => { it('当前 token 收到 401:登出并带 redirect 跳登录', async () => { setPageURL('http://localhost/instances?tab=list') @@ -55,6 +159,66 @@ describe('401 令牌快照', () => { expect(assignSpy).not.toHaveBeenCalled() }) + it('401 先到时等待并发换新响应落入 Store,不得提前登出', async () => { + setPageURL('http://localhost/settings') + useAuthStore().setSession('tokA', '') + const releaseRefresh = stubRefreshThen401() + const refresh = request<{ token: string; expiresAt: string }>('/refresh', { + method: 'POST', + refreshesSession: true, + }) + const stale = request('/stale', { method: 'POST' }).catch((error: unknown) => error) + await Promise.resolve() + expect(useAuthStore().token).toBe('tokA') + expect(assignSpy).not.toHaveBeenCalled() + releaseRefresh() + const next = await refresh + expect(next.token).toBe('tokB') + expect(await stale).toBeInstanceOf(ApiError) + expect(useAuthStore().token).toBe('tokB') + expect(assignSpy).not.toHaveBeenCalled() + }) + + it('换发 Promise 完成时已先写 Store,不给迟到 401 留微任务窗口', async () => { + setPageURL('http://localhost/settings') + useAuthStore().setSession('tokA', '') + vi.stubGlobal('fetch', async () => { + return { + ok: true, + status: 200, + text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}', + } as unknown as Response + }) + await request('/refresh', { method: 'POST', refreshesSession: true }) + expect(useAuthStore().token).toBe('tokB') + }) + + it('公开登录没有旧 token 时仍由页面接管新会话', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: true, + status: 200, + text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}', + } as unknown as Response + }) + await request('/login', { method: 'POST', refreshesSession: true }) + expect(useAuthStore().token).toBe('') + }) + + it('并发换新请求都返回 401 时不得互相等待', async () => { + setPageURL('http://localhost/settings') + useAuthStore().setSession('tokA', '') + stub401() + const opts = { method: 'POST', refreshesSession: true } as const + const results = await Promise.allSettled([ + request('/refresh-a', opts), + request('/refresh-b', opts), + ]) + expect(results.map((result) => result.status)).toEqual(['rejected', 'rejected']) + expect(useAuthStore().token).toBe('') + expect(assignSpy).toHaveBeenCalledTimes(1) + }) + it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => { setPageURL('http://localhost/login') stub401() diff --git a/src/api/request.ts b/src/api/request.ts index 1440135..48b1e4f 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -20,8 +20,27 @@ interface RequestOptions { query?: Record /** 附加请求头 */ headers?: Record + /** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */ + refreshesSession?: boolean } +interface RefreshWaiter { + resolve: (waited: boolean) => void +} + +interface RefreshState { + count: number + waiters: RefreshWaiter[] +} + +interface ErrorDetails { + message: string + ociCode?: string + rateLimited: boolean +} + +const sessionRefreshes = new Map() + function buildUrl(path: string, query?: RequestOptions['query']): string { if (!query) return BASE + path const qs = new URLSearchParams() @@ -32,40 +51,46 @@ function buildUrl(path: string, query?: RequestOptions['query']): string { return s ? `${BASE}${path}?${s}` : BASE + path } -async function parseError(resp: Response, sentToken: string): Promise { - let message = `请求失败(${resp.status})` - let ociCode: string | undefined +async function parseError( + resp: Response, + sentToken: string, +): Promise { + const details = await readErrorDetails(resp) + if (resp.status === 401) await handleUnauthorized(sentToken) + if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') { + location.assign('/blocked') + } + throw new ApiError(resp.status, details.message, details.ociCode) +} + +async function readErrorDetails(resp: Response): Promise { + const fallback = { message: `请求失败(${resp.status})`, rateLimited: false } try { const body = (await resp.json()) as { error?: string hint?: string errors?: unknown[] ociCode?: string + code?: string } - ociCode = body.ociCode - if (body.error) { - message = body.hint ? `${body.hint}|${body.error}` : body.error - } else { - // 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并; - // 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读 - const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string') - if (list.length) message = list.join('\n') - } + const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string') + const message = body.error + ? body.hint + ? `${body.hint}|${body.error}` + : body.error + : list.join('\n') || fallback.message + return { message, ociCode: body.ociCode, rateLimited: body.code === 'RateLimited' } } catch { - /* 非 JSON 响应体,保留默认消息 */ + return fallback } - if (resp.status === 401) handleUnauthorized(sentToken) - // 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示 - if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') { - location.assign('/blocked') - } - throw new ApiError(resp.status, message, ociCode) } /** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新 * token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话; * 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */ -function handleUnauthorized(sentToken: string) { +async function handleUnauthorized(sentToken: string) { + const waited = await waitForSessionRefresh(sentToken) + if (waited) await new Promise((resolve) => setTimeout(resolve, 0)) const auth = useAuthStore() if (sentToken !== auth.token) return auth.logout() @@ -75,8 +100,80 @@ function handleUnauthorized(sentToken: string) { } } -/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */ -export async function request(path: string, opts: RequestOptions = {}): Promise { +/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */ +const inflightWrites = new Map>() + +function beginSessionRefresh(token: string) { + const state = sessionRefreshes.get(token) + if (state) { + state.count++ + return + } + sessionRefreshes.set(token, { count: 1, waiters: [] }) +} + +function endSessionRefresh(token: string) { + const state = sessionRefreshes.get(token) + if (!state) return + state.count-- + state.waiters = state.waiters.filter((waiter) => { + if (state.count > 0) return true + waiter.resolve(true) + return false + }) + if (state.count === 0) sessionRefreshes.delete(token) +} + +function waitForSessionRefresh(token: string): Promise { + const state = sessionRefreshes.get(token) + if (!state || state.count === 0) return Promise.resolve(false) + return new Promise((resolve) => state.waiters.push({ resolve })) +} + +function trackSessionRefresh(token: string): () => void { + beginSessionRefresh(token) + let active = true + return () => { + if (!active) return + active = false + endSessionRefresh(token) + } +} + +function applySessionRefresh(value: unknown) { + if (!value || typeof value !== 'object') return + const refresh = value as { token?: unknown; expiresAt?: unknown } + if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return + useAuthStore().setSession(refresh.token, refresh.expiresAt) +} + +/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError; + * 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise, + * 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。 + * 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */ +export function request(path: string, opts: RequestOptions = {}): Promise { + const method = (opts.method ?? 'GET').toUpperCase() + if (method === 'GET') return doRequest(path, opts) + const body = opts.body === undefined ? '' : JSON.stringify(opts.body) + const sentToken = useAuthStore().token ?? '' + const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}` + const existing = inflightWrites.get(key) + if (existing) return existing as Promise + const tracksRefresh = opts.refreshesSession === true && sentToken !== '' + const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {} + const p = doRequest(path, opts, finishRefresh).finally(() => { + inflightWrites.delete(key) + finishRefresh() + }) + inflightWrites.set(key, p) + return p +} + +async function doRequest( + path: string, + opts: RequestOptions, + finishRefresh: () => void = () => {}, +): Promise { const auth = useAuthStore() const headers: Record = { 'Content-Type': 'application/json', ...opts.headers } if (auth.token) headers.Authorization = `Bearer ${auth.token}` @@ -86,10 +183,15 @@ export async function request(path: string, opts: RequestOptions = {}): Promi headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }) - if (!resp.ok) await parseError(resp, sentToken) + if (!resp.ok) { + if (resp.status === 401) finishRefresh() + await parseError(resp, sentToken) + } // 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input const text = await resp.text() - return (text ? JSON.parse(text) : undefined) as T + const result = (text ? JSON.parse(text) : undefined) as T + if (opts.refreshesSession && sentToken) applySessionRefresh(result) + return result } /** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */ diff --git a/src/api/tenant.ts b/src/api/tenant.ts index 887c89e..8e3a404 100644 --- a/src/api/tenant.ts +++ b/src/api/tenant.ts @@ -214,12 +214,18 @@ export function deleteUserApiKey(id: number, userId: string, fingerprint: string }) } -/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传 */ -export function activateApiKey(id: number, fingerprint: string, privateKey: string): Promise { +/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传; + * userId 给定且异于当前签名用户时,一并把面板签名用户切换为该用户 */ +export function activateApiKey( + id: number, + fingerprint: string, + privateKey: string, + userId?: string, +): Promise { if (mockOn) return mocked(undefined) return request(`/oci-configs/${id}/activate-api-key`, { method: 'POST', - body: { fingerprint, privateKey }, + body: { userId, fingerprint, privateKey }, }) } diff --git a/src/assets/main.css b/src/assets/main.css index a0aacaa..a6fa24a 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -15,9 +15,12 @@ --color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */ --color-accent: #c96442; --color-accent-hover: #ad4f2e; + --color-accent-pressed: #9a4527; --color-chart: #c15f3c; --color-ok: #788c5d; --color-err: #b53333; + --color-err-hover: #9c2b2b; + --color-err-pressed: #872525; --color-info: #6a9bcc; --color-warn: #a87514; @@ -45,9 +48,12 @@ html.dark { --color-wash-deep: #33312d; --color-accent: #d97757; --color-accent-hover: #e08b6d; + --color-accent-pressed: #c4674a; --color-chart: #d97757; --color-ok: #8fa574; --color-err: #d06060; + --color-err-hover: #d97070; + --color-err-pressed: #b85454; --color-info: #7fabd6; --color-warn: #c29135; color-scheme: dark; @@ -104,6 +110,13 @@ body { background: color-mix(in srgb, var(--color-ink) 22%, transparent); } +/* raw 弹层(ConfirmPop / TenantPicker 等自带圆角卡片):Naive 的 .n-popover + 在 raw 模式下不加背景与圆角,但 box-shadow 无条件应用——外层直角矩形的 + 阴影会在内层圆角卡四角露出方角,统一去掉外层阴影(卡片自带 shadow-overlay) */ +.n-popover--raw { + box-shadow: none; +} + /* 租户选择器面板(移动端):popover follower 只会翻转不会平移,窄屏下 bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切; 改为覆盖 follower 定位,固定顶部整宽展示(follower 挂 body 下,无 diff --git a/src/components/ChoiceChip.vue b/src/components/ChoiceChip.vue new file mode 100644 index 0000000..fbd6486 --- /dev/null +++ b/src/components/ChoiceChip.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/FormModal.vue b/src/components/FormModal.vue index 14b6aac..0f98f33 100644 --- a/src/components/FormModal.vue +++ b/src/components/FormModal.vue @@ -35,6 +35,7 @@ const modalStyle = computed(() => :class="isMobile ? 'form-modal-mobile' : ''" :mask-closable="!submitting" :close-on-esc="!submitting" + :closable="!submitting" @update:show="emit('update:show', $event)" > diff --git a/src/components/ai/AiChannelModelsModal.vue b/src/components/ai/AiChannelModelsModal.vue index 21d54df..7606abe 100644 --- a/src/components/ai/AiChannelModelsModal.vue +++ b/src/components/ai/AiChannelModelsModal.vue @@ -1,5 +1,5 @@ @@ -179,13 +171,22 @@ function confirmBlacklist(name: string) { > {{ testing === r.name ? '测试中…' : '测试' }} - + +
diff --git a/src/components/ai/AiChannelPanel.vue b/src/components/ai/AiChannelPanel.vue index 066d610..44bf720 100644 --- a/src/components/ai/AiChannelPanel.vue +++ b/src/components/ai/AiChannelPanel.vue @@ -1,10 +1,11 @@ diff --git a/src/composables/useFormat.ts b/src/composables/useFormat.ts index 2760e6a..0a4b4e2 100644 --- a/src/composables/useFormat.ts +++ b/src/composables/useFormat.ts @@ -20,6 +20,32 @@ export function fmtRelative(iso: string | null | undefined): string { return `${Math.floor(hr / 24)} 天前` } +/** UserAgent → 「Chrome · macOS」式设备标签;识别不出的部分省略 */ +export function uaLabel(ua: string): { label: string; mobile: boolean } { + const label = [uaBrowser(ua), uaOS(ua)].filter(Boolean).join(' · ') || '未知设备' + return { label, mobile: /Mobile|iPhone|Android/i.test(ua) } +} + +// 顺序敏感:Edge/Opera 的 UA 含 Chrome,Chrome 的 UA 含 Safari +function uaBrowser(ua: string): string { + if (/Edg(e|A|iOS)?\//.test(ua)) return 'Edge' + if (/OPR\/|Opera/.test(ua)) return 'Opera' + if (/Firefox\/|FxiOS\//.test(ua)) return 'Firefox' + if (/Chrome\/|CriOS\//.test(ua)) return 'Chrome' + if (/Safari\//.test(ua)) return 'Safari' + return '' +} + +function uaOS(ua: string): string { + if (/iPad/.test(ua)) return 'iPad' + if (/iPhone/.test(ua)) return 'iPhone' + if (/Android/.test(ua)) return 'Android' + if (/Windows/.test(ua)) return 'Windows' + if (/Mac OS X|Macintosh/.test(ua)) return 'macOS' + if (/Linux/.test(ua)) return 'Linux' + return '' +} + export function fmtBytes(n: number): string { if (n <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] diff --git a/src/composables/useWallet.ts b/src/composables/useWallet.ts new file mode 100644 index 0000000..7ed7295 --- /dev/null +++ b/src/composables/useWallet.ts @@ -0,0 +1,79 @@ +/** + * 浏览器注入钱包(EIP-1193)接入:EIP-6963 多钱包发现 + personal_sign 封装。 + * 不引钱包 SDK;WalletConnect 等远程签名方案不在支持范围。 + */ + +/** EIP-1193 provider 最小接口 */ +export interface Eip1193Provider { + request(args: { method: string; params?: unknown[] }): Promise +} + +/** EIP-6963 announce 事件携带的钱包信息 */ +export interface WalletDetail { + info: { uuid: string; name: string; icon: string; rdns: string } + provider: Eip1193Provider +} + +interface Eip6963AnnounceEvent extends Event { + detail: WalletDetail +} + +/** 兼容旧式单一注入的 window.ethereum */ +declare global { + interface Window { + ethereum?: Eip1193Provider + } +} + +/** + * 发现已安装的注入钱包:广播 EIP-6963 请求并收集 announce; + * 无响应时回退 window.ethereum(旧式单钱包注入)。 + */ +export function discoverWallets(collectMs = 300): Promise { + return new Promise((resolve) => { + const found: WalletDetail[] = [] + const onAnnounce = (e: Event) => { + const detail = (e as Eip6963AnnounceEvent).detail + if (detail?.provider && !found.some((w) => w.info.uuid === detail.info.uuid)) found.push(detail) + } + window.addEventListener('eip6963:announceProvider', onAnnounce) + window.dispatchEvent(new Event('eip6963:requestProvider')) + setTimeout(() => { + window.removeEventListener('eip6963:announceProvider', onAnnounce) + if (!found.length && window.ethereum) { + found.push({ + info: { uuid: 'legacy', name: '浏览器钱包', icon: '', rdns: 'legacy.injected' }, + provider: window.ethereum, + }) + } + resolve(found) + }, collectMs) + }) +} + +/** 连接钱包并返回首个授权账户地址;用户拒绝时抛出钱包侧错误 */ +export async function requestAccount(provider: Eip1193Provider): Promise { + const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[] + const addr = accounts?.[0] + if (!addr) throw new Error('钱包未返回账户地址') + return addr +} + +/** 对消息做 personal_sign(EIP-191);返回 0x 前缀的 65 字节签名 hex */ +export async function personalSign( + provider: Eip1193Provider, + message: string, + address: string, +): Promise { + const sig = (await provider.request({ + method: 'personal_sign', + params: [message, address], + })) as string + if (!sig || typeof sig !== 'string') throw new Error('钱包未返回签名') + return sig +} + +/** 钱包侧用户拒绝(EIP-1193 code 4001);拒绝不作为错误提示 */ +export function isUserRejected(e: unknown): boolean { + return typeof e === 'object' && e !== null && (e as { code?: number }).code === 4001 +} diff --git a/src/theme/naive.ts b/src/theme/naive.ts index 1cd754c..59a4881 100644 --- a/src/theme/naive.ts +++ b/src/theme/naive.ts @@ -9,9 +9,12 @@ function makeOverrides(t: ThemeTokens): GlobalThemeOverrides { common: { primaryColor: t.accent, primaryColorHover: t.accentHover, - primaryColorPressed: t.accentHover, + primaryColorPressed: t.accentPressed, primaryColorSuppl: t.accent, errorColor: t.err, + errorColorHover: t.errHover, + errorColorPressed: t.errPressed, + errorColorSuppl: t.err, successColor: t.ok, infoColor: t.info, warningColor: t.warn, diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts index 2cc2a0e..554dc2e 100644 --- a/src/theme/tokens.ts +++ b/src/theme/tokens.ts @@ -16,9 +16,12 @@ export const tokens = { washDeep: '#ECE9DF', accent: '#C96442', accentHover: '#AD4F2E', + accentPressed: '#9A4527', chart: '#C15F3C', ok: '#788C5D', err: '#B53333', + errHover: '#9C2B2B', + errPressed: '#872525', info: '#6A9BCC', warn: '#A87514', fontSans: @@ -45,9 +48,12 @@ export const darkTokens: ThemeTokens = { washDeep: '#33312D', accent: '#D97757', accentHover: '#E08B6D', + accentPressed: '#C4674A', chart: '#D97757', ok: '#8FA574', err: '#D06060', + errHover: '#D97070', + errPressed: '#B85454', info: '#7FABD6', warn: '#C29135', } diff --git a/src/types/api.ts b/src/types/api.ts index 7f379b7..7e0d763 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -85,6 +85,33 @@ export interface OauthProviderInfo { displayName: string } +/** 已注册的通行密钥(Passkey)凭据 */ +export interface PasskeyInfo { + id: number + name: string + createdAt: string + lastUsedAt: string | null +} + +/** WebAuthn 仪式发起响应;options 交给浏览器 API,sessionId 原样带回 finish */ +export interface PasskeyCeremonyOptions { + sessionId: string + options: { publicKey: T } +} + +/** 活跃登录会话;current 标记请求者自身会话(不可定点撤销) */ +export interface SessionItem { + id: number + /** 登录方式:password / oidc / github / passkey / wallet */ + method: string + clientIp: string + userAgent: string + createdAt: string + lastSeenAt: string + expiresAt: string + current: boolean +} + // ---- API Key 配置(租户)---- export type AccountType = 'paid' | 'trial' | 'free' | 'unknown' export type AliveStatus = 'alive' | 'dead' | 'suspended' | 'unknown' diff --git a/src/views/InstanceDetailView.vue b/src/views/InstanceDetailView.vue index 81fe00d..82c51d5 100644 --- a/src/views/InstanceDetailView.vue +++ b/src/views/InstanceDetailView.vue @@ -23,6 +23,8 @@ import { listConsoleConnections, listVolumeAttachments, terminateInstance, + terminatingInstances, + terminatingKey, updateInstance, } from '@/api/instances' import EmptyCard from '@/components/EmptyCard.vue' @@ -226,11 +228,20 @@ async function act(fn: () => Promise, ok: string) { } } -function power(action: PowerAction) { - void act( - () => instanceAction(cfgId.value, instanceId.value, action, region.value), - `${action} 已提交`, - ) +/** 电源操作在飞行中的动作,对应按钮 loading、整组防连点 */ +const powering = ref<'' | PowerAction>('') + +async function power(action: PowerAction) { + if (powering.value || terminating.value) return + powering.value = action + try { + await act( + () => instanceAction(cfgId.value, instanceId.value, action, region.value), + `${action} 已提交`, + ) + } finally { + powering.value = '' + } } const moreOptions = [ @@ -238,22 +249,35 @@ const moreOptions = [ { label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' }, ] +/** 终止飞行中(api 层共享锁):跨 Dialog/视图/重挂载互斥 */ +const terminating = computed(() => + terminatingInstances.has(terminatingKey(cfgId.value, instanceId.value)), +) + +/** 首次点击即锁定两个互斥分支,防慢请求期间「删除/保留引导卷」并发提交 */ function terminate() { - dialog.warning({ + if (terminating.value) { + message.warning('终止请求正在处理中') + return + } + let fired = false + const submit = (preserve: boolean) => { + if (fired || terminating.value) return false + fired = true + d.positiveButtonProps = { disabled: true } + d.negativeButtonProps = { disabled: true } + return act( + () => terminateInstance(cfgId.value, instanceId.value, preserve, region.value), + preserve ? '终止请求已提交(保留引导卷)' : '终止请求已提交', + ) + } + const d = dialog.warning({ title: '终止实例', content: '终止后实例不可恢复。是否同时保留引导卷?', positiveText: '终止并删除引导卷', negativeText: '终止但保留引导卷', - onPositiveClick: () => - act( - () => terminateInstance(cfgId.value, instanceId.value, false, region.value), - '终止请求已提交', - ), - onNegativeClick: () => - act( - () => terminateInstance(cfgId.value, instanceId.value, true, region.value), - '终止请求已提交(保留引导卷)', - ), + onPositiveClick: () => submit(false), + onNegativeClick: () => submit(true), }) } @@ -426,20 +450,56 @@ const consoleColumns: DataTableColumns = [
- + 启动 - 停止 - 重启 - + + + + + + + 终止 - +
diff --git a/src/views/InstanceListView.vue b/src/views/InstanceListView.vue index 6c54de4..d4f72c0 100644 --- a/src/views/InstanceListView.vue +++ b/src/views/InstanceListView.vue @@ -11,8 +11,15 @@ import { import { computed, h, ref, watch } from 'vue' import { RouterLink } from 'vue-router' -import { instanceAction, listInstances, terminateInstance } from '@/api/instances' +import { + instanceAction, + listInstances, + terminateInstance, + terminatingInstances, + terminatingKey, +} from '@/api/instances' import CompartmentSelect from '@/components/CompartmentSelect.vue' +import ConfirmPop from '@/components/ConfirmPop.vue' import DeadPlaceholder from '@/components/DeadPlaceholder.vue' import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue' import LifecycleBadge from '@/components/LifecycleBadge.vue' @@ -83,27 +90,61 @@ function isEnded(row: InstanceRow): boolean { return row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING' } +/** 行内电源操作在飞行中:实例 id → 动作;对应按钮 loading,整行防连点 */ +const powering = ref(new Map()) + async function power(row: InstanceRow, action: PowerAction) { + if (powering.value.has(row.id)) return + powering.value.set(row.id, action) try { await instanceAction(row.cfgId, row.id, action, scope.region || undefined) message.success(`${action} 已提交:${row.displayName}`) void rows.run() } catch (e) { message.error(e instanceof Error ? e.message : '操作失败') + } finally { + powering.value.delete(row.id) + } +} + +/** 电源按钮统一 loading / 禁用判定;终止飞行中电源操作一并禁用 */ +function powerBtnProps(row: InstanceRow, action: PowerAction) { + return { + size: 'tiny' as const, + quaternary: true, + loading: powering.value.get(row.id) === action, + disabled: + isEnded(row) || + powering.value.has(row.id) || + terminatingInstances.has(terminatingKey(row.cfgId, row.id)), } } const dialog = useDialog() -/** 与实例详情页一致:双选是否保留引导卷,返回 Promise 让 dialog 按钮 loading */ +/** 与实例详情页一致:双选是否保留引导卷,返回 Promise 让 dialog 按钮 loading; + * 首次点击即锁定两个互斥分支;跨 Dialog/视图/重挂载的互斥由 api 层 + * terminatingInstances 共享锁承担 */ function confirmTerminate(row: InstanceRow) { - dialog.warning({ + if (terminatingInstances.has(terminatingKey(row.cfgId, row.id))) { + message.warning('该实例的终止请求正在处理中') + return + } + let fired = false + const submit = (preserve: boolean) => { + if (fired || terminatingInstances.has(terminatingKey(row.cfgId, row.id))) return false + fired = true + d.positiveButtonProps = { disabled: true } + d.negativeButtonProps = { disabled: true } + return doTerminate(row, preserve) + } + const d = dialog.warning({ title: `终止实例 ${row.displayName}`, content: '终止后实例不可恢复。是否同时保留引导卷?', positiveText: '终止并删除引导卷', negativeText: '终止但保留引导卷', - onPositiveClick: () => doTerminate(row, false), - onNegativeClick: () => doTerminate(row, true), + onPositiveClick: () => submit(false), + onNegativeClick: () => submit(true), }) } @@ -177,22 +218,40 @@ const columns = computed>(() => [ row.lifecycleState === 'STOPPED' ? h( NButton, - { size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'START') }, + { ...powerBtnProps(row, 'START'), onClick: () => power(row, 'START') }, { default: () => '启动' }, ) : h( - NButton, - { size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'STOP') }, - { default: () => '停止' }, + ConfirmPop, + { + title: `停止实例「${row.displayName}」?`, + content: '实例将关机,其上服务与连接中断。', + kind: 'warn', + confirmText: '停止', + onConfirm: () => power(row, 'STOP'), + }, + { trigger: () => h(NButton, powerBtnProps(row, 'STOP'), { default: () => '停止' }) }, ), h( - NButton, - { size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'RESET') }, - { default: () => '重启' }, + ConfirmPop, + { + title: `重启实例「${row.displayName}」?`, + content: '实例将立即重启,期间服务中断。', + kind: 'warn', + confirmText: '重启', + onConfirm: () => power(row, 'RESET'), + }, + { trigger: () => h(NButton, powerBtnProps(row, 'RESET'), { default: () => '重启' }) }, ), h( NButton, - { size: 'tiny', quaternary: true, type: 'error', disabled: isEnded(row), onClick: () => confirmTerminate(row) }, + { + size: 'tiny', + quaternary: true, + type: 'error', + disabled: isEnded(row) || powering.value.has(row.id), + onClick: () => confirmTerminate(row), + }, { default: () => '终止' }, ), ]), @@ -210,7 +269,7 @@ const columns = computed>(() => [ {{ scope.currentConfig?.alias ?? '…' }}
- + 创建实例 @@ -279,24 +338,39 @@ const columns = computed>(() => [
启动 - - 停止 - - - 重启 - + + + + + + 终止 diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index e02d177..f3af16a 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -1,14 +1,30 @@