style: 修改一些UI和UE,提升用户体验

This commit is contained in:
nap0o committed 2026-03-10 21:42:32 +08:00
1 parent a0405eb725
commit 3d5a29bbaf
14 files changed
+294 -96

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
import { EnvBindings, AppError } from '@/app/config';
import { generateSecureJWT, generateDeviceKey } from '@/shared/utils/crypto';
import * as schema from '@/shared/db/schema';
import { eq, desc, and } from 'drizzle-orm';
import { eq, desc } from 'drizzle-orm';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
+33 -12
View File
@@ -1,9 +1,9 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { Warning } from '@element-plus/icons-vue'
import TheHeader from '@/shared/components/theHeader.vue'
import TheFooter from '@/shared/components/theFooter.vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { Warning, Loading } from '@element-plus/icons-vue'
import MainLayout from '@/app/layouts/mainLayout.vue'
import BlankLayout from '@/app/layouts/blankLayout.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -16,8 +16,25 @@ const elementLocale = computed(() => {
})
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const isAppLoading = ref(true)
const layoutComponent = computed(() => {
if (route.meta.layout === 'blank' || !route.meta.requiresAuth) {
return BlankLayout
}
return MainLayout
})
router.isReady().then(() => {
// Give it a tiny delay to ensure DOM is fully painted with proper matched route and meta
setTimeout(() => {
isAppLoading.value = false
}, 100)
})
// 移动端菜单打开时锁定背景滚动
watch(() => layoutStore.showMobileMenu, (newVal) => {
if (newVal) {
@@ -53,7 +70,15 @@ onBeforeUnmount(() => {
<template>
<el-config-provider :locale="elementLocale">
<div class="app-container">
<!-- 全局初始加载白屏遮罩 (主要用于 /login 和 /health 等外部页面隐藏身份验证时的跳转闪烁) -->
<div v-show="isAppLoading && route.path !== '/'" class="global-app-shell">
<div class="shell-content">
<el-icon class="is-loading shell-spinner" :size="48" color="#409eff"><Loading /></el-icon>
</div>
</div>
<!-- 主体应用 (等 Router 就绪后再挂载) -->
<div class="app-container" v-show="!isAppLoading || route.path === '/'">
<!-- 方案A: 全局离线横幅 -->
<el-alert
v-if="showOfflineBanner"
@@ -66,13 +91,9 @@ onBeforeUnmount(() => {
@close="userClosedOfflineBanner = true"
/>
<!-- 登录页通常不显示头部,可以通过路由 meta 控制,这里简单示例默认显示 -->
<TheHeader v-if="!route.meta.hideHeader" />
<main>
<RouterView />
</main>
<TheFooter />
<!-- 加载动态路由 Layout (MainLayout 或 BlankLayout) -->
<component :is="layoutComponent" />
</div>
</el-config-provider>
</template>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="layout-blank">
<TheHeader />
<main class="layout-blank-content">
<RouterView />
</main>
<TheFooter />
</div>
</template>
<script setup>
import TheHeader from '@/shared/components/theHeader.vue'
import TheFooter from '@/shared/components/theFooter.vue'
import { RouterView } from 'vue-router'
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<div class="layout-main">
<TheHeader />
<main class="layout-main-content">
<RouterView />
</main>
<TheFooter />
</div>
</template>
<script setup>
import TheHeader from '@/shared/components/theHeader.vue'
import TheFooter from '@/shared/components/theFooter.vue'
import { RouterView } from 'vue-router'
</script>
@@ -125,48 +125,86 @@
margin-bottom: 20px;
}
.migration-import-tips {
/* 生态支持矩阵 (Ecosystem Grid) */
.migration-ecosystem {
margin-top: 30px;
text-align: left;
margin-top: 25px;
background-color: var(--el-fill-color-lighter);
padding: 0px 20px 20px 20px;
border-radius: 8px;
border: 1px dashed var(--el-border-color);
}
.migration-format-groups {
display: flex;
flex-direction: column;
gap: 15px;
}
.migration-format-group h4 {
margin: 0 0 10px 0;
.ecosystem-title {
font-size: 16px;
color: var(--el-text-color-primary);
font-size: 14px;
margin: 20px 0;
font-weight: 600;
}
.migration-tags {
.ecosystem-groups {
display: flex;
flex-direction: column;
gap: 25px;
}
.ecosystem-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-title {
margin: 0;
font-size: 15px;
color: var(--el-text-color-regular);
font-weight: 600;
}
.ecosystem-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
gap: 12px 24px;
}
.migration-tags .el-tag {
padding: 4px 8px;
height: auto;
color: var(--el-text-color-regular);
cursor: default;
border-color: transparent;
background-color: var(--el-fill-color-light);
}
.migration-tags .el-tag .el-icon {
.ecosystem-item {
display: inline-flex;
flex-wrap: wrap;
margin-right: 4px;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.ecosystem-item .el-icon,
.ecosystem-item svg {
font-size: 18px;
}
/* 疑难解答折叠面板 */
/* 疑难解答折叠面板 */
.ecosystem-collapse {
border: none;
background-color: transparent;
}
.ecosystem-collapse :deep(.el-collapse),
.ecosystem-collapse :deep(.el-collapse-item__header),
.ecosystem-collapse :deep(.el-collapse-item__wrap) {
border: none;
}
.ecosystem-collapse :deep(.el-collapse-item__header) {
background-color: transparent;
padding: 0;
font-size: 13px;
color: var(--el-text-color-regular);
height: 32px;
line-height: 32px;
}
.ecosystem-collapse :deep(.el-collapse-item__wrap) {
background-color: transparent;
}
.ecosystem-collapse :deep(.el-collapse-item__content) {
padding: 10px 0;
}
.migration-ga-tip {
@@ -174,7 +212,7 @@
padding: 10px 12px;
border-radius: 6px;
border-left: 3px solid var(--el-color-primary);
margin-top: 10px;
margin-top: 5px;
}
.migration-ga-tip span {
@@ -197,7 +235,7 @@
padding: 10px 12px;
border-radius: 6px;
border-left: 3px solid var(--el-color-success);
margin-top: 10px;
margin-top: 15px;
}
.migration-ms-tip span {
@@ -27,6 +27,47 @@ main {
}
}
/* App Shell (Global Loading Overlay) */
.global-app-shell {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background-color: var(--el-bg-color-page);
display: flex;
justify-content: center;
align-items: center;
transition: opacity 0.3s ease-in-out;
}
.shell-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.shell-spinner {
animation: loading-rotate 2s linear infinite;
}
/* Layout Wrappers (Main / Blank) */
.layout-main,
.layout-blank {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.layout-main-content,
.layout-blank-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* Home.vue */
.home-container {
height: 100%;
@@ -28,7 +28,28 @@
}
.user-actions {
gap: 20px;
display: flex;
align-items: center;
gap: 12px;
}
.guest-actions {
display: flex;
align-items: center;
gap: 5px;
}
.header-action-btn.el-button.is-circle {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
color: var(--el-text-color-regular);
}
.header-action-btn.el-button.is-circle:hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary-light-5);
background-color: var(--el-color-primary-light-9);
}
.user-profile {
@@ -56,6 +77,10 @@
.logo h2 {
font-size: 18px;
}
.guest-actions {
gap: 0px;
}
}
/* --- TheHeader Button & Link --- */
@@ -1,5 +1,5 @@
<template>
<div class="health-check-container" v-loading.fullscreen.lock="loading">
<div class="health-check-container">
<el-card v-if="!loading && !passed" class="health-card">
<template #header>
@@ -903,10 +903,8 @@ export const dataMigrationService = {
// Clean up memory VFS (by clearing the maps, safer than calling xDelete from JS)
try {
if (vfs) {
vfs.mapNameToFile.clear()
vfs.mapIdToFile.clear()
}
vfs.mapNameToFile.clear()
vfs.mapIdToFile.clear()
} catch (ignore) { }
if (vault.length === 0) {
@@ -23,34 +23,45 @@
<p><el-tag type="success" effect="light">{{ $t('migration.auto_identify_tip') }}</el-tag></p>
<p><span v-html="$t('migration.drag_drop_tip')"></span></p>
</div>
<template #tip>
<div class="migration-import-tips">
<h4>{{ $t('migration.support_desc') }}</h4>
<div class="migration-format-groups">
<div class="migration-format-group">
<h4>📁 {{ $t('migration.system_backup_format') }}</h4>
<div class="migration-tags">
<el-tag type="info" effect="light"><el-icon><Lock /></el-icon> {{ $t('migration.encrypted_backup_json') }}</el-tag>
<el-tag type="info" effect="light"><el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_backup_json') }}</el-tag>
</div>
</div>
</el-upload>
<div class="migration-format-group">
<h4>📱 {{ $t('migration.mobile_app_format') }}</h4>
<div class="migration-tags">
<el-tag type="info" effect="light"><icon2FAS /> 2FAS (.2fas)</el-tag>
<el-tag type="info" effect="light"><iconAegis /> Aegis (.json/.txt)</el-tag>
<el-tag type="info" effect="light"><iconBitwarden /> Bitwarden Auth (.json/.csv)</el-tag>
<el-tag type="info" effect="light"><iconProtonAuth /> Proton Auth (.json)</el-tag>
<el-tag type="info" effect="light"><iconEnte /> Ente Auth (.txt)</el-tag>
<el-tag type="info" effect="light"><iconGoogleAuth /> Google Auth (.png/.jpg)</el-tag>
<el-tag type="info" effect="light"><iconMicrosoftAuth /> Microsoft Auth (PhoneFactor)</el-tag>
</div>
<div class="migration-ga-tip">
<!-- 生态支持矩阵 (独立剥离的说明区) -->
<div class="migration-ecosystem mt-30">
<el-divider border-style="dashed" />
<h3 class="ecosystem-title">{{ $t('migration.support_desc') }}</h3>
<div class="ecosystem-groups">
<div class="ecosystem-group">
<h4 class="group-title">📁 {{ $t('migration.system_backup_format') }}</h4>
<div class="ecosystem-grid">
<span class="ecosystem-item"><el-icon><Lock /></el-icon> {{ $t('migration.encrypted_backup_json') }}</span>
<span class="ecosystem-item"><el-icon><Unlock /></el-icon> {{ $t('migration.plaintext_backup_json') }}</span>
</div>
</div>
<div class="ecosystem-group">
<h4 class="group-title">📱 {{ $t('migration.mobile_app_format') }}</h4>
<div class="ecosystem-grid">
<span class="ecosystem-item"><icon2FAS /> 2FAS (.2fas)</span>
<span class="ecosystem-item"><iconAegis /> Aegis (.json/.txt)</span>
<span class="ecosystem-item"><iconBitwarden /> Bitwarden (.json/.csv)</span>
<span class="ecosystem-item"><iconProtonAuth /> Proton (.json)</span>
<span class="ecosystem-item"><iconEnte /> Ente (.txt)</span>
<span class="ecosystem-item"><iconGoogleAuth /> Google Auth (.png/.jpg)</span>
<span class="ecosystem-item"><iconMicrosoftAuth /> Microsoft Auth</span>
</div>
<!-- 疑难解答(折叠) -->
<el-collapse class="ecosystem-collapse mt-15">
<el-collapse-item name="1">
<template #title>
<el-icon class="mr-5"><Warning /></el-icon> <strong>{{ $t('migration.ga_ms_import_guide') }}</strong>
</template>
<div class="migration-ga-tip mt-5">
<span>Google Authenticator</span>
<p>{{ $t('migration.ga_auth_desc') }}</p>
</div>
<div class="migration-ms-tip">
<div class="migration-ms-tip mt-10">
<span>{{ $t('migration.ms_auth_desc') }}</span>
<p>{{ $t('migration.ms_auth_detail') }}:<br />
<code>/data/data/com.azure.authenticator/databases/PhoneFactor</code><br />
@@ -58,20 +69,20 @@
<code>/data/data/com.azure.authenticator/databases/PhoneFactor-shm</code>
</p>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
<div class="migration-format-group">
<h4>📄 {{ $t('migration.generic_format') }}</h4>
<div class="migration-tags">
<el-tag type="info" effect="light"><el-icon><Document /></el-icon> {{ $t('migration.generic_json') }}</el-tag>
<el-tag type="info" effect="light"><el-icon><Tickets /></el-icon> OTPAuth URI (.txt)</el-tag>
<el-tag type="info" effect="light"><el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}</el-tag>
</div>
</div>
<div class="ecosystem-group">
<h4 class="group-title">📄 {{ $t('migration.generic_format') }}</h4>
<div class="ecosystem-grid">
<span class="ecosystem-item"><el-icon><Document /></el-icon> {{ $t('migration.generic_json') }}</span>
<span class="ecosystem-item"><el-icon><Tickets /></el-icon> OTPAuth URI (.txt)</span>
<span class="ecosystem-item"><el-icon><Grid /></el-icon> {{ $t('migration.spreadsheet_csv') }}</span>
</div>
</div>
</template>
</el-upload>
</div>
</div>
</div>
</div>
@@ -121,7 +132,7 @@
</template>
<script setup>
import { UploadFilled, Lock, Unlock, Document, Tickets, Grid } from '@element-plus/icons-vue'
import { UploadFilled, Lock, Unlock, Document, Tickets, Grid, Warning } from '@element-plus/icons-vue'
import { useDataImport } from '@/features/migration/composables/useDataImport'
// icons for import options (follow export page style)
@@ -68,12 +68,17 @@
<!-- 移动端用户信息卡片 -->
<div class="user-info-card" v-if="authUserStore.userInfo">
<div class="user-card-content">
<el-avatar :size="40" :src="authUserStore.userInfo?.avatar" class="user-avatar">
{{ authUserStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
<el-avatar
:size="40"
:src="authUserStore.userInfo?.avatar || ''"
class="user-avatar"
@error="(e) => true"
>
{{ authUserStore.userInfo?.username ? authUserStore.userInfo.username.charAt(0).toUpperCase() : '?' }}
</el-avatar>
<div class="user-detail">
<div class="user-name-row">
<span class="user-name">{{ authUserStore.userInfo?.username }}</span>
<span class="user-name">{{ authUserStore.userInfo?.username || '2FAuth' }}</span>
<i class="status-dot" :class="{ 'is-online': !layoutStore.isOffline }"></i>
</div>
<div class="user-provider" v-if="authUserStore.userInfo?.provider">
+1
View File
@@ -299,6 +299,7 @@
"ga_auth_desc": "Export 'Transfer QR code' in the App and upload the screenshot (.png / .jpg)",
"ms_auth_desc": "Microsoft Authenticator (Android Only)",
"ms_auth_detail": "Export the following 3 files from Android and upload them together",
"ga_ms_import_guide": "Google Auth / Microsoft Auth Import Guide",
"batch_import_processing": "📦 Batch Import Processing",
"batch_progress": "{processed} / {total} tasks completed",
"decrypt_backup_title": "🔓 Decrypt Backup File",
+1
View File
@@ -299,6 +299,7 @@
"ga_auth_desc": "请在 App 内导出\"迁移二维码\",将截图直接上传 (.png / .jpg)",
"ms_auth_desc": "Microsoft Authenticator (仅支持 Android)",
"ms_auth_detail": "Android 手机导出以下3个文件后,同时选中上传即可导入",
"ga_ms_import_guide": "Google Auth / Microsoft Auth 导入指南",
"batch_import_processing": "📦 批量导入处理中",
"batch_progress": "{processed} / {total} 任务已完成",
"decrypt_backup_title": "🔓 解密备份文件",
+29 -6
View File
@@ -1,7 +1,7 @@
<template>
<header class="header">
<div class="logo">
<el-button v-if="layoutStore.isMobile && !['/login', '/health'].includes(route.path)" @click="layoutStore.showMobileMenu = true" class="header-menu-btn">
<el-button v-if="layoutStore.isMobile && route.meta.requiresAuth" @click="layoutStore.showMobileMenu = true" class="header-menu-btn">
<el-icon :size="14" class="menu-icon" style="color: var(--el-text-color-primary);">
<svg viewBox="0 0 16 16" version="1.1" width="100%" height="100%" aria-hidden="true" fill="currentColor">
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75Zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75ZM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Z"></path>
@@ -13,12 +13,23 @@
<h2>2FAuth Worker</h2>
</a>
</div>
<div class="user-actions" v-if="!['/login', '/health'].includes(route.path)">
<div class="user-profile" v-if="!layoutStore.isMobile">
<el-avatar :size="32" :src="authUserStore.userInfo?.avatar">
{{ authUserStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
<div class="user-actions">
<!-- 访客页面(登录/报错)始终显示的通用操作 -->
<div class="guest-actions" v-if="!route.meta.requiresAuth">
<el-button circle :icon="themeStore.isDark ? Sunny : Moon" @click="themeStore.toggleTheme" class="header-action-btn" />
<el-button circle :icon="iconLocales" :title="$i18n.locale === 'zh-CN' ? 'English' : '切换语言'" @click="toggleLanguage" class="header-action-btn" />
</div>
<!-- 系统内部页面,仅 PC 端显示用户头像 -->
<div class="user-profile" v-if="!layoutStore.isMobile && route.meta.requiresAuth">
<el-avatar
:size="32"
:src="authUserStore.userInfo?.avatar || ''"
@error="(e) => true"
>
{{ authUserStore.userInfo?.username ? authUserStore.userInfo.username.charAt(0).toUpperCase() : '?' }}
</el-avatar>
<span class="username">{{ authUserStore.userInfo?.username }}</span>
<span class="username">{{ authUserStore.userInfo?.username || '2FAuth' }}</span>
</div>
</div>
</header>
@@ -29,11 +40,23 @@ import { useRoute, useRouter } from 'vue-router'
import { Lock } from '@element-plus/icons-vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
import { useThemeStore } from '@/shared/stores/themeStore'
import { Sunny, Moon } from '@element-plus/icons-vue'
import iconLocales from '@/shared/components/icons/iconLocales.vue'
import { useI18n } from 'vue-i18n'
import { setLanguage } from '@/locales'
const { locale } = useI18n()
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const authUserStore = useAuthUserStore()
const themeStore = useThemeStore()
const toggleLanguage = () => {
const targetLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
setLanguage(targetLang)
}
const goHome = () => {
if (route.path === '/') {