feat(admin): 二级密码未配置时显示设置引导页,登录 Docker 报错改为原地弹窗

This commit is contained in:
JOJO 2026-07-25 16:52:42 +08:00
parent b78504c31e
commit 64902b124a
13 changed files with 292 additions and 370 deletions

View File

@ -293,6 +293,10 @@ class UserContainerManager:
if not self.image: if not self.image:
raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置,无法启动容器。") raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置,无法启动容器。")
# 创建前先做 daemon 就绪检查daemon 未启动时给出友好中文报错,
# 而不是 docker run 的英文原始 stderr如 Cannot connect to the Docker daemon...
self._assert_docker_runtime_ready()
container_name = self._build_container_name(username) container_name = self._build_container_name(username)
self._kill_container(container_name, docker_path) self._kill_container(container_name, docker_path)

View File

@ -102,7 +102,15 @@ def admin_api_page():
@login_required @login_required
@admin_required @admin_required
def admin_secondary_status(): def admin_secondary_status():
return jsonify({"success": True, "verified": _is_secondary_verified(), "ttl": ADMIN_SECONDARY_TTL_SECONDS}) # configured是否已设置二级密码hash 或明文)。未配置时前端应显示设置引导,
# 而不是密码输入框(未配置时 verify 恒 401输入什么都进不去
configured = bool(ADMIN_SECONDARY_PASSWORD_HASH or ADMIN_SECONDARY_PASSWORD)
return jsonify({
"success": True,
"verified": _is_secondary_verified(),
"configured": configured,
"ttl": ADMIN_SECONDARY_TTL_SECONDS,
})
@admin_bp.route('/api/admin/secondary/verify', methods=['POST']) @admin_bp.route('/api/admin/secondary/verify', methods=['POST'])

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API 管理面板</title> <title>API 管理面板</title>
<link rel="stylesheet" href="/static/dist/assets/adminApi.css" /> <link rel="stylesheet" href="/static/dist/assets/adminApi.css" />
<!-- 共享组件 SecondaryGate 被多入口抽为独立 chunk其 CSS 需显式引入 -->
<link rel="stylesheet" href="/static/dist/assets/SecondaryGate.css" />
<script src="/static/security.js"></script> <script src="/static/security.js"></script>
</head> </head>
<body> <body>

View File

@ -9,6 +9,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/dist/assets/admin.css" /> <link rel="stylesheet" href="/static/dist/assets/admin.css" />
<!-- 共享组件 SecondaryGate 被多入口抽为独立 chunk其 CSS 需显式引入 -->
<link rel="stylesheet" href="/static/dist/assets/SecondaryGate.css" />
<script src="/static/security.js"></script> <script src="/static/security.js"></script>
</head> </head>
<body> <body>

View File

@ -9,6 +9,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/dist/assets/adminPolicy.css" /> <link rel="stylesheet" href="/static/dist/assets/adminPolicy.css" />
<!-- 共享组件 SecondaryGate 被多入口抽为独立 chunk其 CSS 需显式引入 -->
<link rel="stylesheet" href="/static/dist/assets/SecondaryGate.css" />
<script src="/static/security.js"></script> <script src="/static/security.js"></script>
</head> </head>
<body> <body>

View File

@ -9,6 +9,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="stylesheet" href="/static/dist/assets/adminCustomTools.css" /> <link rel="stylesheet" href="/static/dist/assets/adminCustomTools.css" />
<!-- 共享组件 SecondaryGate 被多入口抽为独立 chunk其 CSS 需显式引入 -->
<link rel="stylesheet" href="/static/dist/assets/SecondaryGate.css" />
<script src="/static/security.js"></script> <script src="/static/security.js"></script>
</head> </head>
<body> <body>

View File

@ -4,34 +4,15 @@
<p>正在加载监控数据...</p> <p>正在加载监控数据...</p>
</div> </div>
<div v-else class="admin-page"> <div v-else class="admin-page">
<div class="secondary-overlay" v-if="!secondaryVerified"> <SecondaryGate
<div class="secondary-card"> v-if="!secondaryVerified"
<h3>请输入管理员二级密码</h3> :configured="secondaryConfigured"
<p class="muted">为保护敏感监控数据需二级校验后查看</p> :loading="secondaryLoading"
<input :error="secondaryError"
class="secondary-input" description="为保护敏感监控数据,需二级校验后查看。"
type="password" @verify="handleVerifySecondary"
v-model="secondaryPassword" @recheck="checkSecondary"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions">
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认进入' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="admin-header"> <header class="admin-header">
<div> <div>
<h1>管理员监控面板</h1> <h1>管理员监控面板</h1>
@ -426,6 +407,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue';
type Snapshot = Record<string, any> | null; type Snapshot = Record<string, any> | null;
@ -433,12 +415,12 @@ type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'in
const { const {
verified: secondaryVerified, verified: secondaryVerified,
configured: secondaryConfigured,
loading: secondaryLoading, loading: secondaryLoading,
error: secondaryError, error: secondaryError,
check: checkSecondary, check: checkSecondary,
verify: verifySecondary verify: verifySecondary
} = useSecondaryPass(); } = useSecondaryPass();
const secondaryPassword = ref('');
const loading = ref(true); const loading = ref(true);
const refreshing = ref(false); const refreshing = ref(false);
@ -735,10 +717,9 @@ const removeInvite = async (code: string) => {
} }
}; };
const handleVerifySecondary = async () => { const handleVerifySecondary = async (password: string) => {
await verifySecondary(secondaryPassword.value); await verifySecondary(password);
if (secondaryVerified.value) { if (secondaryVerified.value) {
secondaryPassword.value = '';
fetchDashboard(false); fetchDashboard(false);
scheduleAutoRefresh(); scheduleAutoRefresh();
} }
@ -1027,65 +1008,6 @@ button:not(:disabled):hover {
color: #7c3418; color: #7c3418;
} }
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
.admin-layout { .admin-layout {
display: flex; display: flex;
gap: 20px; gap: 20px;

View File

@ -1,33 +1,14 @@
<template> <template>
<div class="api-page"> <div class="api-page">
<div class="secondary-overlay" v-if="!secondaryVerified"> <SecondaryGate
<div class="secondary-card"> v-if="!secondaryVerified"
<h3>API 后台 · 二级验证</h3> :configured="secondaryConfigured"
<p class="muted">请输入管理员二级密码后查看 API 调用与账户</p> :loading="secondaryLoading"
<input :error="secondaryError"
class="secondary-input" description="请输入管理员二级密码后查看 API 调用与账户。"
type="password" @verify="handleVerifySecondary"
v-model="secondaryPassword" @recheck="checkSecondary"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions">
<button type="button" :disabled="secondaryLoading" @click="handleVerifySecondary">
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="api-header"> <header class="api-header">
<div> <div>
@ -235,15 +216,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue';
const { const {
verified: secondaryVerified, verified: secondaryVerified,
configured: secondaryConfigured,
loading: secondaryLoading, loading: secondaryLoading,
error: secondaryError, error: secondaryError,
check: checkSecondary, check: checkSecondary,
verify: verifySecondary verify: verifySecondary
} = useSecondaryPass(); } = useSecondaryPass();
const secondaryPassword = ref('');
const loading = ref(false); const loading = ref(false);
const autoRefresh = ref(true); const autoRefresh = ref(true);
@ -453,10 +435,9 @@ const copyToken = async (username: string) => {
} }
}; };
const handleVerifySecondary = async () => { const handleVerifySecondary = async (password: string) => {
await verifySecondary(secondaryPassword.value); await verifySecondary(password);
if (secondaryVerified.value) { if (secondaryVerified.value) {
secondaryPassword.value = '';
await fetchAll(); await fetchAll();
scheduleAutoRefresh(); scheduleAutoRefresh();
} }
@ -696,62 +677,4 @@ td {
margin-top: 8px; margin-top: 8px;
} }
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style> </style>

View File

@ -1,33 +1,14 @@
<template> <template>
<div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }"> <div class="custom-tools-page" :class="{ 'editor-only': editorPageMode }">
<div class="secondary-overlay" v-if="!secondaryVerified"> <SecondaryGate
<div class="secondary-card"> v-if="!secondaryVerified"
<h3>二级密码校验</h3> :configured="secondaryConfigured"
<p class="muted">输入管理员二级密码后可管理自定义工具</p> :loading="secondaryLoading"
<input :error="secondaryError"
class="secondary-input" description="输入管理员二级密码后可管理自定义工具。"
type="password" @verify="handleVerifySecondary"
v-model="secondaryPassword" @recheck="checkSecondary"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions">
<button
type="button"
class="primary"
:disabled="secondaryLoading"
@click="handleVerifySecondary"
>
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button type="button" class="ghost" :disabled="secondaryLoading" @click="checkSecondary">
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<!-- 列表模式 --> <!-- 列表模式 -->
<template v-if="!editorPageMode"> <template v-if="!editorPageMode">
<header class="page-header"> <header class="page-header">
@ -185,6 +166,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'; import { onMounted, reactive, ref, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue';
interface CustomTool { interface CustomTool {
id: string; id: string;
@ -199,12 +181,12 @@ interface CustomTool {
const { const {
verified: secondaryVerified, verified: secondaryVerified,
configured: secondaryConfigured,
loading: secondaryLoading, loading: secondaryLoading,
error: secondaryError, error: secondaryError,
check: checkSecondary, check: checkSecondary,
verify: verifySecondary verify: verifySecondary
} = useSecondaryPass(); } = useSecondaryPass();
const secondaryPassword = ref('');
const tools = ref<CustomTool[]>([]); const tools = ref<CustomTool[]>([]);
const loading = ref(false); const loading = ref(false);
@ -407,10 +389,9 @@ const paramCount = (tool: CustomTool) => {
return Object.keys(props).length; return Object.keys(props).length;
}; };
const handleVerifySecondary = async () => { const handleVerifySecondary = async (password: string) => {
await verifySecondary(secondaryPassword.value); await verifySecondary(password);
if (secondaryVerified.value) { if (secondaryVerified.value) {
secondaryPassword.value = '';
await refresh(); await refresh();
} }
}; };
@ -793,63 +774,4 @@ watch(secondaryVerified, async (val) => {
} }
} }
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
color: #2a2013;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style> </style>

View File

@ -1,38 +1,14 @@
<template> <template>
<div class="policy-page"> <div class="policy-page">
<div class="secondary-overlay" v-if="!secondaryVerified"> <SecondaryGate
<div class="secondary-card"> v-if="!secondaryVerified"
<h3>二级密码校验</h3> :configured="secondaryConfigured"
<p class="muted">请输入管理员二级密码后继续配置策略</p> :loading="secondaryLoading"
<input :error="secondaryError"
class="secondary-input" description="请输入管理员二级密码后继续配置策略。"
type="password" @verify="handleVerifySecondary"
v-model="secondaryPassword" @recheck="checkSecondary"
:disabled="secondaryLoading"
placeholder="二级密码"
@keyup.enter="handleVerifySecondary"
/> />
<div class="secondary-actions">
<button
type="button"
class="primary"
:disabled="secondaryLoading"
@click="handleVerifySecondary"
>
{{ secondaryLoading ? '校验中...' : '确认' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="secondaryLoading"
@click="checkSecondary"
>
重新检测
</button>
</div>
<p v-if="secondaryError" class="secondary-error">{{ secondaryError }}</p>
</div>
</div>
<header class="policy-header"> <header class="policy-header">
<div> <div>
<h1>管理员策略配置</h1> <h1>管理员策略配置</h1>
@ -336,6 +312,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue'; import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useSecondaryPass } from './useSecondaryPass'; import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue';
type TargetType = 'global' | 'role' | 'user' | 'invite'; type TargetType = 'global' | 'role' | 'user' | 'invite';
@ -383,12 +360,12 @@ const defaults = reactive({
const { const {
verified: secondaryVerified, verified: secondaryVerified,
configured: secondaryConfigured,
loading: secondaryLoading, loading: secondaryLoading,
error: secondaryError, error: secondaryError,
check: checkSecondary, check: checkSecondary,
verify: verifySecondary verify: verifySecondary
} = useSecondaryPass(); } = useSecondaryPass();
const secondaryPassword = ref('');
const form = reactive({ const form = reactive({
target_type: 'global' as TargetType, target_type: 'global' as TargetType,
@ -944,10 +921,9 @@ async function savePolicy() {
} }
} }
const handleVerifySecondary = async () => { const handleVerifySecondary = async (password: string) => {
await verifySecondary(secondaryPassword.value); await verifySecondary(password);
if (secondaryVerified.value) { if (secondaryVerified.value) {
secondaryPassword.value = '';
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => { Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err); console.error(err);
banner.message = err?.message || '加载策略失败'; banner.message = err?.message || '加载策略失败';
@ -1540,62 +1516,4 @@ button:disabled {
font-size: 12px; font-size: 12px;
} }
.secondary-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 5, 0.35);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.secondary-card {
width: 360px;
max-width: 90vw;
background: #f6ecda;
border: 1px solid rgba(118, 103, 84, 0.35);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16);
}
.secondary-card h3 {
margin: 0 0 8px;
}
.secondary-card p {
margin: 0 0 12px;
color: #5b4b35;
}
.secondary-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
margin-bottom: 12px;
font-size: 15px;
}
.secondary-actions {
display: flex;
gap: 10px;
align-items: center;
}
.secondary-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
}
.secondary-error {
color: #b5473d;
margin-top: 10px;
}
</style> </style>

View File

@ -0,0 +1,208 @@
<template>
<div class="secondary-overlay">
<!-- 未配置二级密码设置引导页verify 401给输入框是死锁 -->
<div v-if="!configured" class="secondary-card secondary-card--guide">
<h3>尚未设置二级密码</h3>
<p class="muted">
管理面板的敏感操作邀请码密码管理策略配置等由二级密码保护
当前服务端尚未配置二级密码请先完成设置
</p>
<ol class="guide-steps">
<li>
<span class="step-text">在服务器上生成密码哈希</span>
<code class="step-code">python3 -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('你的二级密码'))"</code>
</li>
<li>
<span class="step-text">将输出写入 settings.json <code>admin</code> </span>
<code class="step-code">"admin": { "secondary_password_hash": "pbkdf2:sha256:..." }</code>
</li>
<li>
<span class="step-text">重启服务然后点击下方重新检测</span>
</li>
</ol>
<div class="secondary-actions">
<button type="button" :disabled="loading" @click="$emit('recheck')">
{{ loading ? '检测中...' : '重新检测' }}
</button>
</div>
<p v-if="error" class="secondary-error">{{ error }}</p>
</div>
<!-- 已配置常规的二级密码输入框 -->
<div v-else class="secondary-card">
<h3>请输入管理员二级密码</h3>
<p class="muted">{{ description }}</p>
<input
class="secondary-input"
type="password"
v-model="password"
:disabled="loading"
placeholder="二级密码"
@keyup.enter="submit"
/>
<div class="secondary-actions">
<button type="button" :disabled="loading" @click="submit">
{{ loading ? '校验中...' : '确认进入' }}
</button>
<button type="button" class="ghost-btn" :disabled="loading" @click="$emit('recheck')">
重新检测
</button>
</div>
<p v-if="error" class="secondary-error">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = withDefaults(
defineProps<{
configured: boolean;
loading: boolean;
error: string | null;
description?: string;
}>(),
{ description: '为保护敏感数据,需二级校验后查看。' }
);
const emit = defineEmits<{
(e: 'verify', password: string): void;
(e: 'recheck'): void;
}>();
const password = ref('');
const submit = () => {
if (props.loading) return;
emit('verify', password.value);
};
</script>
<!-- scopedadmin 各入口未引入全局样式token 定义随组件 chunk 确保 var() 可用 -->
<style lang="scss">
@use '../styles/base/tokens';
</style>
<style scoped>
.secondary-overlay {
position: fixed;
inset: 0;
background: var(--overlay-scrim);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.secondary-card {
background: var(--surface-card);
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 24px;
width: 420px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 96px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.secondary-card--guide {
width: 520px;
}
.secondary-card h3 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
}
.secondary-card p {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
.guide-steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
color: var(--text-secondary);
}
.step-text {
display: block;
margin-bottom: 4px;
line-height: 1.5;
}
.step-code {
display: block;
padding: 8px 10px;
border-radius: 6px;
background: var(--surface-muted);
border: 1px solid var(--border-default);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
word-break: break-all;
user-select: all;
}
.secondary-input {
height: 34px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid var(--border-default);
background: var(--surface-base);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.secondary-input:focus {
border-color: var(--accent);
}
.secondary-actions {
display: flex;
gap: 8px;
}
.secondary-actions button {
height: 32px;
padding: 0 14px;
border-radius: 6px;
border: 1px solid var(--border-default);
background: var(--surface-soft);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.secondary-actions button:hover:not(:disabled) {
background: var(--surface-muted);
}
.secondary-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.secondary-actions .ghost-btn {
background: transparent;
}
.secondary-error {
margin: 0;
font-size: 12px;
color: var(--state-danger);
}
</style>

View File

@ -2,6 +2,8 @@ import { ref } from 'vue';
export function useSecondaryPass() { export function useSecondaryPass() {
const verified = ref(false); const verified = ref(false);
// 是否已在服务端配置二级密码;未配置时前端应显示设置引导而非密码输入框
const configured = ref(true);
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@ -13,6 +15,8 @@ export function useSecondaryPass() {
if (!resp.ok) throw new Error(`状态请求失败:${resp.status}`); if (!resp.ok) throw new Error(`状态请求失败:${resp.status}`);
const data = await resp.json(); const data = await resp.json();
verified.value = !!data.verified; verified.value = !!data.verified;
// 兼容旧后端:字段缺失时视为已配置(保持原有输入框行为)
configured.value = data.configured !== false;
} catch (err: any) { } catch (err: any) {
error.value = err.message || '无法验证二级密码状态'; error.value = err.message || '无法验证二级密码状态';
} finally { } finally {
@ -45,6 +49,7 @@ export function useSecondaryPass() {
return { return {
verified, verified,
configured,
loading, loading,
error, error,
check, check,

View File

@ -77,8 +77,10 @@ const doLogin = async (redirectUrl = '/') => {
body: JSON.stringify({ email: email.value, password: password.value }) body: JSON.stringify({ email: email.value, password: password.value })
}); });
// 503 = ensure_container Docker /error
if (resp.status === 503) { if (resp.status === 503) {
window.location.href = '/resource_busy'; const data = await resp.json().catch(() => ({} as any));
error.value = data.error || '服务暂时不可用,请稍后重试';
return; return;
} }
@ -104,8 +106,10 @@ const doHostLogin = async (redirectUrl = '/') => {
try { try {
const resp = await fetch('/host-login', { method: 'POST' }); const resp = await fetch('/host-login', { method: 'POST' });
// 503 = error
if (resp.status === 503) { if (resp.status === 503) {
window.location.href = '/resource_busy'; const data = await resp.json().catch(() => ({} as any));
error.value = data.error || '资源繁忙,请稍后再试';
return; return;
} }