@@ -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