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

This commit is contained in:
nap0o committed 2026-03-17 15:12:03 +08:00
1 parent 06a6d0820a
commit e9a2060ca1
15 files changed
+228 -58

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ yarn-error.log*
.wrangler/
**/.wrangler/
# Gemini/AI assistant state
.gemini/
.antigravity
# Act (local GitHub Actions) configuration and state
.actrc
.event
@@ -1,8 +1,4 @@
/* Backup Feature Styles */
.backup-container {
padding: 10px;
}
.backup-header-actions {
display: flex;
justify-content: space-between;
@@ -13,6 +9,7 @@
.backup-provider-card {
height: 100%;
transition: transform 0.2s;
border-radius: 8px;
}
.backup-provider-card:hover {
@@ -41,6 +38,14 @@
.backup-action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.backup-action-buttons .el-button {
margin-left: 0 !important;
margin-right: 8px !important;
}
}
.backup-form-tip {
@@ -115,15 +120,17 @@
text-align: center;
}
/* Global Fix for Element Plus Icon & Loading Alignment */
.el-button {
/* Global Fix for Element Plus Icon & Loading Alignment in Backup Feature */
.data-backup-wrapper .el-button,
.backup-container .el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
vertical-align: middle;
}
.el-button .el-icon {
.data-backup-wrapper .el-button .el-icon,
.backup-container .el-button .el-icon {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
@@ -263,4 +270,18 @@
.dark .btn-dropbox-auth:hover {
background-color: #00368c;
}
/* Connection Test Success State Fix */
.backup-action-buttons .el-button.btn-test-success.is-disabled {
opacity: 1 !important;
color: var(--el-color-primary) !important;
border-color: var(--el-color-primary-light-7) !important;
background-color: var(--el-color-primary-light-9) !important;
cursor: default !important;
}
.dark .backup-action-buttons .el-button.btn-test-success.is-disabled {
background-color: rgba(64, 158, 255, 0.1) !important;
border-color: rgba(64, 158, 255, 0.2) !important;
}
@@ -2,7 +2,7 @@
.migration-export-groups {
max-width: 100%;
margin: 0 auto;
margin: 10px auto;
display: flex;
flex-direction: column;
gap: 20px;
@@ -1,3 +1,7 @@
.security-wrapper .tab-card-wrapper {
padding-bottom: 40px;
}
.security-wrapper .empty-state {
padding: 60px 0;
}
@@ -100,6 +100,10 @@
}
/* 3. QrScanner Component */
.add-vault-wrapper .tab-card-wrapper {
padding-bottom: 40px;
}
.qr-scanner-container {
width: 100%;
}
@@ -1,6 +1,11 @@
/* =========================================
4. UtilityTools 组件 (工具箱)
========================================= */
.tools-wrapper .tab-card-wrapper {
padding-bottom: 40px;
}
.tools-header {
display: flex;
align-items: center;
@@ -411,4 +411,48 @@ html.dark .category-tag.is-active {
.tag-count {
margin-left: 5px;
font-size: 11px;
}
/* --- 分类标签加载动效 (对齐 asyncHelper.js 的朴素风格) --- */
.category-tag {
position: relative;
/* 移除 overflow: hidden,由下方的 track 容器单独负责裁剪,确保文字显示完全 */
}
.tag-loading-track {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: rgba(var(--el-color-primary-rgb, 64, 158, 255), 0.1);
border-radius: 0 0 3px 3px;
overflow: hidden;
/* 在这里进行裁剪 */
z-index: 10;
}
.tag-loading-bar {
position: absolute;
top: 0;
left: 0;
width: 40%;
height: 100%;
background: var(--el-color-primary);
border-radius: 2px;
animation: plain-loading-flow 1.5s infinite ease-in-out;
}
@keyframes plain-loading-flow {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
.vault-list-wrapper {
transition: opacity 0.3s ease;
}
@@ -47,6 +47,18 @@
</p>
<div class="backup-action-buttons">
<el-button
type="primary" plain size="small"
:loading="!!testingProviderIds[provider.id]"
:disabled="testResults[provider.id] === 'success'"
:class="{ 'btn-test-success': testResults[provider.id] === 'success' }"
@click="testConnection(provider)"
>
<el-icon v-if="testResults[provider.id] === 'success'" color="#67C23A" style="margin-right: 4px;">
<CircleCheck />
</el-icon>
{{ testResults[provider.id] === 'success' ? $t('backup.connection_normal') : $t('backup.test_connection') }}
</el-button>
<el-button
type="success" plain size="small"
:loading="checkingBackupProviderId === provider.id"
@@ -471,7 +483,7 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="testConnection" :loading="isTesting">{{ $t('backup.test_connection') }}</el-button>
<el-button @click="() => testConnection()" :loading="isTesting">{{ $t('backup.test_connection') }}</el-button>
<el-button type="primary" @click="saveProvider" :loading="isSaving">{{ $t('backup.save') }}</el-button>
</template>
</el-dialog>
@@ -554,6 +566,7 @@ const layoutStore = useLayoutStore()
const {
providers, isLoading, showConfigDialog, isEditing, isTesting, isSaving,
testingProviderIds, testResults,
isEditingWebdavPwd, isEditingS3Secret, isEditingTelegramToken, isEditingEmailPwd, form,
isAutoBackupPasswordSaved, shouldUseExistingAutoBackupPassword, fetchProviders, openAddDialog,
editProvider, testConnection, saveProvider, deleteProvider,
@@ -20,6 +20,9 @@ export function useBackupProviders() {
const isEditing = ref(false)
const isTesting = ref(false)
const isSaving = ref(false)
const testingProviderIds = ref({}) // { providerId: true }
const testResults = ref({}) // { providerId: 'success' | 'error' }
const isEditingWebdavPwd = ref(false)
const isEditingS3Secret = ref(false)
const isEditingTelegramToken = ref(false)
@@ -174,11 +177,22 @@ export function useBackupProviders() {
return null
}
const testConnection = async () => {
const error = validateForm()
if (error) return ElMessage.warning(error)
const testConnection = async (provider = null) => {
const isIndividual = !!provider
const targetData = isIndividual ? provider : form.value
const targetId = isIndividual ? provider.id : (isEditing.value ? currentProviderId.value : null)
if (!isIndividual) {
const error = validateForm()
if (error) return ElMessage.warning(error)
}
if (isIndividual) {
testingProviderIds.value[targetId] = true
} else {
isTesting.value = true
}
isTesting.value = true;
// Only reset error states, keep 'success' state if it exists
if (authStatusGoogle.value === 'error') authStatusGoogle.value = null;
authErrorMessageGoogle.value = '';
@@ -191,14 +205,19 @@ export function useBackupProviders() {
try {
const res = await backupService.testConnection(
form.value.type,
form.value.config,
isEditing.value ? currentProviderId.value : null
targetData.type,
targetData.config,
targetId
)
if (res.success) {
ElMessage.success(t('backup.test_success'))
if (isIndividual) {
testResults.value[targetId] = 'success'
}
// Ensure the success status is reflected in the UI for OAuth providers
const type = form.value.type
const type = targetData.type
if (type === 'gdrive') authStatusGoogle.value = 'success'
else if (type === 'onedrive') authStatusMicrosoft.value = 'success'
else if (type === 'baidu') authStatusBaidu.value = 'success'
@@ -211,11 +230,13 @@ export function useBackupProviders() {
// Listen for the standard project-level OAuth revocation signal
if (errMsg.includes('oauth_token_revoked')) {
const type = form.value.type;
const type = targetData.type;
console.error(`[OAuth Auth Check] The authorization token for ${type} is revoked or expired. Attempting UI reset...`, e);
// Reset the internal token holder
form.value.config.refreshToken = '';
if (!isIndividual) {
form.value.config.refreshToken = '';
}
// Switch UI back to authentication state
if (type === 'gdrive') {
@@ -233,13 +254,19 @@ export function useBackupProviders() {
}
// If we are editing an existing provider, update the global store to reflect the state
if (isEditing.value && currentProviderId.value) {
backupStore.markAsRevoked(currentProviderId.value);
if (targetId) {
backupStore.markAsRevoked(targetId);
}
} else {
ElMessage.error(t(`api_errors.${errMsg}`) || rawMsg);
}
} finally { isTesting.value = false }
} finally {
if (isIndividual) {
delete testingProviderIds.value[targetId]
} else {
isTesting.value = false
}
}
}
const saveProvider = async () => {
@@ -493,6 +520,8 @@ export function useBackupProviders() {
isEditing,
isTesting,
isSaving,
testingProviderIds,
testResults,
isEditingWebdavPwd,
isEditingS3Secret,
isEditingTelegramToken,
@@ -35,7 +35,7 @@
</span>
</el-divider>
<div class="compatibility-container mt-20">
<div class="compatibility-container mt-20 mb-10">
<div class="ecosystem-groups">
<!-- 组1: 本系统 -->
<div class="ecosystem-group-card">
@@ -36,29 +36,42 @@
<div class="category-chips">
<div
class="category-tag"
:class="{ 'is-active': selectedCategory === '' }"
:class="{
'is-active': selectedCategory === '',
'is-loading': isFetching && selectedCategory === '' && !isFetchingNextPage
}"
@click="selectedCategory = ''"
>
{{ $t('common.all') }}
<span class="tag-count">({{ absoluteTotalItems }})</span>
<!-- 独立加载轨道,避免截断文字 -->
<div v-if="isFetching && selectedCategory === '' && !isFetchingNextPage" class="tag-loading-track">
<div class="tag-loading-bar"></div>
</div>
</div>
<div
v-for="stat in categoryStats"
:key="stat.id"
class="category-tag"
:class="{ 'is-active': selectedCategory === stat.id }"
:class="{
'is-active': selectedCategory === stat.id,
'is-loading': isFetching && selectedCategory === stat.id && !isFetchingNextPage
}"
@click="selectedCategory = stat.id"
>
{{ stat.category || $t('common.uncategorized') }}
<span class="tag-count">({{ stat.count }})</span>
<div v-if="isFetching && selectedCategory === stat.id && !isFetchingNextPage" class="tag-loading-track">
<div class="tag-loading-bar"></div>
</div>
</div>
</div>
</div>
</div>
</el-affix>
<!-- 1. 加载中 -->
<div v-if="(isInitializing || isLoading || isFetching) && vault.length === 0" class="flex-column flex-center min-h-400 text-secondary">
<!-- 1. 加载中 (仅在真正没有任何数据或初始化时显示大加载态) -->
<div v-if="isInitializing || (isLoading && vault.length === 0)" class="flex-column flex-center min-h-400 text-secondary">
<el-icon class="is-loading mb-20 text-primary" :size="48"><Loading /></el-icon>
<p class="text-16 ls-1">{{ $t('common.loading_data') }}</p>
</div>
@@ -1,5 +1,5 @@
import { ref, shallowRef, computed, watch } from 'vue'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
import { useInfiniteQuery, useQueryClient, keepPreviousData } from '@tanstack/vue-query'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { vaultService } from '@/features/vault/service/vaultService'
@@ -35,7 +35,7 @@ export function useVaultList(afterLoadRef = null) {
const firstPage = data.value?.pages?.[0]
const stats = firstPage?.categoryStats || localCategoryStats.value
if (!stats) return []
return stats.map(s => ({
category: s.category || '',
count: s.count,
@@ -77,6 +77,7 @@ export function useVaultList(afterLoadRef = null) {
// 60 秒内切换回来直接用缓存,不触发逐页重拉(避免多次 PBKDF2 saveData 开销)
// invalidateQueries / resetQueries 调用时会忽略 staleTime,强制立即刷新
staleTime: 60 * 1000,
placeholderData: keepPreviousData,
})
// 监听数据变化,同步到本地 vault 引用
@@ -136,13 +137,13 @@ export function useVaultList(afterLoadRef = null) {
if (!searchQuery.value && vaultStore.isUnlocked) {
setTimeout(async () => {
try {
await vaultStore.saveData({
await vaultStore.saveData({
vault: merged,
// 保存当前分类统计原始数据到持久层
categoryStats: newData.pages[0]?.categoryStats || []
})
vaultStore.clearDirty()
} catch (e) {}
} catch (e) { }
}, 0)
}
}, { deep: false })
+4 -1
View File
@@ -23,6 +23,7 @@
"selected_items": "Selected {count} items",
"select_all_loaded": "Select all loaded",
"loading_data": "Fetching data, please wait...",
"resource_loading": "Loading resources...",
"empty_vault": "Empty here, go add your first 2FA account!",
"go_add_vault": "Go to Add Account",
"export_account": "Export",
@@ -131,6 +132,8 @@
"management": "Source Management",
"add_source": "Add",
"fetching_sources": "Fetching backup sources, please wait...",
"test_connection": "Test Connection",
"connection_normal": "Connection Normal",
"auto_backup_on": "Auto Backup Enabled",
"last_backup": "Last Backup: ",
"never_backed_up": "Never",
@@ -241,7 +244,7 @@
"continue_use_old_pwd": "The system will continue to use the previous auto-backup password.",
"retain_count_label": "Retain Recent Backups",
"retain_zero_tip": "0 means no limits or cleanup for historical backups.",
"test_connection": "Test Connection",
"retain_zero_tip": "0 means no limits or cleanup for historical backups.",
"save": "Save",
"encrypted_backup": "Encrypted Backup",
"backup_security_tip": "Please enter an encryption password to secure the backup file.",
+4 -1
View File
@@ -23,6 +23,7 @@
"selected_items": "已选 {count} 项",
"select_all_loaded": "全选已加载",
"loading_data": "数据获取中, 请稍候...",
"resource_loading": "资源加载中",
"empty_vault": "空空如也,快去添加你的第一个 2FA 账号吧!",
"go_add_vault": "去添加账号",
"export_account": "账号导出",
@@ -131,6 +132,8 @@
"management": "云端备份管理",
"add_source": "添加备份源",
"fetching_sources": "备份源获取中, 请稍候...",
"test_connection": "测试连接",
"connection_normal": "连接正常",
"auto_backup_on": "自动备份已开启",
"last_backup": "上次备份: ",
"never_backed_up": "从未",
@@ -241,7 +244,7 @@
"continue_use_old_pwd": "系统将继续使用原有的自动备份密码。",
"retain_count_label": "保留最近份数",
"retain_zero_tip": "填 0 表示不对历史备份做任何限制与清理",
"test_connection": "测试连接",
"retain_zero_tip": "填 0 表示不对历史备份做任何限制与清理",
"save": "保存",
"encrypted_backup": "加密备份",
"backup_security_tip": "请输入加密密码用于保护备份文件。",
+54 -24
View File
@@ -19,32 +19,59 @@ export function createAsyncComponent(loader) {
throw err
}
},
// 加载中占位
loadingComponent: defineComponent({
name: 'AsyncLoading',
setup() {
const dots = ref('...')
let timer = null
const t = (key) => {
try {
return i18n.global.t(key)
} catch (e) {
return key
}
}
onMounted(() => {
timer = setInterval(() => {
if (dots.value.length >= 10) {
dots.value = '...'
} else {
dots.value += '.'
// 使用 CSS 关键帧动画注入(最轻量、无副作用)
const injectStyles = () => {
if (document.getElementById('async-loading-styles')) return
const style = document.createElement('style')
style.id = 'async-loading-styles'
style.textContent = `
@keyframes async-progress-flow {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
}, 400)
})
.async-progress-bar {
width: 200px;
height: 3px;
background: rgba(64, 158, 255, 0.1);
border-radius: 4px;
overflow: hidden;
position: relative;
margin-top: 16px;
}
.async-progress-inner {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 100%;
background: var(--el-color-primary);
border-radius: 4px;
animation: async-progress-flow 1.5s infinite ease-in-out;
box-shadow: 0 0 8px var(--el-color-primary);
}
`
document.head.appendChild(style)
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
onMounted(injectStyles)
// 使用闭包渲染函数,这是 Vue3 推荐的最稳健方式
return () => h('div', {
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
@@ -52,16 +79,19 @@ export function createAsyncComponent(loader) {
backgroundColor: 'transparent'
}
}, [
// 文案优化 - 适配多语言
h('div', {
class: 'is-loading',
style: {
fontSize: '24px',
fontFamily: 'monospace',
color: 'var(--el-color-primary)',
fontSize: '16px',
color: 'var(--el-text-color-regular)',
fontWeight: '500',
letterSpacing: '1px'
}
}, `Loading${dots.value}`)
}, t('common.resource_loading')),
// 线性进度条
h('div', { class: 'async-progress-bar' }, [
h('div', { class: 'async-progress-inner' })
])
])
}
}),
@@ -72,7 +102,7 @@ export function createAsyncComponent(loader) {
props: ['error'],
setup(props) {
console.error('[AsyncComponent] Rendering error component:', props.error)
const t = (key) => {
try {
return i18n.global.t(key)
@@ -100,9 +130,9 @@ export function createAsyncComponent(loader) {
timeout: 15000,
// 错误重试逻辑
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
console.warn(`[AsyncComponent] Loading failed, retrying (${attempts}/3)...`, error)
setTimeout(() => retry(), 1000)
if (attempts <= 2) {
console.warn(`[AsyncComponent] Loading failed, retrying (${attempts}/2)...`, error)
setTimeout(() => retry(), 500)
} else {
fail()
}