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

This commit is contained in:
nap0o committed 2026-03-08 14:34:18 +08:00
1 parent 72496934fa
commit d7eb9778df
23 files changed
+266 -81

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
"name": "backend",
"type": "module",
"scripts": {
"dev": "wrangler dev --config ../wrangler.dev.toml --host 0.0.0.0 --port 8787 --test-scheduled",
"dev": "wrangler dev --config ../wrangler.dev.toml --ip 0.0.0.0 --port 8787 --test-scheduled",
"deploy": "wrangler deploy --minify"
},
"dependencies": {
@@ -15,7 +15,7 @@
"@cloudflare/workers-types": "^4.20260226.1",
"drizzle-kit": "^0.31.9",
"typescript": "^5.9.3",
"wrangler": "^4.69.0"
"wrangler": "^4.71.0"
},
"overrides": {
"esbuild": ">=0.25.0"
-3
View File
@@ -10,15 +10,12 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@noble/ciphers": "^2.1.1",
"@noble/hashes": "^2.0.1",
"@tanstack/vue-query": "^5.66.0",
"argon2-browser": "^1.18.0",
"bcryptjs": "^3.0.3",
"element-plus": "^2.9.5",
"hash-wasm": "^4.12.0",
"jsqr": "^1.4.0",
"libsodium-wrappers": "^0.8.2",
"libsodium-wrappers-sumo": "^0.8.2",
"pinia": "^3.0.4",
"qrcode": "^1.5.4",
+10 -1
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
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'
@@ -18,6 +18,15 @@ const elementLocale = computed(() => {
const route = useRoute()
const layoutStore = useLayoutStore()
// 移动端菜单打开时锁定背景滚动
watch(() => layoutStore.showMobileMenu, (newVal) => {
if (newVal) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
const checkMobile = () => {
layoutStore.isMobile = window.innerWidth < 768
}
+58 -2
View File
@@ -40,6 +40,18 @@ body,
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* 移动端特殊适配:释放根部滚动以支持原生下拉刷新 */
@media (max-width: 768px) {
html,
body,
#app {
height: auto;
min-height: 100%;
overflow: visible;
}
}
a {
color: inherit;
text-decoration: none;
@@ -89,6 +101,14 @@ a:hover {
overflow: hidden;
}
@media (max-width: 768px) {
.app-container {
height: auto;
min-height: 100vh;
overflow: visible;
}
}
main {
flex: 1;
overflow: hidden;
@@ -96,6 +116,12 @@ main {
flex-direction: column;
}
@media (max-width: 768px) {
main {
overflow: visible;
}
}
/* TheHeader.vue */
.header {
background: var(--el-bg-color);
@@ -188,6 +214,16 @@ main {
overflow: hidden;
}
@media (max-width: 768px) {
.home-container,
.main-body {
height: auto;
min-height: 100vh;
overflow: visible;
}
}
.left-aside {
background: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
@@ -218,6 +254,15 @@ main {
scrollbar-gutter: stable;
}
@media (max-width: 768px) {
.main-content {
overflow: visible !important;
overflow-y: visible !important;
overflow-x: visible !important;
scrollbar-gutter: auto;
}
}
.sidebar-footer {
padding: 20px;
text-align: center;
@@ -248,12 +293,14 @@ main {
/* Login.vue */
.login-container {
margin: 25px 0px;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex: 1;
background: transparent;
position: relative;
}
.login-card {
@@ -311,12 +358,21 @@ main {
margin-top: 10px;
}
/* 移动端特殊适配 */
@media (max-width: 768px) {
.login-container {
margin: 15px 0px;
align-items: flex-start;
/* 移动端靠上显示,避免被软键盘完全遮挡(虽然登录页通常没键盘) */
}
}
/* OAuthCallback.vue */
.callback-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex: 1;
background: transparent;
}
@@ -98,10 +98,12 @@ export function useOAuthCallback() {
if (data.deviceKey) {
await setIdbItem('device_salt', data.deviceKey)
}
// 更新状态机进入应用主流程
const authUserStore = useAuthUserStore()
await authUserStore.fetchUserInfo()
// 清理旧的 Tab 状态,确保干净进入首页
sessionStorage.clear()
router.push('/')
} else {
errorMsg.value = data.error || t('auth.login_rejected')
@@ -1,6 +1,7 @@
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { authService } from '@/features/auth/service/authService'
import { i18n } from '@/locales'
const CACHE_KEY = 'oauth_providers_cache'
@@ -10,6 +11,7 @@ const CACHE_KEY = 'oauth_providers_cache'
export function useOAuthProviders() {
const loadingProvider = ref(null)
const providers = ref([])
const isFetchingProviders = ref(true)
onMounted(async () => {
// 1. 优先从缓存读取,实现秒开 (Offline-first / Stale-while-revalidate)
@@ -17,6 +19,7 @@ export function useOAuthProviders() {
if (cached) {
try {
providers.value = JSON.parse(cached)
// 移除:isFetchingProviders.value = false,确保持续显示 loading 直到最新 API 返回
} catch (e) {
console.warn('Invalid oauth providers cache', e)
}
@@ -34,6 +37,9 @@ export function useOAuthProviders() {
}
} catch (e) {
console.error('Failed to sync oauth providers:', e)
ElMessage.error(i18n.global.t('auth.fetch_providers_failed'))
} finally {
isFetchingProviders.value = false
}
})
@@ -57,11 +63,19 @@ export function useOAuthProviders() {
window.location.href = data.authUrl
} else {
ElMessage.error(data.error || '获取授权链接失败: 提供商未配置或失效')
let errorMsg = data.error
if (errorMsg && i18n.global.te(`api_errors.${errorMsg}`)) {
errorMsg = i18n.global.t(`api_errors.${errorMsg}`)
}
ElMessage.error(errorMsg || i18n.global.t('auth.oauth_failed'))
}
} catch (error) {
// Error managed by axios request interceptor & authError
ElMessage.error(error.message || '获取授权状态遭遇网络异常')
let errorMsg = error.message
if (errorMsg && i18n.global.te(`api_errors.${errorMsg}`)) {
errorMsg = i18n.global.t(`api_errors.${errorMsg}`)
}
ElMessage.error(errorMsg || i18n.global.t('auth.oauth_network_error'))
} finally {
// The loading state mostly clears when the page navigates away,
// but we reset it here in case of errors.
@@ -72,6 +86,7 @@ export function useOAuthProviders() {
return {
providers,
loadingProvider,
isFetchingProviders,
handleLogin
}
}
+8 -2
View File
@@ -7,7 +7,12 @@
<p class="subtitle">{{ $t('auth.subtitle') }}</p>
</div>
<div class="action-container">
<div v-if="isFetchingProviders" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 150px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading" :size="38" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
<p style="font-size: 15px; letter-spacing: 1px;">{{ $t('common.loading_data') }}</p>
</div>
<div v-else class="action-container" style="min-height: 100px; border-radius: 8px;">
<template v-for="provider in providers" :key="provider.id">
<el-button
type="primary"
@@ -42,7 +47,7 @@
</template>
<script setup>
import { Lock, Platform } from '@element-plus/icons-vue'
import { Lock, Platform, Loading } from '@element-plus/icons-vue'
import iconGithub from '@/shared/components/icons/iconGithub.vue'
import iconGoogle from '@/shared/components/icons/iconGoogle.vue'
import iconGitee from '@/shared/components/icons/iconGitee.vue'
@@ -63,6 +68,7 @@ const iconComponents = {
const {
providers,
loadingProvider,
isFetchingProviders,
handleLogin
} = useOAuthProviders()
</script>
@@ -66,34 +66,14 @@
<el-form :model="form" label-position="top" ref="formRef">
<el-form-item :label="$t('backup.type_label')">
<el-select v-model="form.type" :disabled="isEditing">
<el-option label="WebDAV" value="webdav" />
<el-option :label="$t('backup.type_s3')" value="s3" />
<el-option label="Telegram" value="telegram" />
<el-option label="WebDAV" value="webdav" />
</el-select>
</el-form-item>
<el-form-item :label="$t('backup.name_label')">
<el-input v-model="form.name" :placeholder="$t('backup.name_placeholder')" />
</el-form-item>
<!-- WebDAV 配置 -->
<template v-if="form.type === 'webdav'">
<el-form-item :label="$t('backup.webdav_url')">
<el-input v-model="form.config.url" placeholder="https://pan.example.com/dav/" />
</el-form-item>
<el-form-item :label="$t('backup.username')">
<el-input v-model="form.config.username" />
</el-form-item>
<el-form-item :label="$t('backup.password')">
<div v-if="isEditing && !isEditingWebdavPwd" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
<el-button link type="primary" @click="isEditingWebdavPwd = true; form.config.password = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.password" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('backup.save_dir')">
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
</el-form-item>
</template>
<!-- S3 配置 -->
<template v-if="form.type === 's3'">
@@ -139,6 +119,27 @@
</div>
</el-form-item>
</template>
<!-- WebDAV 配置 -->
<template v-if="form.type === 'webdav'">
<el-form-item :label="$t('backup.webdav_url')">
<el-input v-model="form.config.url" placeholder="https://pan.example.com/dav/" />
</el-form-item>
<el-form-item :label="$t('backup.username')">
<el-input v-model="form.config.username" />
</el-form-item>
<el-form-item :label="$t('backup.password')">
<div v-if="isEditing && !isEditingWebdavPwd" style="display: flex; align-items: center; justify-content: space-between; background-color: var(--el-fill-color-light); padding: 0 15px; border-radius: 4px; border: 1px solid var(--el-border-color); width: 100%; height: 32px;">
<span style="font-family: monospace; letter-spacing: 2px;">******</span>
<el-button link type="primary" @click="isEditingWebdavPwd = true; form.config.password = ''">{{ $t('backup.modify') }}</el-button>
</div>
<el-input v-else v-model="form.config.password" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('backup.save_dir')">
<el-input v-model="form.config.saveDir" placeholder="/2fauth-worker-backup" />
</el-form-item>
</template>
<el-divider content-position="left">{{ $t('backup.auto_backup_config') }}</el-divider>
<el-form-item :label="$t('backup.auto_backup')">
<el-switch v-model="form.autoBackup" :active-text="$t('backup.switch_on')" :inactive-text="$t('backup.switch_off')" />
@@ -162,7 +163,6 @@
<el-input-number v-model="form.autoBackupRetain" :min="0" :max="999" :label="$t('backup.retain_count_label')"></el-input-number>
<div class="form-tip" style="width: 100%">{{ $t('backup.retain_zero_tip') }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="testConnection" :loading="isTesting">{{ $t('backup.test_connection') }}</el-button>
@@ -21,7 +21,7 @@ export function useBackupProviders() {
const isEditingTelegramToken = ref(false)
const initialFormState = () => ({
type: 'webdav',
type: 's3',
name: '',
config: { url: '', username: '', password: '', saveDir: '/2fauth-worker-backup', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '', botToken: '', chatId: '' },
autoBackup: false,
@@ -104,7 +104,7 @@ export function useBackupProviders() {
return null
}
if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) {
return '自动备份密码长度必须至少 12 位'
return t('backup.password_min_length')
}
}
return null
@@ -121,7 +121,7 @@ export function useBackupProviders() {
form.value.config,
isEditing.value ? currentProviderId.value : null
)
if (res.success) ElMessage.success('连接成功')
if (res.success) ElMessage.success(t('backup.test_success'))
} catch (e) {
// Already handled by request.js global error handler
} finally { isTesting.value = false }
@@ -141,7 +141,7 @@ export function useBackupProviders() {
? await backupService.updateProvider(currentProviderId.value, form.value)
: await backupService.createProvider(form.value)
if (res.success) {
ElMessage.success('保存成功')
ElMessage.success(t('backup.save_success'))
showConfigDialog.value = false
await fetchProviders()
}
@@ -152,7 +152,7 @@ export function useBackupProviders() {
const deleteProvider = async (provider) => {
try {
await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' })
await ElMessageBox.confirm(t('backup.delete_provider_confirm'), t('common.warning'), { type: 'warning' })
await backupService.deleteProvider(provider.id)
await fetchProviders()
} catch (e) {
@@ -103,6 +103,7 @@ import { WarningFilled, CopyDocument, Refresh, Document, Select, Monitor, Settin
import { ElMessage } from 'element-plus'
import { request } from '@/shared/utils/request'
import { useClipboard } from '@vueuse/core'
import { i18n } from '@/locales'
const router = useRouter()
const { copy, isSupported } = useClipboard()
@@ -135,11 +136,11 @@ const generateNewKey = (field) => {
const copyKey = async (field) => {
if (!isSupported.value) {
ElMessage.error('您的浏览器不支持自动复制,请手动选中复制。')
ElMessage.error(i18n.global.t('healthCheck.copy_unsupported'))
return
}
await copy(generatedKeys.value[field])
ElMessage.success('安全密钥已复制到剪贴板')
ElMessage.success(i18n.global.t('healthCheck.copy_success'))
}
const checkHealth = async () => {
@@ -178,7 +179,7 @@ const checkHealth = async () => {
}
})
} else {
ElMessage.error('无法连接到服务端进行安全校验')
ElMessage.error(i18n.global.t('healthCheck.connection_failed'))
}
} finally {
loading.value = false
@@ -201,14 +201,18 @@ const {
}
.tags span.el-tag {
padding: 8px 15px;
padding: 4px 8px; /* 减小内边距,使其不那么像按钮 */
height: auto;
color: var(--el-text-color-regular);
cursor: default; /* 明确鼠标状态不是可点击的 */
border-color: transparent; /* 去除边框,使其更像高亮文本 */
background-color: var(--el-fill-color-light); /* 更浅的背景色 */
}
.tags span i.el-icon {
display: inline-flex;
flex-wrap: wrap;
margin-right: 4px; /* 图标和文字之间增加一点点间距 */
}
.ga-tip {
@@ -52,11 +52,8 @@
</el-tooltip>
<el-tooltip v-if="app.downloads.android" content="Google Play" placement="top">
<el-link :href="app.downloads.android" target="_blank" :underline="false" class="platform-btn">
<svg viewBox="0 0 24 24" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
<path d="M17.5 13.9l-4.2-4.2 4.2-4.2c.4.3.7.6.9 1 .6 1 .6 2.1 0 3.3-.2.4-.5.7-.9 1L19.4 12l-1.9 1.9z"/>
<path d="M2.3 22l13.6-13.6-1.5-1.5L.8 20.4c.5.8 1.4 1.2 2.3 1.2H3v.4z"/>
<path d="M.8 3.5l14 14 1.6-1.6L2.8 2.3H2.4C1.5 2.3.6 2.7.2 3.4L2.3 5v-.5z"/>
<path d="M12 12L2.5 2.5v19L12 12z"/>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor">
<path d="M 5.4160156 2.328125 L 13.296875 10.535156 L 15.626953 8.109375 C 11.611953 5.838375 6.3020156 2.834125 5.4160156 2.328125 z M 3.140625 2.8476562 C 3.055625 3.0456562 3 3.2629063 3 3.5039062 L 3 20.591797 C 3 20.788797 3.044375 20.970625 3.109375 21.140625 L 11.910156 11.978516 L 3.140625 2.8476562 z M 17.423828 9.1269531 L 14.683594 11.978516 L 17.402344 14.810547 C 19.071344 13.865547 20.226562 13.210937 20.226562 13.210938 C 20.725562 12.907937 21.005047 12.441594 20.998047 11.933594 C 20.991047 11.438594 20.702609 10.981938 20.224609 10.710938 C 20.159609 10.673937 19.031828 10.035953 17.423828 9.1269531 z M 13.296875 13.423828 L 5.4746094 21.566406 C 6.8216094 20.800406 11.797469 17.982172 15.605469 15.826172 L 13.296875 13.423828 z"/>
</svg>
</el-link>
</el-tooltip>
@@ -179,8 +179,22 @@ const handleLogout = async () => {
if (layoutStore.showMobileMenu) {
layoutStore.showMobileMenu = false
}
await authUserStore.logout()
router.replace('/login')
// 1. 立即清空本地状态(同步执行,防止 UI 卡顿)
await authUserStore.clearUserInfo()
// 2. 带有超时保护的异步注销,最多等待 1.5 秒保证后端请求发完,防止浏览器过早跳转导致请求 Abort
await Promise.race([
authUserStore.logout(),
new Promise(resolve => setTimeout(resolve, 1500))
]).catch(() => {})
// 3. 物理跳转进入登录页
// 使用 location.href 而不是 router.replace,目的是强制刷新全部 JS Chunks,
// 解决 Cloudflare 部署新版本后旧分块 404 导致的白屏或无响应问题。
window.location.href = '/login'
// 提示信息(由于页面重刷,这条提示可能稍纵即逝,但保留逻辑完整性)
ElMessage.success(t('auth.logout_success'))
}
</script>
@@ -199,6 +213,7 @@ const handleLogout = async () => {
}
.user-avatar {
flex-shrink: 0;
border: 2px solid var(--el-color-primary-light-8);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
@@ -206,6 +221,7 @@ const handleLogout = async () => {
.user-detail {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name-row {
@@ -220,6 +236,9 @@ const handleLogout = async () => {
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-dot {
@@ -1,8 +1,8 @@
<template>
<div class="vault-list-wrapper" style="min-height: 400px;">
<div class="vault-content">
<!-- 移动端偏移 0px (Header2), PC端偏移 61px (Header1) -->
<el-affix :offset="layoutStore.isMobile ? 0 : 61" @change="(val) => isToolbarFixed = val">
<!-- 移动端偏移 60px (Header2), PC端偏移 61px (Header1) -->
<el-affix :offset="layoutStore.isMobile ? 60 : 61" @change="(val) => isToolbarFixed = val">
<div class="toolbar" :class="{ 'is-affixed': isToolbarFixed }" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
<el-input
@@ -263,16 +263,14 @@ onMounted(handleUnlocked)
.toolbar.is-affixed {
z-index: 2000;
margin-bottom: 0 !important;
background-color: var(--el-bg-color-page);
}
/* 移动端吸顶效果:全屏、背景、边框 */
@media (max-width: 767px) {
.toolbar.is-affixed {
position: fixed !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
background-color: var(--el-bg-color-page);
margin-left: -20px !important; /* 抵消 el-main 的内边距 */
padding: 14px 20px !important;
border-radius: 0 !important;
}
@@ -281,9 +279,8 @@ onMounted(handleUnlocked)
/* PC 端吸顶效果:保持原有宽度,无背景色和下边框 */
@media (min-width: 768px) {
.toolbar.is-affixed {
background-color: var(--el-bg-color) !important;
border-bottom: none !important;
padding: 10px 0px 20px 0px !important;
padding: 20px 0px !important;
}
}
</style>
+17 -8
View File
@@ -45,23 +45,32 @@
</template>
<script setup>
import { ref, nextTick, defineAsyncComponent, watch } from 'vue'
import { ref, nextTick, watch, onMounted } from 'vue'
import AppSidebar from '@/features/vault/components/appSidebar.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { createAsyncComponent } from '@/shared/utils/asyncHelper'
const layoutStore = useLayoutStore()
const VaultList = defineAsyncComponent(() => import('@/features/vault/components/vaultList.vue'))
const AddVaultScan = defineAsyncComponent(() => import('@/features/vault/components/addVaultScan.vue'))
const AddVaultManual= defineAsyncComponent(() => import('@/features/vault/components/addVaultManual.vue'))
const DataExport = defineAsyncComponent(() => import('@/features/migration/views/dataExport.vue'))
const DataImport = defineAsyncComponent(() => import('@/features/migration/views/dataImport.vue'))
const DataBackup = defineAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
const UtilityTools = defineAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
const VaultList = createAsyncComponent(() => import('@/features/vault/components/vaultList.vue'))
const AddVaultScan = createAsyncComponent(() => import('@/features/vault/components/addVaultScan.vue'))
const AddVaultManual= createAsyncComponent(() => import('@/features/vault/components/addVaultManual.vue'))
const DataExport = createAsyncComponent(() => import('@/features/migration/views/dataExport.vue'))
const DataImport = createAsyncComponent(() => import('@/features/migration/views/dataImport.vue'))
const DataBackup = createAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
const UtilityTools = createAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
const sessionKey = 'activeTab'
const activeTab = ref(sessionStorage.getItem(sessionKey) || 'vault')
onMounted(() => {
// 强制校准:如果状态非法,回归默认视图
const validTabs = ['vault', 'add-vault-scan', 'add-vault-manual', 'migration-export', 'migration-import', 'backups', 'tools']
if (!validTabs.includes(activeTab.value)) {
activeTab.value = 'vault'
}
})
watch(activeTab, (newVal) => {
sessionStorage.setItem(sessionKey, newVal)
})
+7 -1
View File
@@ -15,6 +15,8 @@
"update_available": "Update Available",
"pwa_update_ready": "🎉 New Cloudflare Worker deployment is ready!",
"refresh_now": "Refresh Now",
"copy_success": "Copy success",
"copy_fail": "Copy failed",
"offline_mode": "Network disconnected, the application is currently in offline cache mode",
"search_placeholder": "Search service, account, or category...",
"selected_items": "Selected {count} items",
@@ -477,6 +479,7 @@
"subtitle": "A Secure 2FA Management Tool",
"login": "Login",
"logout_success": "Signed out securely.",
"fetch_providers_failed": "Failed to fetch identity providers, please try again later",
"oauth_failed": "Failed to retrieve OAuth link. Provider not configured.",
"oauth_network_error": "Network error while getting OAuth status.",
"login_with": "Login with {provider}",
@@ -641,6 +644,9 @@
"gitee_config_suggestion": "Please verify Gitee developer credentials, and depending on your deployment method, fill in OAUTH_GITEE_CLIENT_ID, OAUTH_GITEE_CLIENT_SECRET, and OAUTH_GITEE_REDIRECT_URI.",
"cloudflare_config_suggestion": "Requires four parameters: Client ID, Secret, Org Domain, Redirect URI. Depending on your deployment method, fill in OAUTH_CLOUDFLARE_CLIENT_ID, OAUTH_CLOUDFLARE_CLIENT_SECRET, and OAUTH_CLOUDFLARE_REDIRECT_URI.",
"no_provider_suggestion": "Consult the README to pick the easiest provider. Retrieve the Client ID and Secret and configure them in Cloudflare's dashboard."
}
},
"copy_unsupported": "Your browser doesn't support automatic copying, please manually select it.",
"copy_success": "Key copied to clipboard",
"connection_failed": "Cannot connect to server for health check"
}
}
+7 -1
View File
@@ -15,6 +15,8 @@
"update_available": "更新可用",
"pwa_update_ready": "🎉 Cloudflare Worker 新部署已就绪!",
"refresh_now": "立即刷新",
"copy_success": "复制成功",
"copy_fail": "复制失败",
"offline_mode": "网络已断开,当前应用正处于离线缓存模式",
"search_placeholder": "搜索服务名称、账号或分类...",
"selected_items": "已选 {count} 项",
@@ -478,6 +480,7 @@
"subtitle": "一站式安全双重认证管理",
"login": "登录",
"logout_success": "已安全退出",
"fetch_providers_failed": "获取身份提供商列表失败,请稍后重试",
"oauth_failed": "获取授权链接失败:提供商未配置或失效",
"oauth_network_error": "获取授权状态遭遇网络异常",
"login_with": "通过 {provider} 授权登录",
@@ -642,6 +645,9 @@
"gitee_config_suggestion": "请核对 Gitee 开发者凭据,,根据不同部署方式,填入 OAUTH_GITEE_CLIENT_ID、OAUTH_GITEE_CLIENT_SECRET、OAUTH_GITEE_REDIRECT_URI 填入",
"cloudflare_config_suggestion": "需要四位一体的参数:Client ID, Secret, Org Domain, Redirect URI,根据不同部署方式,填入 OAUTH_CLOUDFLARE_CLIENT_ID、OAUTH_CLOUDFLARE_CLIENT_SECRET、OAUTH_CLOUDFLARE_REDIRECT_URI 填入",
"no_provider_suggestion": "阅读2Fauth Worker 的 README,挑选最适合您且配置最简单的一种登录方式,按照其说明获取 Client ID 与 Secret 并在云端配置。"
}
},
"copy_unsupported": "您的浏览器不支持自动复制,请手动选中复制。",
"copy_success": "安全密钥已复制到剪贴板",
"connection_failed": "无法连接到服务端进行安全校验"
}
}
+1
View File
@@ -25,6 +25,7 @@ themeStore.initTheme()
// PWA Service Worker 注册
const updateSW = registerSW({
immediate: true,
onofflineReady() { },
onNeedRefresh() {
import('element-plus').then(({ ElNotification }) => {
+54
View File
@@ -0,0 +1,54 @@
import { defineAsyncComponent, h } from 'vue'
import { ElLoading, ElEmpty, ElButton } from 'element-plus'
/**
* 增强的异步组件工厂函数
* 解决 PWA/Cloudflare 环境下分块加载失败导致的白屏问题
*/
export function createAsyncComponent(loader) {
return defineAsyncComponent({
loader,
// 加载中占位
loadingComponent: {
render() {
return h('div', {
style: 'display: flex; justify-content: center; align-items: center; min-height: 400px; width: 100%;'
}, [
h('div', {
class: 'is-loading',
style: 'color: var(--el-color-primary); font-size: 24px;'
}, 'Loading...')
])
}
},
// 失败占位
errorComponent: {
props: ['error'],
render() {
return h('div', {
style: 'padding: 40px; text-align: center;'
}, [
h(ElEmpty, { description: '组件加载失败,请检查网络连接' }, {
default: () => h(ElButton, {
type: 'primary',
onClick: () => window.location.reload()
}, '刷新页面')
})
])
}
},
// 超时设置
timeout: 15000,
// 延迟显示加载组件
delay: 200,
// 错误重试逻辑
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
console.warn(`[AsyncComponent] Loading failed, retrying (${attempts}/3)...`, error)
retry()
} else {
fail()
}
}
})
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { ElMessage } from 'element-plus'
import { i18n } from '@/locales'
export async function copyToClipboard(text, successMsg = '复制成功') {
export async function copyToClipboard(text, successMsg = i18n.global.t('common.copy_success')) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success(successMsg)
} catch (e) {
ElMessage.error('复制失败')
ElMessage.error(i18n.global.t('common.copy_fail'))
console.error('Clipboard error:', e)
}
}
+4 -8
View File
@@ -109,12 +109,8 @@ export async function request(url, options = {}) {
}
if (router && router.currentRoute.value.path !== '/login') {
router.replace('/login').then(() => {
// 给个延迟再释放锁,防止快速回溯
setTimeout(() => { isNavigatingToLogin = false }, 1000)
}).catch(() => {
setTimeout(() => { isNavigatingToLogin = false }, 1000)
})
// 全局会话过期使用物理跳转,清空内存所有闭包与过期 JS 分块引用
window.location.href = '/login'
} else {
setTimeout(() => { isNavigatingToLogin = false }, 1000)
}
@@ -150,7 +146,7 @@ export async function request(url, options = {}) {
}
ElMessage.error(translatedError)
}
throw new Error(data.message || data.error || '请求失败')
throw new Error(data.message || data.error || i18n.global.t('api_errors.request_failed'))
}
// 解构新的标准响应格式。如果后端返回 { success: true, data: [...] },这里直接将 data 铺平返回,保持对原有旧代码的兼容性。
@@ -168,7 +164,7 @@ export async function request(url, options = {}) {
// Only show toast if it wasn't already shown in the !response.ok block above
// The throwing logic above throws an Error with a specific format, we only toast here if it's a fetch/network level error
if (error.name === 'TypeError' || error.message.includes('fetch')) {
ElMessage.error(i18n.global.te('api_errors.network_error') ? i18n.global.t('api_errors.network_error') : '网络请求失败')
ElMessage.error(i18n.global.te('api_errors.network_error') ? i18n.global.t('api_errors.network_error') : i18n.global.t('auth.network_abnormal'))
}
}
throw error
+6 -2
View File
@@ -81,6 +81,8 @@ export default defineConfig({
globIgnores: [
'**/assets/wa-sqlite*.js',
'**/assets/argon2*.js',
'**/assets/hash-wasm*.js',
'**/assets/libsodium-wrappers*.js',
'**/assets/sql*.js',
'**/assets/jsQR*.js',
'**/assets/dataImport*.js',
@@ -110,7 +112,7 @@ export default defineConfig({
},
{
// 为被 globIgnores 忽略的大体积工具 JS chunk 配置动态缓存
urlPattern: /assets\/(wa-sqlite|argon2|sql|jsQR|dataImport|dataMigrationService).*\.js$/i,
urlPattern: /assets\/(wa-sqlite|argon2|hash-wasm|libsodium-wrappers|sql|jsQR|dataImport|dataMigrationService).*\.js$/i,
handler: 'CacheFirst',
options: {
cacheName: 'lazy-tools-js-cache',
@@ -137,7 +139,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8787',
target: 'http://127.0.0.1:8787',
changeOrigin: true
}
}
@@ -169,6 +171,8 @@ export default defineConfig({
// 加密与数据库相关的超大模块抽象为主 Vendor Chunk
if (id.includes('/node_modules/wa-sqlite/')) return 'wa-sqlite'
if (id.includes('/node_modules/argon2-browser/')) return 'argon2-browser'
if (id.includes('/node_modules/hash-wasm/')) return 'hash-wasm'
if (id.includes('/node_modules/libsodium-wrappers/') || id.includes('/node_modules/libsodium-wrappers-sumo/')) return 'libsodium-wrappers'
if (id.includes('/node_modules/sql.js/')) return 'sql-js'
// 剩余其他的第三方包交给 Rollup 按需路由或组件自动拆分 (移除强硬的 'vendor' 返回)
+5
View File
@@ -3,6 +3,10 @@ main = "backend/src/app/index.ts"
compatibility_date = "2024-04-01"
compatibility_flags = ["nodejs_compat"]
[dev]
ip = "0.0.0.0"
port = 8787
[assets]
binding = "ASSETS"
directory = "frontend/dist"
@@ -12,6 +16,7 @@ run_worker_first = true
[build]
command = "npm run build --prefix ../frontend"
[[d1_databases]]
binding = "DB"
database_name = "2fauth-db-dev"