安全页重构:免密登录与活跃会话;按钮规范统一
CI / test (push) Successful in 53s

This commit is contained in:
2026-07-30 12:23:16 +08:00
parent d0fbecbd43
commit d675b950ee
43 changed files with 1944 additions and 475 deletions
+82 -22
View File
@@ -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>
+98 -24
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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" />
+2 -2
View File
@@ -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
View File
@@ -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'" />
+46 -19
View File
@@ -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>
+14 -4
View File
@@ -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)"
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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>