feat: 离线状态体验增强
This commit is contained in:
1 parent
face59cdd7
commit
247d25b213
4 files changed
+50
-52
No files matched your search
@@ -91,7 +91,9 @@ jobs:
|
||||
OAUTH_GOOGLE_REDIRECT_URI: ${{ secrets.OAUTH_GOOGLE_REDIRECT_URI }}
|
||||
|
||||
run: |
|
||||
SECRETS="OAUTH_ALLOWED_USERS
|
||||
SECRETS="
|
||||
OAUTH_ALLOW_ALL
|
||||
OAUTH_ALLOWED_USERS
|
||||
ENCRYPTION_KEY
|
||||
JWT_SECRET"
|
||||
|
||||
@@ -125,6 +127,7 @@ jobs:
|
||||
command: deploy --minify
|
||||
secrets: ${{ steps.secret_list.outputs.secrets }}
|
||||
env:
|
||||
OAUTH_ALLOW_ALL: ${{ secrets.OAUTH_ALLOW_ALL || 'false' }}
|
||||
OAUTH_ALLOWED_USERS: ${{ secrets.OAUTH_ALLOWED_USERS }}
|
||||
OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }}
|
||||
OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } 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 { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
@@ -12,18 +13,32 @@ const checkMobile = () => {
|
||||
layoutStore.isMobile = window.innerWidth < 768
|
||||
}
|
||||
|
||||
const isOffline = ref(false)
|
||||
const setOnline = () => { isOffline.value = false }
|
||||
const setOffline = () => { isOffline.value = true }
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
isOffline.value = !navigator.onLine
|
||||
window.addEventListener('resize', checkMobile)
|
||||
window.addEventListener('online', setOnline)
|
||||
window.addEventListener('offline', setOffline)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
window.removeEventListener('online', setOnline)
|
||||
window.removeEventListener('offline', setOffline)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 方案A: 全局离线横幅 -->
|
||||
<div v-if="isOffline" class="global-offline-banner" style="background-color: #fdf6ec; color: #e6a23c; padding: 10px 15px; text-align: center; font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 6px; border-bottom: 1px solid #faecd8; position: sticky; top: 0; z-index: 9999; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
|
||||
<el-icon><Warning /></el-icon> <span>网络已断开,当前应用正处于离线缓存模式</span>
|
||||
</div>
|
||||
|
||||
<!-- 登录页通常不显示头部,可以通过路由 meta 控制,这里简单示例默认显示 -->
|
||||
<TheHeader v-if="!route.meta.hideHeader" />
|
||||
|
||||
|
||||
@@ -16,12 +16,19 @@
|
||||
<!-- 3. 数据列表 (已解锁) -->
|
||||
<div v-else class="vault-content">
|
||||
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="🔍 搜索服务名称、账号或分类..."
|
||||
clearable
|
||||
style="max-width: 400px; flex: 1;"
|
||||
/>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex: 1;">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索服务名称、账号或分类..."
|
||||
clearable
|
||||
style="max-width: 400px;"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon v-if="isFetching && searchQuery" class="is-loading"><Loading /></el-icon>
|
||||
<el-icon v-else><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions" style="display: flex; align-items: center; gap: 10px;">
|
||||
<template v-if="selectedIds.length > 0">
|
||||
@@ -165,7 +172,7 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted, watch, computed, defineAsyncComponent, nextTick, triggerRef } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { MoreFilled, Edit, Delete, Picture, View, Hide, CopyDocument, Loading } from '@element-plus/icons-vue'
|
||||
import { MoreFilled, Edit, Delete, Picture, View, Hide, CopyDocument, Loading, Search } from '@element-plus/icons-vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useLayoutStore } from '@/shared/stores/layoutStore'
|
||||
@@ -199,7 +206,6 @@ const showSecret = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
|
||||
let globalTimer = null
|
||||
let loadingMessageHandle = null
|
||||
|
||||
// --- 定时更新 (提升定义顺序) ---
|
||||
// 更新倒计时与验证码
|
||||
@@ -251,54 +257,20 @@ const updateVaultStatus = async (targetList) => {
|
||||
// --- 工具函数 ---
|
||||
let searchTimer = null
|
||||
|
||||
// --- Loading 提示控制 ---
|
||||
const showTopLoading = () => {
|
||||
// 仅在已解锁状态下显示 Loading
|
||||
if (!vaultStore.isUnlocked) return
|
||||
// 防抖:如果已经在显示或正在等待显示,则不重复触发
|
||||
if (loadingMessageHandle || loadingTimer) return
|
||||
|
||||
loadingMessageHandle = ElMessage({
|
||||
message: '数据正在加载...',
|
||||
icon: Loading,
|
||||
duration: 0, // 设为 0 则不会自动关闭
|
||||
type: 'info',
|
||||
grouping: true
|
||||
})
|
||||
}
|
||||
|
||||
let loadingTimer = null
|
||||
const hideTopLoading = () => {
|
||||
if (loadingTimer) clearTimeout(loadingTimer) // 清除等待中的 Loading
|
||||
loadingTimer = null
|
||||
if (loadingMessageHandle) {
|
||||
loadingMessageHandle.close()
|
||||
loadingMessageHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vue Query 集成 ---
|
||||
|
||||
// 1. 定义数据获取函数
|
||||
const fetchVaultPage = async ({ pageParam = 1 }) => {
|
||||
// 如果未解锁,直接返回空(理论上 enabled 会控制不执行,但做个兜底)
|
||||
// 如果未解锁,直接返回空
|
||||
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
|
||||
|
||||
// 如果是第一页且没有搜索,显示顶部 Loading
|
||||
if (pageParam === 1 && !searchQuery.value) showTopLoading()
|
||||
|
||||
const query = new URLSearchParams({
|
||||
const query = new URLSearchParams({
|
||||
page: pageParam,
|
||||
limit: pageSize.value,
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
search: searchQuery.value
|
||||
}).toString()
|
||||
|
||||
const data = await request(`/api/vault?${query}`)
|
||||
|
||||
// 隐藏 Loading (仅第一页)
|
||||
if (pageParam === 1) hideTopLoading()
|
||||
|
||||
return data
|
||||
return await request(`/api/vault?${query}`)
|
||||
}
|
||||
|
||||
// 2. 使用 useInfiniteQuery
|
||||
@@ -403,8 +375,7 @@ defineExpose({
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
// Vue Query 会自动处理,这里只需要处理 Loading UI
|
||||
if (searchQuery.value) showTopLoading()
|
||||
// Vue Query 会自动处理列表更新
|
||||
})
|
||||
|
||||
// 批量选择
|
||||
@@ -528,6 +499,5 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (globalTimer) clearInterval(globalTimer)
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
hideTopLoading()
|
||||
})
|
||||
</script>
|
||||
@@ -22,6 +22,16 @@ export async function request(url, options = {}) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
// --- 离线拦截逻辑 ---
|
||||
// 拦截破坏性/写操作,在无网络时提供温和提示并短路执行
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
if (!navigator.onLine && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
||||
if (!options.silent) {
|
||||
ElMessage.warning('当前处于离线模式,无法进行此操作');
|
||||
}
|
||||
throw new Error('Offline: Cannot perform mutative action');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { ...options, headers, credentials: 'include' })
|
||||
|
||||
|
||||
Reference in new issue
Block a user