+160
-7
@@ -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<void> {
|
||||
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
|
||||
export function revokeSessions(): Promise<SessionRefresh> {
|
||||
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<void> {
|
||||
if (mockOn) return mocked(undefined, 250)
|
||||
return request(`/auth/sessions/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ---- 登录凭据 ----
|
||||
@@ -45,13 +84,17 @@ export function getCredentials(): Promise<CredentialsInfo> {
|
||||
/** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
|
||||
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
|
||||
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<SessionRefresh> {
|
||||
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<TotpSetup> {
|
||||
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function activateTotp(code: string): Promise<SessionRefresh> {
|
||||
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<SessionRefresh> {
|
||||
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<SessionRefresh> {
|
||||
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<PublicKeyCredentialCreationOptionsJSON> = {
|
||||
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<PublicKeyCredentialCreationOptionsJSON>
|
||||
> {
|
||||
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<SessionRefresh> {
|
||||
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<SessionRefresh> {
|
||||
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<PublicKeyCredentialRequestOptionsJSON>
|
||||
> {
|
||||
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<LoginResponse> {
|
||||
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<LoginResponse> {
|
||||
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 配置(设置页) ----
|
||||
|
||||
@@ -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<void>((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)
|
||||
})
|
||||
})
|
||||
+33
-3
@@ -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<string>())
|
||||
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
if (mockOn) return mocked(undefined, 600)
|
||||
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
|
||||
method: 'DELETE',
|
||||
query: { preserveBootVolume, region },
|
||||
|
||||
@@ -21,6 +21,27 @@ function stub401(rotateTo?: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function stubRefreshThen401() {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((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<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,6 +55,89 @@ afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** 可控完成时机的 fetch stub,记录调用次数 */
|
||||
function stubSlowFetch() {
|
||||
const calls = { count: 0 }
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((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<string, unknown>) {
|
||||
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()
|
||||
|
||||
+126
-24
@@ -20,8 +20,27 @@ interface RequestOptions {
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
/** 附加请求头 */
|
||||
headers?: Record<string, string>
|
||||
/** 成功响应会换发当前会话;并发 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<string, RefreshState>()
|
||||
|
||||
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<never> {
|
||||
let message = `请求失败(${resp.status})`
|
||||
let ociCode: string | undefined
|
||||
async function parseError(
|
||||
resp: Response,
|
||||
sentToken: string,
|
||||
): Promise<never> {
|
||||
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<ErrorDetails> {
|
||||
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<void>((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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
|
||||
const inflightWrites = new Map<string, Promise<unknown>>()
|
||||
|
||||
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<boolean> {
|
||||
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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method = (opts.method ?? 'GET').toUpperCase()
|
||||
if (method === 'GET') return doRequest<T>(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<T>
|
||||
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
|
||||
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
|
||||
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
|
||||
inflightWrites.delete(key)
|
||||
finishRefresh()
|
||||
})
|
||||
inflightWrites.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function doRequest<T>(
|
||||
path: string,
|
||||
opts: RequestOptions,
|
||||
finishRefresh: () => void = () => {},
|
||||
): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
@@ -86,10 +183,15 @@ export async function request<T>(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(二进制内容读写用) */
|
||||
|
||||
+9
-3
@@ -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<void> {
|
||||
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传;
|
||||
* userId 给定且异于当前签名用户时,一并把面板签名用户切换为该用户 */
|
||||
export function activateApiKey(
|
||||
id: number,
|
||||
fingerprint: string,
|
||||
privateKey: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) return mocked(undefined)
|
||||
return request(`/oci-configs/${id}/activate-api-key`, {
|
||||
method: 'POST',
|
||||
body: { fingerprint, privateKey },
|
||||
body: { userId, fingerprint, privateKey },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 下,无
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 互斥单选的 chip 按钮(按钮规范第 8 条):选中 accent 描边 + 浅底 + 加粗;
|
||||
* dashed 为「自定义」扩展位。多选/触发器场景不要用它。
|
||||
*/
|
||||
defineProps<{ active?: boolean; dashed?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1.5 text-xs transition-colors enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="[
|
||||
active
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2 enabled:hover:border-ink-3',
|
||||
dashed ? 'border-dashed' : '',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -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)"
|
||||
>
|
||||
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal, useDialog } from 'naive-ui'
|
||||
import { NButton, NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
syncAiChannelModels,
|
||||
testAiChannelModel,
|
||||
} from '@/api/aigateway'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiChannel, AiModelCacheItem } from '@/types/api'
|
||||
@@ -16,7 +17,6 @@ const props = defineProps<{ show: boolean; channel: AiChannel | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const rows = ref<AiModelCacheItem[]>([])
|
||||
@@ -99,23 +99,15 @@ async function doTest(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBlacklist(name: string) {
|
||||
dialog.warning({
|
||||
title: '拉黑模型',
|
||||
content: `「${name}」将从全部渠道剔除,同步与探测不再入库;可在模型黑名单中移出后重新同步恢复。`,
|
||||
positiveText: '拉黑',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await addAiBlacklist(name)
|
||||
toast.success('已拉黑')
|
||||
void reload()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '拉黑失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
async function doBlacklist(name: string) {
|
||||
try {
|
||||
await addAiBlacklist(name)
|
||||
toast.success('已拉黑')
|
||||
void reload()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '拉黑失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -179,13 +171,22 @@ function confirmBlacklist(name: string) {
|
||||
>
|
||||
{{ testing === r.name ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs text-ink-3/70 group-hover:text-err hover:bg-err/10"
|
||||
@click="confirmBlacklist(r.name)"
|
||||
<ConfirmPop
|
||||
:title="`拉黑模型「${r.name}」?`"
|
||||
content="将从全部渠道剔除,同步与探测不再入库。"
|
||||
note="可在模型黑名单中移出后重新同步恢复"
|
||||
confirm-text="拉黑"
|
||||
:on-confirm="() => doBlacklist(r.name)"
|
||||
>
|
||||
拉黑
|
||||
</button>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs text-ink-3/70 group-hover:text-err hover:bg-err/10"
|
||||
>
|
||||
拉黑
|
||||
</button>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!shownCount" class="py-10 text-center text-xs text-ink-3">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
|
||||
import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway'
|
||||
import { listCachedRegions } from '@/api/configs'
|
||||
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
@@ -25,7 +26,6 @@ const emit = defineEmits<{ changed: [] }>()
|
||||
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
/** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户);
|
||||
* 名称本身不承载跳转,单元格内截断 */
|
||||
@@ -135,28 +135,31 @@ function openModels(ch: AiChannel) {
|
||||
showModels.value = true
|
||||
}
|
||||
|
||||
/** 正在切换启用状态的渠道 id 集合,按行 loading 防连点 */
|
||||
const toggling = ref(new Set<number>())
|
||||
|
||||
async function toggle(ch: AiChannel) {
|
||||
if (toggling.value.has(ch.id)) return
|
||||
toggling.value.add(ch.id)
|
||||
try {
|
||||
await updateAiChannel(ch.id, { enabled: !ch.enabled })
|
||||
toast.success(ch.enabled ? '已停用' : '已启用')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
toggling.value.delete(ch.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(ch: AiChannel) {
|
||||
dialog.warning({
|
||||
title: '删除渠道',
|
||||
content: `删除渠道「${ch.name}」及其模型缓存?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteAiChannel(ch.id)
|
||||
toast.success('已删除')
|
||||
emit('changed')
|
||||
},
|
||||
})
|
||||
async function doRemove(ch: AiChannel) {
|
||||
try {
|
||||
await deleteAiChannel(ch.id)
|
||||
toast.success('已删除')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function probeCell(r: AiChannel) {
|
||||
@@ -204,9 +207,18 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', loading: probing.value.has(r.id), onClick: () => doProbe(r.id) }, { default: () => '探测' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openModels(r) }, { default: () => '模型列表' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除渠道「${r.name}」?`,
|
||||
content: '渠道及其模型缓存将一并删除。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => doRemove(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
|
||||
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
@@ -17,7 +18,6 @@ const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[]; m
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
|
||||
// ---- 新建 / 编辑弹窗 ----
|
||||
const showForm = ref(false)
|
||||
@@ -100,28 +100,31 @@ async function copyKey() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在切换启用状态的密钥 id 集合,按行 loading 防连点 */
|
||||
const toggling = ref(new Set<number>())
|
||||
|
||||
async function toggle(key: AiKey) {
|
||||
if (toggling.value.has(key.id)) return
|
||||
toggling.value.add(key.id)
|
||||
try {
|
||||
await updateAiKey(key.id, { enabled: !key.enabled })
|
||||
message.success(key.enabled ? '已停用' : '已启用')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
toggling.value.delete(key.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(key: AiKey) {
|
||||
dialog.warning({
|
||||
title: '删除密钥',
|
||||
content: `删除密钥「${key.name}」?使用该密钥的调用将立即失效。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteAiKey(key.id)
|
||||
message.success('已删除')
|
||||
emit('changed')
|
||||
},
|
||||
})
|
||||
async function doRemove(key: AiKey) {
|
||||
try {
|
||||
await deleteAiKey(key.id)
|
||||
message.success('已删除')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const groupHints = computed(() => props.groups.filter(Boolean))
|
||||
@@ -160,9 +163,18 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
{
|
||||
title: '操作', key: 'actions', width: 170,
|
||||
render: (r) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除密钥「${r.name}」?`,
|
||||
content: '使用该密钥的调用将立即失效。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => doRemove(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
@@ -221,6 +233,7 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
<span class="text-xs text-ink-3">小时(上限 7 天)</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
:loading="togglingContentLog"
|
||||
:disabled="!contentLogHours"
|
||||
@click="setContentLog(contentLogHours ?? 0)"
|
||||
|
||||
@@ -279,7 +279,8 @@ function isEditing(dir: Dir, idx: number): boolean {
|
||||
<td colspan="5" class="px-3.5 py-1.5">
|
||||
<NButton
|
||||
size="tiny"
|
||||
dashed
|
||||
quaternary
|
||||
type="primary"
|
||||
:disabled="!!editing"
|
||||
@click="startEdit(dir, -1)"
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
uploadViaPar,
|
||||
} from '@/api/objectstorage'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
@@ -642,9 +643,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
<div class="flex-1" />
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="small" @click="openParModal('')">分享桶</NButton>
|
||||
<NButton size="small" @click="emit('settings')">桶设置</NButton>
|
||||
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
<NButton size="medium" @click="openParModal('')">分享桶</NButton>
|
||||
<NButton size="medium" @click="emit('settings')">桶设置</NButton>
|
||||
<NButton size="medium" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
@@ -734,7 +735,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
|
||||
>
|
||||
<span class="font-medium">已选 {{ checkedKeys.length }} 项</span>
|
||||
<NButton size="tiny" @click="batchDownload">逐个下载</NButton>
|
||||
<NButton size="tiny" quaternary @click="batchDownload">逐个下载</NButton>
|
||||
<ConfirmPop
|
||||
:title="`删除选中的 ${checkedKeys.length} 个对象?`"
|
||||
content="未开版本控制时不可恢复。"
|
||||
@@ -763,8 +764,10 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
|
||||
<span class="flex flex-none items-center gap-1.5">
|
||||
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
|
||||
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
|
||||
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="prevPage">
|
||||
上一页
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!result.data.value?.nextStartWith" @click="nextPage">
|
||||
下一页
|
||||
</NButton>
|
||||
</span>
|
||||
@@ -922,16 +925,14 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
class="!w-24"
|
||||
/>
|
||||
<span class="text-[12.5px] text-ink-2">小时</span>
|
||||
<NButton
|
||||
<ChoiceChip
|
||||
v-for="q in QUICK_HOURS"
|
||||
:key="q.value"
|
||||
size="tiny"
|
||||
quaternary
|
||||
:type="parForm.expiresHours === q.value ? 'primary' : 'default'"
|
||||
:active="parForm.expiresHours === q.value"
|
||||
@click="parForm.expiresHours = q.value"
|
||||
>
|
||||
{{ q.label }}
|
||||
</NButton>
|
||||
</ChoiceChip>
|
||||
</div>
|
||||
</FormField>
|
||||
<div
|
||||
@@ -939,7 +940,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
|
||||
>
|
||||
<span class="mono min-w-0 flex-1 truncate text-xs">{{ parUrl }}</span>
|
||||
<NButton size="tiny" @click="copyParUrl">复制</NButton>
|
||||
<NButton size="tiny" quaternary @click="copyParUrl">复制</NButton>
|
||||
</div>
|
||||
<div v-if="parUrl" class="mt-1 text-xs text-warn">
|
||||
链接仅本次展示,关闭后无法再次获取,请立即保存
|
||||
|
||||
@@ -200,8 +200,8 @@ const columns = computed<DataTableColumns<Par>>(() => [
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<NButton size="tiny" :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
|
||||
<NButton size="tiny" :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import { onBeforeUnmount, computed, ref, watch } from 'vue'
|
||||
|
||||
import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight'
|
||||
@@ -60,22 +60,8 @@ async function renderMd() {
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1">
|
||||
<NButton
|
||||
size="tiny"
|
||||
:quaternary="renderMode !== 'render'"
|
||||
:type="renderMode === 'render' ? 'primary' : 'default'"
|
||||
@click="renderMode = 'render'"
|
||||
>
|
||||
渲染
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
:quaternary="renderMode !== 'code'"
|
||||
:type="renderMode === 'code' ? 'primary' : 'default'"
|
||||
@click="renderMode = 'code'"
|
||||
>
|
||||
代码
|
||||
</NButton>
|
||||
<ChoiceChip :active="renderMode === 'render'" @click="renderMode = 'render'">渲染</ChoiceChip>
|
||||
<ChoiceChip :active="renderMode === 'code'" @click="renderMode = 'code'">代码</ChoiceChip>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded bg-white">
|
||||
<img
|
||||
|
||||
@@ -127,7 +127,7 @@ async function save() {
|
||||
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" quaternary @click="emit('update:show', false)">取消</NButton>
|
||||
<NButton size="small" @click="emit('update:show', false)">取消</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
|
||||
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { discoverWallets, isUserRejected, personalSign, requestAccount } from '@/composables/useWallet'
|
||||
|
||||
import {
|
||||
activateTotp,
|
||||
beginPasskeyRegister,
|
||||
disableTotp,
|
||||
revokeSessions,
|
||||
finishPasskeyRegister,
|
||||
getCredentials,
|
||||
getOAuthSettings,
|
||||
getOauthAuthorizeUrl,
|
||||
getTotpStatus,
|
||||
getWalletChallenge,
|
||||
listIdentities,
|
||||
listPasskeys,
|
||||
removePasskey,
|
||||
setupTotp,
|
||||
unbindIdentity,
|
||||
updateCredentials,
|
||||
updateOAuthSettings,
|
||||
updatePasswordLogin,
|
||||
verifyWallet,
|
||||
} from '@/api/auth'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -42,12 +52,17 @@ function applySession(r: SessionRefresh) {
|
||||
if (r?.token) auth.setSession(r.token, r.expiresAt)
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
|
||||
const providerLabels: Record<string, string> = {
|
||||
github: 'GitHub',
|
||||
oidc: 'OIDC 单点登录',
|
||||
wallet: '钱包',
|
||||
}
|
||||
|
||||
/** provider 的展示名:配置显示名 → 默认名 → 原串 */
|
||||
/** provider 的展示名:配置显示名 → 默认名 → 原串;钱包等无自定义名,不得误读 GitHub 的 */
|
||||
function displayNameOf(p: string): string {
|
||||
const cfg = oauthCfg.data.value
|
||||
const custom = p === 'oidc' ? cfg?.oidcDisplayName : cfg?.githubDisplayName
|
||||
const custom =
|
||||
p === 'oidc' ? cfg?.oidcDisplayName : p === 'github' ? cfg?.githubDisplayName : ''
|
||||
return custom || providerLabels[p] || p
|
||||
}
|
||||
|
||||
@@ -119,12 +134,86 @@ async function copyTotpSecret() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通行密钥(Passkey) ----
|
||||
const passkeys = useAsync(listPasskeys)
|
||||
const showPasskeyModal = ref(false)
|
||||
const passkeyName = ref('')
|
||||
const passkeyBusy = ref(false)
|
||||
/** WebAuthn 仅在 secure context(HTTPS / localhost)可用;不支持时禁用入口并给出原因 */
|
||||
const webauthnSupported = browserSupportsWebAuthn()
|
||||
|
||||
function openPasskeyModal() {
|
||||
passkeyName.value = ''
|
||||
showPasskeyModal.value = true
|
||||
}
|
||||
|
||||
/** 两段式注册:后端 options → 浏览器验证器创建凭据 → 后端校验落库并换发会话 */
|
||||
async function confirmAddPasskey() {
|
||||
if (passkeyBusy.value) return // Enter 连按重入卫:流程进行中忽略再次提交
|
||||
passkeyBusy.value = true
|
||||
try {
|
||||
const { sessionId, options } = await beginPasskeyRegister()
|
||||
const credential = await startRegistration({ optionsJSON: options.publicKey })
|
||||
applySession(await finishPasskeyRegister(sessionId, passkeyName.value.trim(), credential))
|
||||
message.success('通行密钥已添加,登录页可一键免密登录')
|
||||
showPasskeyModal.value = false
|
||||
void passkeys.run()
|
||||
} catch (e) {
|
||||
// 用户在系统弹窗中取消(NotAllowedError)不算错误
|
||||
if (!(e instanceof Error && e.name === 'NotAllowedError'))
|
||||
message.error(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
passkeyBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskeyRow(id: number) {
|
||||
try {
|
||||
applySession(await removePasskey(id))
|
||||
message.success('已删除;该通行密钥后续无法登录面板')
|
||||
void passkeys.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外部身份绑定 ----
|
||||
const identities = useAsync(listIdentities)
|
||||
const oauthCfg = useAsync(getOAuthSettings)
|
||||
const binding = ref('')
|
||||
|
||||
// ---- 钱包绑定:注入钱包签名(EIP-4361),身份并入外部身份列表 ----
|
||||
const bindingWallet = ref(false)
|
||||
|
||||
async function bindWallet() {
|
||||
bindingWallet.value = true
|
||||
try {
|
||||
const wallets = await discoverWallets()
|
||||
if (!wallets.length) {
|
||||
message.error('未检测到浏览器钱包扩展(MetaMask / OKX 等)')
|
||||
return
|
||||
}
|
||||
// 绑定场景取首个钱包;多钱包用户可在扩展侧切换默认钱包
|
||||
const w = wallets[0]!
|
||||
const address = await requestAccount(w.provider)
|
||||
const { nonce, message: siwe } = await getWalletChallenge(address, 'bind')
|
||||
const signature = await personalSign(w.provider, siwe, address)
|
||||
applySession(await verifyWallet(nonce, signature))
|
||||
message.success('钱包已绑定,可在登录页用签名登录')
|
||||
void identities.run()
|
||||
} catch (e) {
|
||||
if (!isUserRejected(e)) message.error(e instanceof Error ? e.message : '绑定失败')
|
||||
} finally {
|
||||
bindingWallet.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasIdentity = computed(() => (identities.data.value?.items ?? []).length > 0)
|
||||
const hasPasskey = computed(() => (passkeys.data.value?.items ?? []).length > 0)
|
||||
/** 禁用密码登录的门槛:任一免密方式(通行密钥或外部身份)即可;已禁用时始终可关闭 */
|
||||
const canDisablePassword = computed(() => hasIdentity.value || hasPasskey.value)
|
||||
/** 免密登录卡是否还没有任何条目(空态文案) */
|
||||
const passwordlessEmpty = computed(() => !hasIdentity.value && !hasPasskey.value)
|
||||
|
||||
// ---- 登录凭据:用户名 / 密码修改与密码登录开关 ----
|
||||
const creds = useAsync(getCredentials)
|
||||
@@ -185,12 +274,12 @@ async function saveCredentials() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */
|
||||
/** 切换密码登录禁用;开启由后端校验至少一种免密方式(通行密钥或外部身份) */
|
||||
async function togglePasswordLogin(disabled: boolean) {
|
||||
togglingPwLogin.value = true
|
||||
try {
|
||||
applySession(await updatePasswordLogin(disabled))
|
||||
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
|
||||
message.success(disabled ? '已禁用密码登录,仅可用通行密钥或外部身份登录' : '已恢复密码登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
@@ -199,17 +288,7 @@ async function togglePasswordLogin(disabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 已配置 clientID 的 provider 才显示绑定入口 */
|
||||
const bindableProviders = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const out: string[] = []
|
||||
if (cfg.oidcClientId) out.push('oidc')
|
||||
if (cfg.githubClientId) out.push('github')
|
||||
return out
|
||||
})
|
||||
|
||||
/** 已绑定的 provider 集合;绑定入口按钮据此禁用,解绑后自动恢复 */
|
||||
/** 已绑定的 provider 集合;表格「绑定」按钮据此置灰,解绑后自动恢复 */
|
||||
const boundProviders = computed(
|
||||
() => new Set((identities.data.value?.items ?? []).map((it) => it.provider)),
|
||||
)
|
||||
@@ -235,21 +314,6 @@ async function removeIdentity(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 撤销全部会话:令牌版本递增,除本会话(换新)外全部立即失效 ----
|
||||
const revoking = ref(false)
|
||||
|
||||
async function revokeAllSessions() {
|
||||
revoking.value = true
|
||||
try {
|
||||
applySession(await revokeSessions())
|
||||
message.success('已撤销全部会话;其他设备须重新登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
|
||||
type ProviderType = 'github' | 'oidc'
|
||||
|
||||
@@ -274,13 +338,20 @@ const pForm = reactive({
|
||||
const providerRows = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const rows: { provider: ProviderType; name: string; clientId: string; disabled: boolean }[] = []
|
||||
const rows: {
|
||||
provider: ProviderType
|
||||
name: string
|
||||
clientId: string
|
||||
disabled: boolean
|
||||
ready: boolean
|
||||
}[] = []
|
||||
if (cfg.githubClientId)
|
||||
rows.push({
|
||||
provider: 'github',
|
||||
name: displayNameOf('github'),
|
||||
clientId: cfg.githubClientId,
|
||||
disabled: cfg.githubDisabled,
|
||||
ready: cfg.githubSecretSet,
|
||||
})
|
||||
if (cfg.oidcClientId)
|
||||
rows.push({
|
||||
@@ -288,6 +359,7 @@ const providerRows = computed(() => {
|
||||
name: displayNameOf('oidc'),
|
||||
clientId: cfg.oidcClientId,
|
||||
disabled: cfg.oidcDisabled,
|
||||
ready: cfg.oidcSecretSet && Boolean(cfg.oidcIssuer),
|
||||
})
|
||||
return rows
|
||||
})
|
||||
@@ -313,15 +385,18 @@ function openEditProvider(p: ProviderType) {
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
const pFormValid = computed(
|
||||
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
|
||||
)
|
||||
|
||||
const secretConfigured = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
|
||||
})
|
||||
|
||||
const pFormValid = computed(
|
||||
() =>
|
||||
Boolean(pForm.clientId.trim()) &&
|
||||
(pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())) &&
|
||||
(secretConfigured.value || Boolean(pForm.secret)),
|
||||
)
|
||||
|
||||
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
|
||||
function providerFormPatch(): UpdateOAuthRequest {
|
||||
const name = pForm.displayName.trim()
|
||||
@@ -390,132 +465,197 @@ async function deleteProvider(p: ProviderType) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 账号安全:两步验证 + 外部身份 -->
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">账号安全</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">两步验证与外部身份</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
|
||||
<!-- 密码登录 / 免密登录:双栏卡(窄屏纵排),方案B 布局 -->
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">登录凭据与两步验证</div>
|
||||
</div>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="totp.data.value?.enabled ? 'bg-ok' : 'bg-ink-3/50'"
|
||||
/>
|
||||
两步验证{{ totp.data.value?.enabled ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
|
||||
>
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="totp.data.value?.enabled ? 'bg-ok' : 'bg-ink-3/50'"
|
||||
/>
|
||||
两步验证{{ totp.data.value?.enabled ? '已启用' : '未启用' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4.5 py-1">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">登录凭据</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">密码登录时额外输入验证器动态码</div>
|
||||
</div>
|
||||
<NSpin v-if="totp.loading.value" size="small" />
|
||||
<NButton
|
||||
v-else-if="totp.data.value?.enabled"
|
||||
size="tiny"
|
||||
type="error"
|
||||
quaternary
|
||||
@click="showTotpDisable = true"
|
||||
>
|
||||
停用
|
||||
</NButton>
|
||||
<NButton v-else size="tiny" type="primary" :loading="totpBusy" @click="startTotpSetup">
|
||||
启用
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">禁用密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{{
|
||||
canDisablePassword || creds.data.value?.passwordLoginDisabled
|
||||
? '开启后仅可用通行密钥或外部身份登录;关闭随时恢复'
|
||||
: '需至少添加一枚通行密钥或绑定一个外部身份'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<NSwitch
|
||||
:value="creds.data.value?.passwordLoginDisabled ?? false"
|
||||
size="small"
|
||||
:loading="togglingPwLogin"
|
||||
:disabled="!canDisablePassword && !creds.data.value?.passwordLoginDisabled"
|
||||
@update:value="togglePasswordLogin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5 py-1">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
登录需额外输入验证器动态码
|
||||
</div>
|
||||
</div>
|
||||
<NSpin v-if="totp.loading.value" size="small" />
|
||||
<NButton
|
||||
v-else-if="totp.data.value?.enabled"
|
||||
size="tiny"
|
||||
type="error"
|
||||
quaternary
|
||||
@click="showTotpDisable = true"
|
||||
>
|
||||
停用
|
||||
</NButton>
|
||||
<NButton v-else size="tiny" type="primary" :loading="totpBusy" @click="startTotpSetup">
|
||||
启用
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">登录凭据</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">撤销全部会话</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
怀疑令牌泄露时使用;其他设备与旧令牌立即失效,本会话自动换新
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" type="error" quaternary :loading="revoking" @click="revokeAllSessions">
|
||||
撤销
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">禁用密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{{
|
||||
hasIdentity || creds.data.value?.passwordLoginDisabled
|
||||
? '开启后仅外部身份可登录;关闭随时恢复密码登录'
|
||||
: '至少绑定一个外部身份(OAuth2)后才能开启'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<NSwitch
|
||||
:value="creds.data.value?.passwordLoginDisabled ?? false"
|
||||
size="small"
|
||||
:loading="togglingPwLogin"
|
||||
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled"
|
||||
@update:value="togglePasswordLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div
|
||||
v-for="it in identities.data.value?.items ?? []"
|
||||
:key="it.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">
|
||||
{{ displayNameOf(it.provider) }} · {{ it.display }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
|
||||
<div class="text-sm font-semibold">免密登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">通行密钥、OAuth 与钱包;解绑受防自锁保护</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="primary"
|
||||
:loading="passkeyBusy"
|
||||
:disabled="!webauthnSupported"
|
||||
@click="openPasskeyModal"
|
||||
>
|
||||
添加通行密钥
|
||||
</NButton>
|
||||
<NButton size="tiny" :loading="bindingWallet" @click="bindWallet">绑定钱包</NButton>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
:title="`解绑 ${displayNameOf(it.provider)} 身份?`"
|
||||
content="解绑后该身份无法再登录面板。"
|
||||
kind="plain"
|
||||
confirm-text="解绑"
|
||||
:on-confirm="() => removeIdentity(it.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>解绑</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
<div class="px-4.5 py-1">
|
||||
<div
|
||||
v-for="pk in passkeys.data.value?.items ?? []"
|
||||
:key="'pk-' + pk.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
|
||||
<path d="M8.5 11a3.5 3.5 0 0 1 7 0c0 2.2-.2 4.2-.7 6" />
|
||||
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13px] font-medium">通行密钥 · {{ pk.name }}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
添加于 {{ fmtTime(pk.createdAt) }}<template v-if="pk.lastUsedAt">
|
||||
· 最近使用 {{ fmtTime(pk.lastUsedAt) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
:title="`删除通行密钥「${pk.name}」?`"
|
||||
content="删除后该密钥无法再登录面板;设备侧的凭据条目需自行清理。"
|
||||
kind="plain"
|
||||
confirm-text="删除"
|
||||
:on-confirm="() => removePasskeyRow(pk.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="bindableProviders.length"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<NButton
|
||||
v-for="p in bindableProviders"
|
||||
:key="p"
|
||||
size="small"
|
||||
:loading="binding === p"
|
||||
:disabled="boundProviders.has(p)"
|
||||
@click="bindProvider(p)"
|
||||
<div
|
||||
v-for="it in identities.data.value?.items ?? []"
|
||||
:key="it.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
>
|
||||
{{ boundProviders.has(p) ? '已绑定' : '绑定' }} {{ displayNameOf(p) }}
|
||||
</NButton>
|
||||
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
|
||||
</div>
|
||||
<div class="py-2.5 text-xs text-ink-3">
|
||||
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册)
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
v-if="it.provider === 'wallet'"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
|
||||
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
|
||||
</svg>
|
||||
<OauthProviderIcon v-else :provider="it.provider" class="!h-3.5 !w-3.5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13px] font-medium">
|
||||
{{ displayNameOf(it.provider) }} · {{ it.display }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
:title="`解绑 ${displayNameOf(it.provider)} 身份?`"
|
||||
content="解绑后该身份无法再登录面板。"
|
||||
kind="plain"
|
||||
confirm-text="解绑"
|
||||
:on-confirm="() => removeIdentity(it.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>解绑</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div v-if="passwordlessEmpty" class="border-b border-line-soft py-2.5 text-xs text-ink-3">
|
||||
尚未添加任何免密登录方式;添加后可在登录页免密码一键登录
|
||||
</div>
|
||||
<div v-if="!webauthnSupported" class="pt-2.5 text-xs text-warn">
|
||||
当前环境不支持通行密钥:需通过 HTTPS(或 localhost)访问面板
|
||||
</div>
|
||||
<div class="py-2.5 text-xs text-ink-3">
|
||||
OAuth 身份在下方「登录方式配置」操作列发起绑定;未绑定的身份无法登录(不开放注册)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -527,7 +667,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">登录方式配置</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">配置后登录页与上方绑定入口即出现对应方式</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">OAuth 提供方;启用后登录页出现入口,在操作列绑定到当前账号</div>
|
||||
</div>
|
||||
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
|
||||
</div>
|
||||
@@ -539,7 +679,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">Client ID</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">状态</th>
|
||||
<th class="w-40 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
<th class="w-52 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -557,11 +697,24 @@ async function deleteProvider(p: ProviderType) {
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
|
||||
{{ row.disabled ? '已禁用' : '启用中' }}
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="!row.ready ? 'bg-warn' : row.disabled ? 'bg-ink-3' : 'bg-ok'"
|
||||
/>
|
||||
{{ !row.ready ? '配置不完整' : row.disabled ? '已禁用' : '启用中' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:loading="binding === row.provider"
|
||||
:disabled="!row.ready || boundProviders.has(row.provider)"
|
||||
@click="bindProvider(row.provider)"
|
||||
>
|
||||
绑定
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@@ -590,7 +743,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
</div>
|
||||
<FootNote>
|
||||
在 IdP / GitHub 应用中填写的授权回调地址:
|
||||
{{ callbackBase}}/api/v1/auth/oauth/<github|oidc>/callback
|
||||
{{ callbackBase }}/api/v1/auth/oauth/<github|oidc>/callback;已绑定的身份在上方「免密登录」中解绑
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
@@ -607,22 +760,15 @@ async function deleteProvider(p: ProviderType) {
|
||||
>
|
||||
<FormField label="类型" required>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
<ChoiceChip
|
||||
v-for="t in PROVIDER_TYPES"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1.5 text-xs"
|
||||
:class="[
|
||||
pForm.provider === t.value
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2',
|
||||
editingProvider ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-ink-3',
|
||||
]"
|
||||
:active="pForm.provider === t.value"
|
||||
:disabled="!!editingProvider"
|
||||
@click="pForm.provider = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</ChoiceChip>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}」`">
|
||||
@@ -653,6 +799,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Client Secret"
|
||||
required
|
||||
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
|
||||
>
|
||||
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
|
||||
@@ -704,6 +851,29 @@ async function deleteProvider(p: ProviderType) {
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<!-- 添加通行密钥弹窗:命名后唤起浏览器验证器 -->
|
||||
<FormModal
|
||||
:show="showPasskeyModal"
|
||||
title="添加通行密钥"
|
||||
:width="380"
|
||||
:submitting="passkeyBusy"
|
||||
submit-text="继续"
|
||||
@update:show="showPasskeyModal = $event"
|
||||
@submit="confirmAddPasskey"
|
||||
>
|
||||
<FormField label="名称" hint="便于区分设备,如「MacBook Touch ID」;留空用默认名">
|
||||
<NInput
|
||||
v-model:value="passkeyName"
|
||||
:maxlength="64"
|
||||
placeholder="通行密钥"
|
||||
@keyup.enter="confirmAddPasskey"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="text-xs text-ink-3">
|
||||
点击「继续」后按浏览器提示完成验证(生物识别 / PIN / 安全钥匙)
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<!-- 停用两步验证弹窗 -->
|
||||
<FormModal
|
||||
:show="showTotpDisable"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { listSessions, revokeSession, revokeSessions } from '@/api/auth'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtRelative, fmtTime, uaLabel } from '@/composables/useFormat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { SessionItem } from '@/types/api'
|
||||
|
||||
/** 活跃会话卡:全宽表格列出当前有效登录会话,支持定点撤销与撤销全部 */
|
||||
const message = useToast()
|
||||
const auth = useAuthStore()
|
||||
const sessions = useAsync(listSessions)
|
||||
|
||||
const items = computed(() => sessions.data.value?.items ?? [])
|
||||
/** 零行才是空态:单条本机会话也渲染表格(展示设备/IP/时间比空文案更有信息量);
|
||||
* 零行不证明只有一个活跃会话——升级前签发的旧令牌不产生记录 */
|
||||
const empty = computed(() => items.value.length === 0)
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
password: '密码',
|
||||
passkey: '通行密钥',
|
||||
wallet: '钱包',
|
||||
oidc: 'OIDC',
|
||||
github: 'GitHub',
|
||||
}
|
||||
|
||||
function deviceOf(s: SessionItem) {
|
||||
return uaLabel(s.userAgent)
|
||||
}
|
||||
|
||||
/** 撤销互斥:单个与全部共享——撤销全部在服务端递增版本后、前端写入新
|
||||
* token 前,并发的单撤销会携带旧 token 吃 401 并触发全局登出竞态 */
|
||||
const revoking = ref(false)
|
||||
|
||||
async function revokeOne(s: SessionItem) {
|
||||
if (revoking.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await revokeSession(s.id)
|
||||
message.success('已撤销,该设备将立即退出登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '撤销失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
void sessions.run({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销全部 = 令牌版本递增:其他设备立即失效,本会话经新 token 无感换新 */
|
||||
async function revokeAll() {
|
||||
if (revoking.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
const r = await revokeSessions()
|
||||
if (r?.token) auth.setSession(r.token, r.expiresAt)
|
||||
message.success('已撤销全部会话;其他设备须重新登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
void sessions.run({ silent: true })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">活跃会话</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
当前有效登录会话,最近活跃在前;登出、撤销或凭据变更后自动消失
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
title="撤销全部会话?"
|
||||
content="其他设备与旧令牌立即失效并需重新登录;本会话自动换新。"
|
||||
confirm-text="全部撤销"
|
||||
:on-confirm="revokeAll"
|
||||
>
|
||||
<template #trigger>
|
||||
<!-- 不依赖列表禁用(存量令牌唯一失效入口);仅与单撤销互斥 -->
|
||||
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销全部会话</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div v-if="sessions.loading.value && !sessions.data.value" class="flex justify-center py-8">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<!-- 请求失败与空列表分开呈现,失败不得伪装成「仅本机一个会话」 -->
|
||||
<div
|
||||
v-else-if="sessions.error.value"
|
||||
class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3"
|
||||
>
|
||||
会话列表加载失败:{{ sessions.error.value }}
|
||||
<NButton size="tiny" quaternary type="primary" class="ml-1" @click="() => sessions.run()">
|
||||
重试
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-else-if="empty" class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3">
|
||||
暂无会话记录。升级前签发的旧令牌不产生记录,不代表没有其他活跃会话;
|
||||
如有顾虑可直接「撤销全部会话」。
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="border-b border-line-soft px-4.5 py-2 font-medium">设备</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">登录方式</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">IP</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">登录时间</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">最近活跃</th>
|
||||
<th class="w-20 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in items" :key="s.id" class="[&:last-child>td]:border-b-0">
|
||||
<td class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
v-if="deviceOf(s).mobile"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<rect x="7" y="2" width="10" height="20" rx="2" />
|
||||
<path d="M11 18h2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="font-medium whitespace-nowrap">{{ deviceOf(s).label }}</span>
|
||||
<span
|
||||
v-if="s.current"
|
||||
class="rounded-full bg-ok/12 px-2 py-px text-[11px] font-semibold whitespace-nowrap text-ok"
|
||||
>
|
||||
本机
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span
|
||||
class="rounded-full border border-line bg-white px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
|
||||
>
|
||||
{{ methodLabels[s.method] || s.method || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono border-b border-line-soft px-3 py-2.5 text-ink-2">{{ s.clientIp }}</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
|
||||
{{ fmtTime(s.createdAt) }}
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
|
||||
{{ fmtRelative(s.lastSeenAt) }}
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span v-if="s.current" class="px-2 text-xs text-ink-3">—</span>
|
||||
<ConfirmPop
|
||||
v-else
|
||||
title="撤销该会话?"
|
||||
:content="`${deviceOf(s).label}(${s.clientIp})将立即退出登录;其他会话不受影响。`"
|
||||
confirm-text="撤销"
|
||||
:on-confirm="() => revokeOne(s)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<FootNote>
|
||||
当前会话不可定点撤销(请用退出登录);升级前签发的存量令牌不在列表中,24h 内自然过期
|
||||
</FootNote>
|
||||
</div>
|
||||
</template>
|
||||
@@ -191,7 +191,6 @@ async function sendTest() {
|
||||
:key="p.label"
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
@click="form.bodyTemplate = p.tpl"
|
||||
>
|
||||
{{ p.label }}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cronText, parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/c
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
import type { Task } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ task: Task; cfgAlias?: string }>()
|
||||
const props = defineProps<{ task: Task; cfgAlias?: string; busy?: boolean }>()
|
||||
const emit = defineEmits<{ run: []; toggle: []; edit: []; remove: [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -79,7 +79,7 @@ function goDetail() {
|
||||
<div class="flex flex-none items-center gap-0.5" @click.stop>
|
||||
<NTooltip v-if="task.status !== 'succeeded'">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="emit('toggle')">
|
||||
<NButton size="small" quaternary :disabled="busy" @click="emit('toggle')">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -98,7 +98,7 @@ function goDetail() {
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="emit('run')">
|
||||
<NButton size="small" quaternary :disabled="busy" @click="emit('run')">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -116,7 +116,7 @@ function goDetail() {
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="goDetail">
|
||||
<NButton size="small" quaternary @click="goDetail">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,7 +132,7 @@ function goDetail() {
|
||||
详情与事件流
|
||||
</NTooltip>
|
||||
<NDropdown v-if="moreOptions.length" :options="moreOptions" trigger="click" @select="onMore">
|
||||
<NButton size="small" quaternary circle>
|
||||
<NButton size="small" quaternary>
|
||||
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import type { CreatedApiKey, IamUser, UserApiKey } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; cfgId: number; user: IamUser | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; switched: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(760)
|
||||
@@ -83,14 +83,17 @@ async function doDelete(fp: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 把刚创建的 key 设为面板签名凭据;私钥仅存在于本次弹窗内存中 */
|
||||
/** 把刚创建的 key 设为面板签名凭据;非当前签名用户则一并切换签名用户。
|
||||
* 私钥仅存在于本次弹窗内存中 */
|
||||
async function doActivate() {
|
||||
if (!created.value || activating.value) return
|
||||
if (!created.value || !props.user || activating.value) return
|
||||
const switched = !props.user.isCurrentUser
|
||||
activating.value = true
|
||||
try {
|
||||
await activateApiKey(props.cfgId, created.value.fingerprint, created.value.privateKey)
|
||||
await activateApiKey(props.cfgId, created.value.fingerprint, created.value.privateKey, props.user.id)
|
||||
activated.value = true
|
||||
toast.success('面板签名凭据已切换到该 key')
|
||||
toast.success(switched ? `面板签名已切换到用户 ${props.user.name}` : '面板签名凭据已切换到该 key')
|
||||
if (switched) emit('switched')
|
||||
await reload()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '设置失败')
|
||||
@@ -182,10 +185,33 @@ async function copyIni(text: string) {
|
||||
</div>
|
||||
<pre class="mono overflow-x-auto px-3 pt-1 pb-2.5 text-xs leading-relaxed">{{ created.configIni }}</pre>
|
||||
</div>
|
||||
<div v-if="user?.isCurrentUser" class="mt-3 flex">
|
||||
<NButton size="tiny" secondary :loading="activating" :disabled="activated" @click="doActivate">
|
||||
<div class="mt-3 flex">
|
||||
<NButton
|
||||
v-if="user?.isCurrentUser"
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:loading="activating"
|
||||
:disabled="activated"
|
||||
@click="doActivate"
|
||||
>
|
||||
{{ activated ? '已设为面板签名 key' : '设为面板签名 key' }}
|
||||
</NButton>
|
||||
<ConfirmPop
|
||||
v-else
|
||||
title="替换面板签名凭据?"
|
||||
:content="`面板将改用此 key 签名。`"
|
||||
note="权限随该用户变化"
|
||||
kind="warn"
|
||||
confirm-text="替换"
|
||||
:on-confirm="doActivate"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="primary" :loading="activating" :disabled="activated">
|
||||
{{ activated ? '已切换面板签名到该用户' : '替换面板签名凭据' }}
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -212,7 +238,7 @@ async function copyIni(text: string) {
|
||||
:on-confirm="() => doDelete(selectedFp)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" secondary type="error">删除此 Key</NButton>
|
||||
<NButton size="tiny" quaternary type="error">删除此 Key</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
|
||||
import { h, ref } from 'vue'
|
||||
import { NButton, NDataTable, NSwitch, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref } from 'vue'
|
||||
|
||||
import {
|
||||
activateIdp,
|
||||
@@ -22,10 +22,15 @@ import { useToast } from '@/composables/useToast'
|
||||
const props = defineProps<{ cfgId: number; domainId?: string }>()
|
||||
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const idps = useAsync(() => listIdps(props.cfgId, props.domainId))
|
||||
const rules = useAsync(() => listSignOnRules(props.cfgId, props.domainId))
|
||||
const showCreate = ref(false)
|
||||
const downloading = ref(false)
|
||||
const exempting = ref(false)
|
||||
/** 正在切换启用状态的 IdP id 集合,开关按行 loading 防连拨 */
|
||||
const toggling = ref(new Set<string>())
|
||||
|
||||
const enabledIdp = computed(() => idps.data.value?.find((i) => i.enabled))
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
try {
|
||||
@@ -38,6 +43,16 @@ async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleIdp(row: IdentityProvider, v: boolean) {
|
||||
if (toggling.value.has(row.id)) return
|
||||
toggling.value.add(row.id)
|
||||
try {
|
||||
await act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用')
|
||||
} finally {
|
||||
toggling.value.delete(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
const idpColumns: DataTableColumns<IdentityProvider> = [
|
||||
{
|
||||
title: 'IdP',
|
||||
@@ -75,13 +90,24 @@ const idpColumns: DataTableColumns<IdentityProvider> = [
|
||||
h(NSwitch, {
|
||||
size: 'small',
|
||||
value: row.enabled,
|
||||
onUpdateValue: (v: boolean) =>
|
||||
act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用'),
|
||||
loading: toggling.value.has(row.id),
|
||||
disabled: toggling.value.has(row.id),
|
||||
onUpdateValue: (v: boolean) => toggleIdp(row, v),
|
||||
}),
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteIdp(row) },
|
||||
{ default: () => '删除' },
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除 IdP「${row.name}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
note: '将自动清理关联的免 MFA 规则,并从登录页移除',
|
||||
confirmText: '删除',
|
||||
onConfirm: () =>
|
||||
act(() => deleteIdp(props.cfgId, row.id, props.domainId), `已删除 IdP「${row.name}」及其关联规则`),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -139,39 +165,30 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
|
||||
]
|
||||
|
||||
async function doDownloadMetadata() {
|
||||
if (downloading.value) return
|
||||
downloading.value = true
|
||||
try {
|
||||
await downloadSamlMetadata(props.cfgId, props.domainId)
|
||||
message.success('SAML 元数据已下载')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '下载失败')
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteIdp(row: IdentityProvider) {
|
||||
dialog.warning({
|
||||
title: '删除身份提供商',
|
||||
content: () =>
|
||||
h('div', { class: 'text-[13px]' }, [
|
||||
h('p', `确定删除 IdP「${row.name}」?此操作不可恢复。`),
|
||||
h('p', { class: 'mt-1.5 text-ink-3' }, '将自动清理关联的免 MFA 规则,并从登录页移除'),
|
||||
]),
|
||||
positiveText: '确认删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () =>
|
||||
act(() => deleteIdp(props.cfgId, row.id, props.domainId), `已删除 IdP「${row.name}」及其关联规则`),
|
||||
})
|
||||
}
|
||||
|
||||
function addExemption() {
|
||||
const enabled = idps.data.value?.find((i) => i.enabled)
|
||||
if (!enabled) {
|
||||
message.warning('没有已启用的 IdP')
|
||||
return
|
||||
async function addExemption() {
|
||||
const enabled = enabledIdp.value
|
||||
if (!enabled || exempting.value) return
|
||||
exempting.value = true
|
||||
try {
|
||||
await act(
|
||||
() => createSignOnExemption(props.cfgId, enabled.id, props.domainId),
|
||||
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
|
||||
)
|
||||
} finally {
|
||||
exempting.value = false
|
||||
}
|
||||
void act(
|
||||
() => createSignOnExemption(props.cfgId, enabled.id, props.domainId),
|
||||
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -181,7 +198,7 @@ function addExemption() {
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">SAML 身份提供商</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" @click="doDownloadMetadata">下载 SP 元数据</NButton>
|
||||
<NButton size="small" :loading="downloading" @click="doDownloadMetadata">下载 SP 元数据</NButton>
|
||||
<NButton size="small" type="primary" @click="showCreate = true">创建 IdP</NButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,7 +217,24 @@ function addExemption() {
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">Sign-on 策略(免 MFA 规则)</div>
|
||||
<NButton size="small" @click="addExemption">为已启用 IdP 创建免 MFA 规则</NButton>
|
||||
<ConfirmPop
|
||||
title="创建免 MFA 规则?"
|
||||
:content="`经「${enabledIdp?.name}」登录的用户将跳过 MFA,规则置顶生效。`"
|
||||
kind="warn"
|
||||
confirm-text="创建"
|
||||
:on-confirm="addExemption"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
:loading="exempting"
|
||||
:disabled="!enabledIdp"
|
||||
:title="enabledIdp ? undefined : '没有已启用的 IdP'"
|
||||
>
|
||||
为已启用 IdP 创建免 MFA 规则
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="ruleColumns"
|
||||
|
||||
@@ -283,7 +283,7 @@ function policyDesc(p: PasswordPolicy): string {
|
||||
<span class="min-w-0 flex-1 text-xs text-ink-3">
|
||||
推荐直接「一键创建链路」;仅需回调地址手动配置 OCI 侧时,可单独生成
|
||||
</span>
|
||||
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook">
|
||||
<NButton size="tiny" quaternary :loading="savingWebhook" @click="createWebhook">
|
||||
生成回调地址
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
@@ -48,20 +48,6 @@ async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row: IamUser) {
|
||||
dialog.warning({
|
||||
title: '删除用户',
|
||||
content: () =>
|
||||
h('div', { class: 'text-[13px]' }, [
|
||||
h('p', `确定删除用户「${row.name}」?此操作不可恢复。`),
|
||||
h('p', { class: 'mt-1.5 text-ink-3' }, '删除后该用户的全部 API Key 将一并吊销'),
|
||||
]),
|
||||
positiveText: '确认删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id, props.domainId), `已删除用户 ${row.name}`),
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPwd(row: IamUser) {
|
||||
try {
|
||||
const { password } = await resetUserPassword(props.cfgId, row.id, props.domainId)
|
||||
@@ -155,9 +141,18 @@ const columns: DataTableColumns<IamUser> = [
|
||||
row.isCurrentUser
|
||||
? null
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) },
|
||||
{ default: () => '删除' },
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除用户「${row.name}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
note: '删除后该用户的全部 API Key 将一并吊销',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => act(() => deleteUser(props.cfgId, row.id, props.domainId), `已删除用户 ${row.name}`),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
@@ -189,6 +184,6 @@ const columns: DataTableColumns<IamUser> = [
|
||||
@created="users.run()"
|
||||
/>
|
||||
|
||||
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" />
|
||||
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" @switched="users.run()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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<unknown>
|
||||
}
|
||||
|
||||
/** 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<WalletDetail[]> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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
|
||||
}
|
||||
+4
-1
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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<T> {
|
||||
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'
|
||||
|
||||
@@ -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<unknown>, 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<ConsoleConnection> = [
|
||||
<div class="flex-1" />
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
|
||||
<NButton
|
||||
v-if="isStopped"
|
||||
size="small"
|
||||
:loading="powering === 'START'"
|
||||
:disabled="isEnded || !!powering || terminating"
|
||||
@click="power('START')"
|
||||
>
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
|
||||
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
|
||||
<ConfirmPop
|
||||
v-else
|
||||
title="停止实例?"
|
||||
content="实例将关机,其上服务与连接中断。"
|
||||
kind="warn"
|
||||
confirm-text="停止"
|
||||
:on-confirm="() => power('STOP')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" :loading="powering === 'STOP'" :disabled="isEnded || !!powering || terminating">
|
||||
停止
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<ConfirmPop
|
||||
title="重启实例?"
|
||||
content="实例将立即重启,期间服务中断。"
|
||||
kind="warn"
|
||||
confirm-text="重启"
|
||||
:on-confirm="() => power('RESET')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" :loading="powering === 'RESET'" :disabled="isEnded || !!powering || terminating">重启</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
:loading="terminating"
|
||||
:disabled="isEnded || !!powering || terminating"
|
||||
@click="terminate"
|
||||
>
|
||||
终止
|
||||
</NButton>
|
||||
<NDropdown
|
||||
:options="moreOptions"
|
||||
:disabled="isEnded"
|
||||
:disabled="isEnded || !!powering || terminating"
|
||||
@select="(key: string) => power(key as PowerAction)"
|
||||
>
|
||||
<NButton size="small" quaternary :disabled="isEnded">⋯</NButton>
|
||||
<NButton size="small" quaternary :disabled="isEnded || !!powering || terminating">⋯</NButton>
|
||||
</NDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, PowerAction>())
|
||||
|
||||
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<DataTableColumns<InstanceRow>>(() => [
|
||||
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<DataTableColumns<InstanceRow>>(() => [
|
||||
{{ scope.currentConfig?.alias ?? '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true">
|
||||
<NButton v-if="!isDead" size="medium" type="primary" @click="showCreate = true">
|
||||
创建实例
|
||||
</NButton>
|
||||
</div>
|
||||
@@ -279,24 +338,39 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
|
||||
<div class="mt-2.5 flex items-center gap-1 border-t border-line-soft pt-2">
|
||||
<NButton
|
||||
v-if="r.lifecycleState === 'STOPPED'"
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="isEnded(r)"
|
||||
v-bind="powerBtnProps(r, 'START')"
|
||||
@click="power(r, 'START')"
|
||||
>
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'STOP')">
|
||||
停止
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'RESET')">
|
||||
重启
|
||||
</NButton>
|
||||
<ConfirmPop
|
||||
v-else
|
||||
:title="`停止实例「${r.displayName}」?`"
|
||||
content="实例将关机,其上服务与连接中断。"
|
||||
kind="warn"
|
||||
confirm-text="停止"
|
||||
:on-confirm="() => power(r, 'STOP')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton v-bind="powerBtnProps(r, 'STOP')">停止</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<ConfirmPop
|
||||
:title="`重启实例「${r.displayName}」?`"
|
||||
content="实例将立即重启,期间服务中断。"
|
||||
kind="warn"
|
||||
confirm-text="重启"
|
||||
:on-confirm="() => power(r, 'RESET')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton v-bind="powerBtnProps(r, 'RESET')">重启</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="isEnded(r)"
|
||||
:disabled="isEnded(r) || powering.has(r.id)"
|
||||
@click="confirmTerminate(r)"
|
||||
>
|
||||
终止
|
||||
|
||||
+165
-9
@@ -1,14 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NSpin } from 'naive-ui'
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
|
||||
import { NButton, NDropdown, NInput, NSpin } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getOauthAuthorizeUrl, getOauthProviders, login } from '@/api/auth'
|
||||
import {
|
||||
beginPasskeyLogin,
|
||||
finishPasskeyLogin,
|
||||
getOauthAuthorizeUrl,
|
||||
getOauthProviders,
|
||||
getWalletChallenge,
|
||||
login,
|
||||
verifyWallet,
|
||||
} from '@/api/auth'
|
||||
import { ApiError } from '@/api/request'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useThemeRipple } from '@/composables/useThemeRipple'
|
||||
import {
|
||||
discoverWallets,
|
||||
isUserRejected,
|
||||
personalSign,
|
||||
requestAccount,
|
||||
type WalletDetail,
|
||||
} from '@/composables/useWallet'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OauthProviderInfo } from '@/types/api'
|
||||
|
||||
@@ -30,8 +46,17 @@ const errorMsg = ref('')
|
||||
const providers = ref<OauthProviderInfo[]>([])
|
||||
// 密码登录已禁用(设置里开启且存在可用外部身份):隐藏用户名密码表单
|
||||
const passwordLoginDisabled = ref(false)
|
||||
// 存在已注册通行密钥且浏览器支持 WebAuthn:显示一键登录入口
|
||||
const passkeyAvailable = ref(false)
|
||||
// 存在已绑定钱包身份:显示钱包登录入口(是否装有钱包扩展点击时才探测)
|
||||
const walletAvailable = ref(false)
|
||||
// 多个注入钱包并存时的选择项(EIP-6963)
|
||||
const walletChoices = ref<WalletDetail[]>([])
|
||||
const showWalletPick = ref(false)
|
||||
// 登录方式加载中:providers 返回前不渲染表单,避免「先见密码表单再消失」的闪烁与误提交
|
||||
const bootLoading = ref(true)
|
||||
// 能力请求失败:密码是否被禁用未知,不渲染任何表单(可能必然 403),给重试入口
|
||||
const bootError = ref(false)
|
||||
|
||||
// 左侧品牌区功能徽章:各自不同周期/幅度/相位上下浮动(不规律观感)
|
||||
const featureChips = [
|
||||
@@ -41,18 +66,29 @@ const featureChips = [
|
||||
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
consumeOauthCallback()
|
||||
void loadProviders()
|
||||
})
|
||||
|
||||
/** 加载登录方式能力;失败进入错误态而非默认渲染密码表单(禁用状态未知) */
|
||||
async function loadProviders() {
|
||||
bootLoading.value = true
|
||||
bootError.value = false
|
||||
try {
|
||||
const r = await getOauthProviders()
|
||||
providers.value = r.providers
|
||||
passwordLoginDisabled.value = r.passwordLoginDisabled && r.providers.length > 0
|
||||
passkeyAvailable.value = r.passkeyLogin && browserSupportsWebAuthn()
|
||||
walletAvailable.value = r.walletLogin
|
||||
// 服务端已保证「存在任一免密方式才下发禁用」;浏览器能力只影响入口显示,
|
||||
// 不得覆盖该事实(不支持 WebAuthn 时显示必然 403 的密码表单更误导)
|
||||
passwordLoginDisabled.value = r.passwordLoginDisabled
|
||||
} catch {
|
||||
/* 登录页容忍 provider 列表加载失败 */
|
||||
bootError.value = true
|
||||
} finally {
|
||||
bootLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
|
||||
function consumeOauthCallback() {
|
||||
@@ -114,6 +150,68 @@ async function oauthLogin(provider: string) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 钱包登录入口:发现注入钱包;多个时弹选择,单个直接进入签名流程;
|
||||
* 发现期(约 300ms)以 loading 互斥,防双击并发多组授权/挑战流程 */
|
||||
async function walletLogin() {
|
||||
if (loading.value) return
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const wallets = await discoverWallets()
|
||||
if (!wallets.length) {
|
||||
errorMsg.value = '未检测到浏览器钱包扩展(MetaMask / OKX 等)'
|
||||
return
|
||||
}
|
||||
if (wallets.length === 1) {
|
||||
await walletLoginWith(wallets[0]!)
|
||||
return
|
||||
}
|
||||
walletChoices.value = wallets
|
||||
showWalletPick.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 钱包签名登录:连接取地址 → 后端挑战 → personal_sign → 校验签发会话 */
|
||||
async function walletLoginWith(w: WalletDetail) {
|
||||
showWalletPick.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const address = await requestAccount(w.provider)
|
||||
const { nonce, message } = await getWalletChallenge(address, 'login')
|
||||
const signature = await personalSign(w.provider, message, address)
|
||||
const resp = await verifyWallet(nonce, signature)
|
||||
auth.setSession(resp.token, resp.expiresAt)
|
||||
const redirect = route.query.redirect
|
||||
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
|
||||
} catch (e) {
|
||||
if (!isUserRejected(e)) errorMsg.value = e instanceof Error ? e.message : '钱包登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 通行密钥一键登录:断言 options → 浏览器验证器 → 后端校验签发会话 */
|
||||
async function passkeyLogin() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const { sessionId, options } = await beginPasskeyLogin()
|
||||
const credential = await startAuthentication({ optionsJSON: options.publicKey })
|
||||
const resp = await finishPasskeyLogin(sessionId, credential)
|
||||
auth.setSession(resp.token, resp.expiresAt)
|
||||
const redirect = route.query.redirect
|
||||
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
|
||||
} catch (e) {
|
||||
// 用户在系统弹窗中取消:浏览器抛 NotAllowedError,静默返回
|
||||
if (e instanceof Error && e.name === 'NotAllowedError') return
|
||||
errorMsg.value = e instanceof Error ? e.message : '通行密钥登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +261,13 @@ async function oauthLogin(provider: string) {
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="bootError"
|
||||
class="mt-5 flex flex-col items-start gap-3 rounded-md border border-line bg-wash px-4 py-3.5 text-[12.5px] text-ink-2"
|
||||
>
|
||||
<span>无法加载登录方式,请检查网络后重试。</span>
|
||||
<NButton size="small" @click="loadProviders">重试</NButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="errorMsg"
|
||||
@@ -206,15 +311,66 @@ async function oauthLogin(provider: string) {
|
||||
{{ loading ? '验证中…' : '登 录' }}
|
||||
</NButton>
|
||||
</form>
|
||||
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">密码登录已禁用,请使用外部身份继续</div>
|
||||
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">
|
||||
{{
|
||||
providers.length || passkeyAvailable || walletAvailable
|
||||
? '密码登录已禁用,请使用免密方式继续'
|
||||
: '密码登录已禁用,当前浏览器没有可用的免密登录入口(通行密钥需浏览器支持);请更换浏览器后重试'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<template v-if="providers.length">
|
||||
<template v-if="providers.length || passkeyAvailable || walletAvailable">
|
||||
<div v-if="!passwordLoginDisabled" class="mt-5 flex items-center gap-2.5 text-[11.5px] text-ink-3">
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
或
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
</div>
|
||||
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 gap-2'">
|
||||
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 flex-wrap gap-2'">
|
||||
<NButton
|
||||
v-if="passkeyAvailable"
|
||||
:block="passwordLoginDisabled"
|
||||
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
|
||||
:size="passwordLoginDisabled ? 'large' : 'medium'"
|
||||
:disabled="loading"
|
||||
@click="passkeyLogin"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- 指纹图标:通行密钥入口 -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
|
||||
<path d="M8.5 10.97a3.5 3.5 0 0 1 7 .03c0 2.2-.2 4.2-.7 6" />
|
||||
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
|
||||
<path d="M4.8 14.5c.3-1.1.4-2.3.4-3.5" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ passwordLoginDisabled ? '使用通行密钥登录' : '通行密钥' }}
|
||||
</NButton>
|
||||
<NDropdown
|
||||
v-if="walletAvailable"
|
||||
trigger="manual"
|
||||
:show="showWalletPick"
|
||||
:options="walletChoices.map((w) => ({ key: w.info.uuid, label: w.info.name }))"
|
||||
@select="(key: string) => { const w = walletChoices.find((x) => x.info.uuid === key); if (w) void walletLoginWith(w) }"
|
||||
@clickoutside="showWalletPick = false"
|
||||
>
|
||||
<NButton
|
||||
:block="passwordLoginDisabled"
|
||||
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
|
||||
:size="passwordLoginDisabled ? 'large' : 'medium'"
|
||||
:disabled="loading"
|
||||
@click="walletLogin"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- 钱包图标 -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
|
||||
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
|
||||
<circle cx="16.5" cy="13.5" r="0.6" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ passwordLoginDisabled ? '使用钱包登录' : '钱包' }}
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
<NButton
|
||||
v-for="p in providers"
|
||||
:key="p.provider"
|
||||
|
||||
@@ -155,7 +155,7 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
|
||||
新建默认启用 IPv6 并放行全部出入流量,可在 VCN 详情收紧
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="showCreate = true">创建 VCN</NButton>
|
||||
<NButton size="medium" type="primary" @click="showCreate = true">创建 VCN</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
|
||||
@@ -262,7 +262,7 @@ const columns = computed<DataTableColumns<Bucket>>(() => [
|
||||
{{ buckets.data.value?.length ?? '…' }} 个桶 · {{ scope.currentConfig?.alias ?? '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<NButton v-if="!isDead" size="small" type="primary" @click="openCreate">创建存储桶</NButton>
|
||||
<NButton v-if="!isDead" size="medium" type="primary" @click="openCreate">创建存储桶</NButton>
|
||||
</div>
|
||||
|
||||
<DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" />
|
||||
|
||||
@@ -13,8 +13,8 @@ const panel = ref<InstanceType<typeof ProxyPanel> | null>(null)
|
||||
<h1 class="page-title">代理</h1>
|
||||
<span class="truncate text-[13px] text-ink-3">出站代理管理</span>
|
||||
<div class="ml-auto flex flex-none items-center gap-2">
|
||||
<NButton size="small" @click="panel?.openImport()">批量导入</NButton>
|
||||
<NButton size="small" type="primary" @click="panel?.openCreate()">新增代理</NButton>
|
||||
<NButton size="medium" @click="panel?.openImport()">批量导入</NButton>
|
||||
<NButton size="medium" type="primary" @click="panel?.openCreate()">新增代理</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<ProxyPanel ref="panel" />
|
||||
|
||||
+15
-29
@@ -21,7 +21,9 @@ import FootNote from '@/components/FootNote.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import AboutTab from '@/components/settings/AboutTab.vue'
|
||||
import AiSettingsTab from '@/components/settings/AiSettingsTab.vue'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue'
|
||||
import ActiveSessionsCard from '@/components/settings/ActiveSessionsCard.vue'
|
||||
import NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue'
|
||||
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
@@ -593,9 +595,9 @@ async function sendTest() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安全:双栏 —— 左访问防护 / 网络与地址,右账号安全与登录方式 -->
|
||||
<div v-else-if="tab === 'security'" class="grid max-w-[1180px] grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 安全:方案B 全宽分段 —— 防护与地址并排小卡,账号安全双栏,配置与会话全宽 -->
|
||||
<div v-else-if="tab === 'security'" class="flex max-w-[1180px] flex-col gap-4">
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">访问防护</div>
|
||||
@@ -656,32 +658,17 @@ async function sendTest() {
|
||||
hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
<ChoiceChip
|
||||
v-for="opt in REALIP_PRESETS"
|
||||
:key="opt.label"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border px-3 py-1.5 text-xs"
|
||||
:class="
|
||||
!customHeaderMode && securityForm.realIpHeader === opt.value
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink-3'
|
||||
"
|
||||
:active="!customHeaderMode && securityForm.realIpHeader === opt.value"
|
||||
@click="pickHeader(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md border border-dashed px-3 py-1.5 text-xs"
|
||||
:class="
|
||||
customHeaderMode
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink-3'
|
||||
"
|
||||
@click="pickCustomHeader"
|
||||
>
|
||||
</ChoiceChip>
|
||||
<ChoiceChip dashed :active="customHeaderMode" @click="pickCustomHeader">
|
||||
自定义…
|
||||
</button>
|
||||
</ChoiceChip>
|
||||
</div>
|
||||
<NInput
|
||||
v-if="customHeaderMode"
|
||||
@@ -709,14 +696,13 @@ async function sendTest() {
|
||||
登录锁定为内存计数,重启即清零
|
||||
</FootNote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号安全:两步验证 / 外部身份绑定 / 登录方式配置 -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<AccountSecurityCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号安全(密码登录/免密登录/登录方式配置)与活跃会话 -->
|
||||
<AccountSecurityCard />
|
||||
<ActiveSessionsCard />
|
||||
</div>
|
||||
|
||||
<!-- AI:网关运行时设置(保险丝 / grok 工具默认 / 模型治理) -->
|
||||
<AiSettingsTab v-else-if="tab === 'ai'" />
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin, useDialog } from 'naive-ui'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
@@ -18,7 +19,6 @@ import { useToast } from '@/composables/useToast'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
@@ -78,8 +78,12 @@ const params = computed<[string, string][]>(() => {
|
||||
})
|
||||
|
||||
const showEdit = ref(false)
|
||||
/** 正在执行的操作类别,按钮按类 loading、整组防连点(删除也计入,防与运行/编辑竞态) */
|
||||
const acting = ref<'' | 'run' | 'toggle' | 'remove'>('')
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
async function act(kind: 'run' | 'toggle', fn: () => Promise<unknown>, ok: string) {
|
||||
if (acting.value) return
|
||||
acting.value = kind
|
||||
try {
|
||||
await fn()
|
||||
message.success(ok)
|
||||
@@ -87,6 +91,8 @@ async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
void logs.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
acting.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,25 +102,25 @@ function toggle() {
|
||||
if (!t) return
|
||||
const resume = t.status !== 'active'
|
||||
void act(
|
||||
'toggle',
|
||||
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
|
||||
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
|
||||
)
|
||||
}
|
||||
|
||||
function confirmRemove() {
|
||||
async function doRemove() {
|
||||
const t = task.data.value
|
||||
if (!t) return
|
||||
dialog.warning({
|
||||
title: '删除任务',
|
||||
content: `删除任务「${t.name}」及其全部日志?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await deleteTask(t.id)
|
||||
message.success('已删除任务及其日志')
|
||||
void router.push({ name: 'tasks' })
|
||||
},
|
||||
})
|
||||
if (!t || acting.value) return
|
||||
acting.value = 'remove'
|
||||
try {
|
||||
await deleteTask(t.id)
|
||||
message.success('已删除任务及其日志')
|
||||
void router.push({ name: 'tasks' })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
acting.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const features = computed(() => {
|
||||
@@ -174,19 +180,40 @@ const features = computed(() => {
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
@click="act(() => runTask(task.data.value!.id), '已触发执行')"
|
||||
:loading="acting === 'run'"
|
||||
:disabled="!!acting"
|
||||
@click="act('run', () => runTask(task.data.value!.id), '已触发执行')"
|
||||
>
|
||||
立即执行
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="task.data.value.status !== 'succeeded'"
|
||||
size="small"
|
||||
:loading="acting === 'toggle'"
|
||||
:disabled="!!acting"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
|
||||
</NButton>
|
||||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton>
|
||||
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</NButton>
|
||||
<NButton
|
||||
v-if="task.data.value.type !== 'ai_probe'"
|
||||
size="small"
|
||||
:disabled="!!acting"
|
||||
@click="showEdit = true"
|
||||
>
|
||||
编辑
|
||||
</NButton>
|
||||
<ConfirmPop
|
||||
v-if="task.data.value.type !== 'ai_probe'"
|
||||
:title="`删除任务「${task.data.value.name}」?`"
|
||||
content="任务及其全部日志将一并删除。"
|
||||
confirm-text="删除"
|
||||
:on-confirm="doRemove"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" quaternary :disabled="!!acting">删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -53,13 +53,20 @@ function openCreate() {
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
/** 正在操作的任务 id 集合,对应卡片操作按钮防连点 */
|
||||
const acting = ref(new Set<number>())
|
||||
|
||||
async function act(id: number, fn: () => Promise<unknown>, ok: string) {
|
||||
if (acting.value.has(id)) return
|
||||
acting.value.add(id)
|
||||
try {
|
||||
await fn()
|
||||
message.success(ok)
|
||||
void tasks.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
acting.value.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +74,20 @@ async function act(fn: () => Promise<unknown>, ok: string) {
|
||||
function toggle(task: Task) {
|
||||
const resume = task.status !== 'active'
|
||||
void act(
|
||||
task.id,
|
||||
() => updateTask(task.id, { status: resume ? 'active' : 'paused' }),
|
||||
resume ? (task.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
|
||||
)
|
||||
}
|
||||
|
||||
/** 删除自下拉菜单触发,popover 无锚点,保留 NDialog;确认按钮随 Promise 进入 loading */
|
||||
function confirmRemove(task: Task) {
|
||||
dialog.warning({
|
||||
title: '删除任务',
|
||||
content: `删除任务「${task.name}」及其全部日志?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => act(() => deleteTask(task.id), '已删除任务及其日志'),
|
||||
onPositiveClick: () => act(task.id, () => deleteTask(task.id), '已删除任务及其日志'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -92,7 +101,7 @@ function confirmRemove(task: Task) {
|
||||
{{ tasks.data.value?.length ?? '…' }} 个任务 · cron 为服务器本地时区
|
||||
</span>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="openCreate">新建任务</NButton>
|
||||
<NButton size="medium" type="primary" @click="openCreate">新建任务</NButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -107,7 +116,8 @@ function confirmRemove(task: Task) {
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:cfg-alias="aliasFor(task)"
|
||||
@run="act(() => runTask(task.id), `已触发执行:${task.name}`)"
|
||||
:busy="acting.has(task.id)"
|
||||
@run="act(task.id, () => runTask(task.id), `已触发执行:${task.name}`)"
|
||||
@toggle="toggle(task)"
|
||||
@edit="openEdit(task)"
|
||||
@remove="confirmRemove(task)"
|
||||
|
||||
@@ -331,7 +331,7 @@ const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
|
||||
<h1 class="page-title">租户</h1>
|
||||
<span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="showImport = true">导入租户</NButton>
|
||||
<NButton size="medium" type="primary" @click="showImport = true">导入租户</NButton>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
|
||||
@@ -39,7 +39,7 @@ function retry() {
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex gap-2.5">
|
||||
<NButton type="warning" @click="retry">稍后重试</NButton>
|
||||
<NButton @click="retry">稍后重试</NButton>
|
||||
<NButton @click="router.push({ name: 'login' })">返回登录</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user