style: 修改一些UI和UE,提升用户体验
This commit is contained in:
1 parent
72496934fa
commit
d7eb9778df
23 files changed
+266
-81
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "无法连接到服务端进行安全校验"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ themeStore.initTheme()
|
||||
|
||||
// PWA Service Worker 注册
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onofflineReady() { },
|
||||
onNeedRefresh() {
|
||||
import('element-plus').then(({ ElNotification }) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' 返回)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user