mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-17 14:07:35 +08:00
Merge pull request #419 from PKC278/v2
This commit is contained in:
100
index.html
100
index.html
@@ -193,6 +193,35 @@
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
/* 超时通知样式 */
|
||||
#loading-timeout {
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#timeout-btn {
|
||||
color: var(--initial-loader-color, #9155FD);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
margin-inline-start: 8px;
|
||||
border-bottom: 1px solid var(--initial-loader-color, #9155FD);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -210,7 +239,7 @@
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
@@ -243,6 +272,73 @@
|
||||
updateSafeArea()
|
||||
window.addEventListener('resize', updateSafeArea)
|
||||
window.addEventListener('orientationchange', updateSafeArea)
|
||||
|
||||
// 清除缓存处理逻辑
|
||||
window.clearAndReload = async function() {
|
||||
try {
|
||||
// 1. 清除所有缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log('[VersionChecker] 已清除所有缓存')
|
||||
}
|
||||
// 2. 注销 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map(registration => registration.unregister()))
|
||||
console.log('[VersionChecker] 已注销所有 Service Worker')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VersionChecker] 清除缓存时出错:', e)
|
||||
} finally {
|
||||
// 3. 重载页面
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
const timeoutEl = document.getElementById('loading-timeout');
|
||||
if (timeoutEl) {
|
||||
// 适配多语言
|
||||
const lang = navigator.language || 'zh-CN';
|
||||
const messages = {
|
||||
'zh-CN': {
|
||||
text: '页面加载似乎遇到了阻碍,请尝试',
|
||||
btn: '清除缓存'
|
||||
},
|
||||
'zh-TW': {
|
||||
text: '頁面載入似乎遇到了阻礙,請嘗試',
|
||||
btn: '清除快取'
|
||||
},
|
||||
'en-US': {
|
||||
text: 'Page loading seems to be blocked, please try',
|
||||
btn: 'Clear Cache'
|
||||
}
|
||||
};
|
||||
|
||||
// 默认匹配前缀,如 en-GB 匹配 en-US 的逻辑
|
||||
let msg = messages['zh-CN'];
|
||||
if (lang.startsWith('zh-TW') || lang.startsWith('zh-HK')) {
|
||||
msg = messages['zh-TW'];
|
||||
} else if (lang.startsWith('en')) {
|
||||
msg = messages['en-US'];
|
||||
}
|
||||
|
||||
const textNode = document.createTextNode(msg.text + ' ');
|
||||
const btnLink = document.createElement('a');
|
||||
btnLink.href = 'javascript:void(0)';
|
||||
btnLink.id = 'timeout-btn';
|
||||
btnLink.onclick = window.clearAndReload;
|
||||
btnLink.textContent = msg.btn;
|
||||
|
||||
timeoutEl.innerHTML = '';
|
||||
timeoutEl.appendChild(textNode);
|
||||
timeoutEl.appendChild(btnLink);
|
||||
timeoutEl.style.display = 'block';
|
||||
}
|
||||
}, 15000); // 15秒后显示超时提示
|
||||
</script>
|
||||
</head>
|
||||
|
||||
@@ -257,6 +353,8 @@
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
10
src/App.vue
10
src/App.vue
@@ -15,7 +15,7 @@ import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
@@ -237,6 +237,14 @@ async function loadBackgroundImages(retryCount = 0) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 移除URL中的时间戳参数
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.has('_t')) {
|
||||
url.searchParams.delete('_t')
|
||||
const newUrl = url.pathname + url.search + url.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCachesAndServiceWorker } from '@/composables/useVersionChecker'
|
||||
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -122,10 +120,10 @@ function releaseTime(releaseDate: string) {
|
||||
}
|
||||
|
||||
// 强制清除缓存
|
||||
async function cleanCache() {
|
||||
async function clearCache() {
|
||||
await clearCachesAndServiceWorker()
|
||||
// 刷新页面
|
||||
window.location.reload()
|
||||
// 刷新页面,添加时间戳参数以强制更新
|
||||
reloadWithTimestamp()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -193,12 +191,12 @@ onMounted(() => {
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
class="ms-2"
|
||||
@click="cleanCache"
|
||||
@click="clearCache"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" size="14" />
|
||||
</template>
|
||||
{{ t('setting.about.cleanCache') }}
|
||||
{{ t('setting.about.clearCache') }}
|
||||
</VBtn>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
@@ -122,7 +122,7 @@ function loadRecentSearches() {
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
getNavMenus().forEach(
|
||||
getNavMenus(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
@@ -134,7 +134,7 @@ function getMenus(): NavMenu[] {
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
getSettingTabs().forEach(
|
||||
getSettingTabs(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="version-update-toast">
|
||||
<span class="message">{{ message }}</span>
|
||||
<button class="refresh-button" @click="handleRefresh">
|
||||
<button v-if="refreshText" class="refresh-button" @click="handleRefresh">
|
||||
{{ refreshText }}
|
||||
</button>
|
||||
<div v-else class="spinner"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +12,18 @@
|
||||
// 接收 props
|
||||
interface Props {
|
||||
message: string
|
||||
refreshText: string
|
||||
refreshText?: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload()
|
||||
if (props.onRefresh) {
|
||||
props.onRefresh()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -25,12 +31,13 @@ const handleRefresh = () => {
|
||||
.version-update-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
@@ -43,6 +50,7 @@ const handleRefresh = () => {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -55,4 +63,20 @@ const handleRefresh = () => {
|
||||
.refresh-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,17 +3,25 @@ import { useToast } from 'vue-toastification'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare const __APP_VERSION__: string
|
||||
|
||||
// 全局状态
|
||||
const currentVersion = ref(__APP_VERSION__)
|
||||
let isListenerAdded = false
|
||||
let notificationShowTime = 0
|
||||
const serverVersion = ref<string | null>(null)
|
||||
const versionChecked = ref(false)
|
||||
const needsUpdate = computed(() => {
|
||||
return serverVersion.value !== null && serverVersion.value !== currentVersion.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 刷新页面并添加时间戳
|
||||
*/
|
||||
export const reloadWithTimestamp = (): void => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.replace(url.pathname + url.search + url.hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存和 Service Worker
|
||||
*/
|
||||
@@ -56,6 +64,7 @@ export function useVersionChecker() {
|
||||
const component = h(VersionUpdateToast, {
|
||||
message: i18n.global.t('common.newVersionAvailable'),
|
||||
refreshText: i18n.global.t('common.refresh'),
|
||||
onRefresh: reloadWithTimestamp,
|
||||
})
|
||||
|
||||
toast.info(component, {
|
||||
@@ -63,6 +72,7 @@ export function useVersionChecker() {
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
toastClassName: 'version-update-toast-container',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,25 +89,79 @@ export function useVersionChecker() {
|
||||
// 更新服务端版本
|
||||
serverVersion.value = latestVersion
|
||||
|
||||
// 版本不同,且尚未显示通知
|
||||
if (needsUpdate.value) {
|
||||
versionChecked.value = true
|
||||
console.log(`[VersionChecker] 检测到版本更新: ${currentVersion.value} -> ${latestVersion}`)
|
||||
// 执行版本不一致时的处理逻辑
|
||||
const handleVersionMismatch = async () => {
|
||||
if (needsUpdate.value) {
|
||||
versionChecked.value = true
|
||||
console.log(`[VersionChecker] 检测到版本更新: ${currentVersion.value} -> ${latestVersion}`)
|
||||
|
||||
// 清除缓存和 Service Worker
|
||||
await clearCachesAndServiceWorker()
|
||||
// 清除缓存和 Service Worker
|
||||
await clearCachesAndServiceWorker()
|
||||
|
||||
// 显示持久化通知
|
||||
showUpdateNotification()
|
||||
// 显示持久化通知
|
||||
showUpdateNotification()
|
||||
}
|
||||
}
|
||||
|
||||
// 优先尝试通过 Service Worker 检查更新
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
console.log('[VersionChecker] 正在请求 Service Worker 检查更新...')
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
|
||||
// 如果已经有等待中的更新,直接处理
|
||||
if (registration?.waiting) {
|
||||
console.log('[VersionChecker] Service Worker 发现新版本,跳过版本号对比')
|
||||
handleVersionMismatch()
|
||||
return
|
||||
}
|
||||
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
messageChannel.port1.onmessage = event => {
|
||||
if (event.data && event.data.type === 'SW_NO_UPDATE_DETECTED') {
|
||||
console.log('[VersionChecker] Service Worker 报告无更新, 进行版本号检查...')
|
||||
handleVersionMismatch()
|
||||
}
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({ type: 'CHECK_SW_UPDATE' }, [messageChannel.port2])
|
||||
} else {
|
||||
// 如果没有 Service Worker 控制,直接进行版本比较
|
||||
await handleVersionMismatch()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置版本检查状态(用于测试或特殊场景)
|
||||
*/
|
||||
const resetVersionCheck = (): void => {
|
||||
versionChecked.value = false
|
||||
serverVersion.value = null
|
||||
// 监听 Service Worker 版本更新消息
|
||||
if (!isListenerAdded && 'serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
// 1. 发现新版本 -> 弹出通知
|
||||
if (event.data && event.data.type === 'SW_VERSION_DETECTED') {
|
||||
console.log('[VersionChecker] 发现新版本:', event.data.version)
|
||||
notificationShowTime = Date.now()
|
||||
|
||||
const component = h(VersionUpdateToast, {
|
||||
message: i18n.global.t('common.newVersionFound'),
|
||||
})
|
||||
|
||||
toast.info(component, {
|
||||
timeout: false,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
toastClassName: 'version-update-toast-container',
|
||||
})
|
||||
}
|
||||
// 2. 安装完成 -> 刷新页面
|
||||
else if (event.data && event.data.type === 'SW_RELOAD_PAGE') {
|
||||
const elapsed = Date.now() - notificationShowTime
|
||||
const delay = Math.max(0, 1500 - elapsed)
|
||||
console.log(`[VersionChecker] 更新已安装, 延迟 ${delay}ms 后刷新...`)
|
||||
setTimeout(() => {
|
||||
reloadWithTimestamp()
|
||||
}, delay)
|
||||
}
|
||||
})
|
||||
isListenerAdded = true
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -108,6 +172,5 @@ export function useVersionChecker() {
|
||||
versionChecked: computed(() => versionChecked.value),
|
||||
// 方法
|
||||
checkVersion,
|
||||
resetVersionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ const {
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = (header: string) => {
|
||||
// 使用国际化菜单
|
||||
const menus = getNavMenus()
|
||||
const menus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const userPermissions = computed(() => {
|
||||
|
||||
// 获取导航菜单
|
||||
const navMenus = computed(() => {
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
return filterMenusByPermission(allMenus, userPermissions.value)
|
||||
})
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ const getUIModeIcon = computed(() => {
|
||||
|
||||
// 主题相关功能
|
||||
const { name: themeName, global: globalTheme } = useTheme()
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
|
||||
const themes: ThemeSwitcherTheme[] = [
|
||||
|
||||
@@ -69,6 +69,7 @@ export default {
|
||||
preset: 'Preset',
|
||||
refresh: 'Refresh',
|
||||
newVersionAvailable: 'New version detected, please refresh the page to get the latest features',
|
||||
newVersionFound: 'New version found, updating...',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -1249,7 +1250,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
cleanCache: 'Clear Cache',
|
||||
clearCache: 'Clear Cache',
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
|
||||
@@ -69,6 +69,7 @@ export default {
|
||||
preset: '预设',
|
||||
refresh: '刷新',
|
||||
newVersionAvailable: '检测到新版本,请刷新页面以获取最新功能',
|
||||
newVersionFound: '发现新版本,正在更新...',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -1246,7 +1247,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
cleanCache: '清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
|
||||
@@ -69,6 +69,7 @@ export default {
|
||||
preset: '預設',
|
||||
refresh: '刷新',
|
||||
newVersionAvailable: '檢測到新版本,請刷新頁面以獲取最新功能',
|
||||
newVersionFound: '發現新版本,正在更新...',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -1234,7 +1235,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
cleanCache: '清除緩存',
|
||||
clearCache: '清除快取',
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
|
||||
@@ -23,7 +23,7 @@ const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function initializeColors() {
|
||||
|
||||
// 初始化发现标签
|
||||
function initDiscoverTabs() {
|
||||
const tabs = getDiscoverTabs()
|
||||
const tabs = getDiscoverTabs(t)
|
||||
for (const tab of tabs) {
|
||||
discoverTabs.value.push({
|
||||
name: tab.name,
|
||||
|
||||
@@ -21,7 +21,7 @@ const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取有权限的菜单
|
||||
const navMenus = getNavMenus()
|
||||
const navMenus = computed(() => getNavMenus(t))
|
||||
|
||||
// 表单
|
||||
const form = ref({
|
||||
@@ -229,7 +229,7 @@ async function handleLoginSuccess(response: any) {
|
||||
...userPayload.permissions,
|
||||
}
|
||||
|
||||
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
|
||||
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
|
||||
if (filteredMenus.length === 0) {
|
||||
errorMessage.value = t('login.noPermission')
|
||||
return
|
||||
|
||||
@@ -12,10 +12,11 @@ import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const settingTabs = computed(() => getSettingTabs())
|
||||
const settingTabs = computed(() => getSettingTabs(t))
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -22,9 +22,9 @@ const shareViewKey = ref(0)
|
||||
// 获取标签页
|
||||
const subscribeTabs = computed(() => {
|
||||
if (subType === '电影') {
|
||||
return getSubscribeMovieTabs()
|
||||
return getSubscribeMovieTabs(t)
|
||||
} else {
|
||||
return getSubscribeTvTabs()
|
||||
return getSubscribeTvTabs(t)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const listViewKey = ref(0)
|
||||
|
||||
// 获取标签页
|
||||
const workflowTabs = computed(() => {
|
||||
return getWorkflowTabs()
|
||||
return getWorkflowTabs(t)
|
||||
})
|
||||
|
||||
// 新增工作流对话框
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
// 构建路由菜单,每次调用时使用当前的语言环境
|
||||
export function getNavMenus() {
|
||||
const { t } = useI18n()
|
||||
export function getNavMenus(t: Composer['t']) {
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 检查是否为高级模式
|
||||
@@ -148,9 +147,7 @@ export function getNavMenus() {
|
||||
}
|
||||
|
||||
// 获取设置标签页
|
||||
export function getSettingTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSettingTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('settingTabs.system.title'),
|
||||
@@ -204,9 +201,7 @@ export function getSettingTabs() {
|
||||
}
|
||||
|
||||
// 获取电影订阅标签页
|
||||
export function getSubscribeMovieTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSubscribeMovieTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('subscribeTabs.movie.mysub'),
|
||||
@@ -222,9 +217,7 @@ export function getSubscribeMovieTabs() {
|
||||
}
|
||||
|
||||
// 获取电视剧订阅标签页
|
||||
export function getSubscribeTvTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSubscribeTvTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('subscribeTabs.tv.mysub'),
|
||||
@@ -245,9 +238,7 @@ export function getSubscribeTvTabs() {
|
||||
}
|
||||
|
||||
// 获取插件标签页
|
||||
export function getPluginTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getPluginTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('pluginTabs.installed'),
|
||||
@@ -263,9 +254,7 @@ export function getPluginTabs() {
|
||||
}
|
||||
|
||||
// 获取发现标签页
|
||||
export function getDiscoverTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getDiscoverTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
name: t('discoverTabs.themoviedb'),
|
||||
@@ -286,9 +275,7 @@ export function getDiscoverTabs() {
|
||||
}
|
||||
|
||||
// 获取工作流标签页
|
||||
export function getWorkflowTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getWorkflowTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('workflowTabs.list'),
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute, setCatchHandler } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||
import * as navigationPreload from 'workbox-navigation-preload'
|
||||
|
||||
// Service Worker 类型声明
|
||||
declare let self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<{ url: string; revision?: string }>
|
||||
readonly __WB_MANIFEST: Array<{ url: string; revision?: string }>
|
||||
}
|
||||
|
||||
// 缓存版本控制
|
||||
const CACHE_VERSION = 'v13'
|
||||
const CACHE_NAMES = {
|
||||
appShell: `app-shell-${CACHE_VERSION}`,
|
||||
static: `static-resources-${CACHE_VERSION}`,
|
||||
images: `image-cache-${CACHE_VERSION}`,
|
||||
fonts: `font-cache-${CACHE_VERSION}`,
|
||||
api: `api-cache-${CACHE_VERSION}`,
|
||||
tmdb: `tmdb-image-cache-${CACHE_VERSION}`,
|
||||
pages: `pages-cache-${CACHE_VERSION}`,
|
||||
}
|
||||
const RESOURCE_VERSION = 'V2'
|
||||
const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}` // 开发环境下无法使用此环境变量,生产环境正常
|
||||
|
||||
// 缓存大小限制
|
||||
const CACHE_SIZE_LIMITS = {
|
||||
appShell: { maxEntries: 10, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
|
||||
static: { maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
|
||||
images: { maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }, // 30天
|
||||
fonts: { maxEntries: 50, maxAgeSeconds: 365 * 24 * 60 * 60 }, // 1年
|
||||
api: { maxEntries: 500, maxAgeSeconds: 24 * 60 * 60 }, // 24小时
|
||||
tmdb: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
|
||||
pages: { maxEntries: 50, maxAgeSeconds: 7 * 24 * 60 * 60 }, // 7天
|
||||
}
|
||||
// 启用导航预载
|
||||
navigationPreload.enable()
|
||||
|
||||
// 自动清理旧的预缓存
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// 预缓存并路由
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// 变量记录是否为更新安装
|
||||
let isUpdate = false
|
||||
|
||||
// 监听安装事件以检测更新
|
||||
self.addEventListener('install', () => {
|
||||
// 强制等待中的 Service Worker 立即激活
|
||||
self.skipWaiting()
|
||||
|
||||
// 检查是否是更新(即是否已经有激活的 Service Worker)
|
||||
if (self.registration.active) {
|
||||
isUpdate = true
|
||||
// 通知客户端发现新版本
|
||||
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SW_VERSION_DETECTED',
|
||||
version: CACHE_VERSION,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 监听激活事件
|
||||
self.addEventListener('activate', event => {
|
||||
// 让 Service Worker 立即接管页面
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim()
|
||||
|
||||
// 清理旧版本的运行时缓存
|
||||
await cleanupRuntimeCaches(true)
|
||||
|
||||
// 如果是更新,则通知客户端刷新页面
|
||||
if (isUpdate) {
|
||||
const clients = await self.clients.matchAll({ type: 'window' })
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SW_RELOAD_PAGE',
|
||||
})
|
||||
})
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
// 通知选项
|
||||
const options = {
|
||||
@@ -38,100 +79,226 @@ const options = {
|
||||
// 存储未读消息数量的键名
|
||||
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
||||
|
||||
// 从IndexedDB获取未读消息数量
|
||||
async function getStoredUnreadCount(): Promise<number> {
|
||||
try {
|
||||
const count = await get(UNREAD_COUNT_KEY)
|
||||
return count || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to get stored unread count:', error)
|
||||
return 0
|
||||
// --- 缓存策略配置 ---
|
||||
|
||||
// 导航请求与 App Shell - 优先网络
|
||||
registerRoute(
|
||||
({ request, url }) => request.mode === 'navigate' || url.pathname === '/' || url.pathname === '/index.html',
|
||||
new NetworkFirst({
|
||||
cacheName: `app-shell-${CACHE_VERSION}`,
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// 静态资源 (JS, CSS, HTML) - 优先缓存
|
||||
registerRoute(
|
||||
({ request }) => ['style', 'script', 'worker'].includes(request.destination),
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: `static-resources-${CACHE_VERSION}`,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// 图片资源 - 优先缓存
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'image',
|
||||
new CacheFirst({
|
||||
cacheName: `image-cache-${RESOURCE_VERSION}`,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// 字体资源 - 优先缓存
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'font',
|
||||
new CacheFirst({
|
||||
cacheName: `font-cache-${RESOURCE_VERSION}`,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// TMDB 图片 - 优先缓存
|
||||
registerRoute(
|
||||
({ url }) => url.hostname === 'image.tmdb.org',
|
||||
new CacheFirst({
|
||||
cacheName: `tmdb-image-cache-${RESOURCE_VERSION}`,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 300,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// API GET 请求 - 优先网络
|
||||
registerRoute(
|
||||
({ url, request }) =>
|
||||
url.pathname.includes('/api/v1/') &&
|
||||
request.method === 'GET' &&
|
||||
!url.pathname.includes('/api/v1/system/message') && // 排除 SSE 长连接
|
||||
!url.pathname.includes('/api/v1/common/message') && // 排除通用消息
|
||||
!url.pathname.includes('/api/v1/message/') && // 排除所有消息类接口
|
||||
!url.pathname.includes('/api/v1/system/global'), // 排除global接口
|
||||
new NetworkFirst({
|
||||
cacheName: `api-cache-${CACHE_VERSION}`,
|
||||
networkTimeoutSeconds: 5,
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24小时
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// 设置默认离线页面
|
||||
setCatchHandler(async ({ request }) => {
|
||||
if (request?.destination === 'document') {
|
||||
return (await caches.match('/offline.html')) || Response.error()
|
||||
}
|
||||
return Response.error()
|
||||
})
|
||||
|
||||
// --- 辅助函数 (通知与徽章) ---
|
||||
|
||||
// 清理运行时缓存
|
||||
async function cleanupRuntimeCaches(onlyOld: boolean = false) {
|
||||
const cacheNames = await caches.keys()
|
||||
const runtimeCachePrefixes = [
|
||||
'app-shell',
|
||||
'static-resources',
|
||||
'image-cache',
|
||||
'font-cache',
|
||||
'api-cache',
|
||||
'tmdb-image-cache',
|
||||
]
|
||||
|
||||
// 当前版本的缓存全名
|
||||
const currentCacheNames = [
|
||||
`app-shell-${CACHE_VERSION}`,
|
||||
`static-resources-${CACHE_VERSION}`,
|
||||
`image-cache-${RESOURCE_VERSION}`,
|
||||
`font-cache-${RESOURCE_VERSION}`,
|
||||
`tmdb-image-cache-${RESOURCE_VERSION}`,
|
||||
`api-cache-${CACHE_VERSION}`,
|
||||
]
|
||||
|
||||
await Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
const isRuntimeCache = runtimeCachePrefixes.some(prefix => cacheName.startsWith(prefix))
|
||||
if (isRuntimeCache) {
|
||||
if (!onlyOld || !currentCacheNames.includes(cacheName)) {
|
||||
console.log('[SW] Deleting runtime cache:', cacheName)
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 保存未读消息数量到IndexedDB
|
||||
async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
try {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
} catch (error) {
|
||||
console.error('Failed to set stored unread count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的IndexedDB包装器
|
||||
// 简单的 IndexedDB 包装器 (用于未读计数)
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
// Bump the version to add the new "sync" store while keeping existing data intact
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('mp_badge_db', 2)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = event => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
// Badge store (existing)
|
||||
if (!db.objectStoreNames.contains('badge')) {
|
||||
db.createObjectStore('badge')
|
||||
}
|
||||
|
||||
// Dedicated store for offline-sync items
|
||||
if (!db.objectStoreNames.contains('sync')) {
|
||||
db.createObjectStore('sync')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取IndexedDB中的数据
|
||||
async function get(key: string, storeName: string = 'badge'): Promise<any> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([storeName], 'readonly')
|
||||
const store = tx.objectStore(storeName)
|
||||
const request = store.get(key)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const tx = db.transaction([storeName], 'readonly')
|
||||
const store = tx.objectStore(storeName)
|
||||
const request = store.get(key)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 保存数据到IndexedDB
|
||||
async function set(key: string, value: any, storeName: string = 'badge'): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([storeName], 'readwrite')
|
||||
const store = tx.objectStore(storeName)
|
||||
|
||||
store.put(value, key)
|
||||
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
console.warn(`Store ${storeName} not found`)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const tx = db.transaction([storeName], 'readwrite')
|
||||
const store = tx.objectStore(storeName)
|
||||
store.put(value, key)
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(`[SW] Failed to set IndexedDB key "${key}" in store "${storeName}":`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除IndexedDB中的数据(确保事务完成)
|
||||
async function del(key: string, storeName: string = 'badge'): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction([storeName], 'readwrite')
|
||||
const store = tx.objectStore(storeName)
|
||||
|
||||
store.delete(key)
|
||||
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
async function getStoredUnreadCount(): Promise<number> {
|
||||
const count = await get(UNREAD_COUNT_KEY)
|
||||
return typeof count === 'number' ? count : 0
|
||||
}
|
||||
|
||||
async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
}
|
||||
|
||||
// 更新桌面图标徽章
|
||||
async function updateBadge(count: number) {
|
||||
if ('setAppBadge' in navigator) {
|
||||
if ('setAppBadge' in self.navigator) {
|
||||
try {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge!(count)
|
||||
await self.navigator.setAppBadge(count)
|
||||
} else {
|
||||
await navigator.clearAppBadge!()
|
||||
await self.navigator.clearAppBadge()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
@@ -139,11 +306,10 @@ async function updateBadge(count: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 清除桌面图标徽章
|
||||
async function clearBadge() {
|
||||
if ('clearAppBadge' in navigator) {
|
||||
if ('clearAppBadge' in self.navigator) {
|
||||
try {
|
||||
await navigator.clearAppBadge!()
|
||||
await self.navigator.clearAppBadge()
|
||||
await setStoredUnreadCount(0)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear app badge:', error)
|
||||
@@ -151,352 +317,91 @@ async function clearBadge() {
|
||||
}
|
||||
}
|
||||
|
||||
// 清理旧版本缓存
|
||||
async function deleteOldCaches() {
|
||||
const cacheWhitelist = Object.values(CACHE_NAMES)
|
||||
const cacheNames = await caches.keys()
|
||||
|
||||
await Promise.all(
|
||||
cacheNames.map(async cacheName => {
|
||||
if (!cacheWhitelist.includes(cacheName)) {
|
||||
console.log('Deleting old cache:', cacheName)
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 获取缓存大小
|
||||
async function getCacheSize(cacheName: string): Promise<number> {
|
||||
if (!('estimate' in navigator.storage)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
try {
|
||||
const cache = await caches.open(cacheName)
|
||||
const keys = await cache.keys()
|
||||
let totalSize = 0
|
||||
|
||||
for (const request of keys) {
|
||||
const response = await cache.match(request)
|
||||
if (response) {
|
||||
const blob = await response.blob()
|
||||
totalSize += blob.size
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize
|
||||
} catch (error) {
|
||||
console.error('Failed to get cache size:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 监控缓存大小
|
||||
async function monitorCacheSize() {
|
||||
const cacheSizes: Record<string, number> = {}
|
||||
let totalSize = 0
|
||||
let calculatedTotalSize = 0
|
||||
|
||||
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
|
||||
const size = await getCacheSize(cacheName)
|
||||
cacheSizes[key] = size
|
||||
totalSize += size
|
||||
}
|
||||
try {
|
||||
const cacheNames = await caches.keys()
|
||||
|
||||
// 发送缓存统计信息给客户端
|
||||
const clients = await self.clients.matchAll()
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'CACHE_SIZE_UPDATE',
|
||||
data: {
|
||||
cacheSizes,
|
||||
totalSize,
|
||||
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
|
||||
},
|
||||
})
|
||||
})
|
||||
// 并行处理所有缓存
|
||||
await Promise.all(
|
||||
cacheNames.map(async cacheName => {
|
||||
const cache = await caches.open(cacheName)
|
||||
const requests = await cache.keys()
|
||||
let cacheSize = 0
|
||||
|
||||
return { cacheSizes, totalSize }
|
||||
}
|
||||
|
||||
// 清理过期缓存条目
|
||||
async function cleanupExpiredCaches() {
|
||||
for (const [key, cacheName] of Object.entries(CACHE_NAMES)) {
|
||||
const limit = CACHE_SIZE_LIMITS[key as keyof typeof CACHE_SIZE_LIMITS]
|
||||
if (!limit) continue
|
||||
|
||||
try {
|
||||
const cache = await caches.open(cacheName)
|
||||
const keys = await cache.keys()
|
||||
|
||||
// 如果缓存条目超过限制,删除最老的条目
|
||||
if (keys.length > limit.maxEntries) {
|
||||
const deleteCount = keys.length - limit.maxEntries
|
||||
console.log(`Cleaning up ${deleteCount} entries from ${cacheName}`)
|
||||
|
||||
// 删除最老的条目(假设数组开头是最老的)
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
await cache.delete(keys[i])
|
||||
// 遍历请求以获取响应头部,避免 matchAll 一次性加载大量响应对象到内存
|
||||
for (const request of requests) {
|
||||
const response = await cache.match(request)
|
||||
if (response) {
|
||||
const contentLength = response.headers.get('content-length')
|
||||
if (contentLength) {
|
||||
cacheSize += parseInt(contentLength, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to cleanup cache ${cacheName}:`, error)
|
||||
cacheSizes[cacheName] = cacheSize
|
||||
}),
|
||||
)
|
||||
|
||||
calculatedTotalSize = Object.values(cacheSizes).reduce((acc, size) => acc + size, 0)
|
||||
|
||||
// 获取系统级存储估算
|
||||
let quota = 0
|
||||
let usage = 0
|
||||
if (self.navigator.storage && self.navigator.storage.estimate) {
|
||||
const estimate = await self.navigator.storage.estimate()
|
||||
quota = estimate.quota || 0
|
||||
usage = estimate.usage || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 安装事件
|
||||
self.addEventListener('install', () => {
|
||||
// 强制等待中的Service Worker立即成为活动的Service Worker
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// 激活事件
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 启用导航预载功能以提高性能
|
||||
if ('navigationPreload' in self.registration) {
|
||||
await self.registration.navigationPreload.enable()
|
||||
}
|
||||
|
||||
// 清理旧版本的缓存
|
||||
await deleteOldCaches()
|
||||
|
||||
// 清理过期的缓存条目
|
||||
await cleanupExpiredCaches()
|
||||
|
||||
// 监控缓存大小
|
||||
await monitorCacheSize()
|
||||
})(),
|
||||
)
|
||||
// 告诉活动的Service Worker立即控制页面
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// 处理API请求,当离线时发送消息到客户端
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// 处理API请求
|
||||
if (event.request.url.includes('/api/v1/')) {
|
||||
// GET请求:尝试从缓存返回
|
||||
if (event.request.method === 'GET') {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// 尝试网络请求
|
||||
const networkResponse = await fetch(event.request)
|
||||
return networkResponse
|
||||
} catch (error) {
|
||||
// 网络错误时,通知客户端当前处于离线状态
|
||||
if (self.clients) {
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'OFFLINE_STATUS',
|
||||
offline: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 尝试返回缓存的响应
|
||||
const cache = await caches.open(CACHE_NAMES.api)
|
||||
const cachedResponse = await cache.match(event.request)
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
|
||||
// 如果没有缓存,抛出错误
|
||||
throw error
|
||||
}
|
||||
})(),
|
||||
)
|
||||
// 构造结果:满足 useCacheManager.ts 的需求
|
||||
const result = {
|
||||
cacheSizes,
|
||||
// 优先使用准确的 usage (真实磁盘占用),如果不可用则退回到计算值
|
||||
totalSize: usage || calculatedTotalSize,
|
||||
totalSizeMB: ((usage || calculatedTotalSize) / 1024 / 1024).toFixed(2),
|
||||
// 额外信息保留,供未来扩展
|
||||
quota,
|
||||
usage,
|
||||
quotaMB: (quota / 1024 / 1024).toFixed(2),
|
||||
usageMB: (usage / 1024 / 1024).toFixed(2),
|
||||
calculatedTotalSize,
|
||||
}
|
||||
// POST/PUT/DELETE请求:离线时加入同步队列
|
||||
else if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(event.request.method)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// 尝试网络请求
|
||||
const networkResponse = await fetch(event.request)
|
||||
return networkResponse
|
||||
} catch (error) {
|
||||
// 网络错误时,加入同步队列
|
||||
await addToSyncQueue(event.request)
|
||||
|
||||
// 通知客户端请求已加入队列
|
||||
if (self.clients) {
|
||||
self.clients.matchAll().then(clients => {
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'REQUEST_QUEUED',
|
||||
url: event.request.url,
|
||||
method: event.request.method,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 返回一个假的成功响应
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
queued: true,
|
||||
message: '请求已加入离线队列,将在网络恢复后自动同步',
|
||||
}),
|
||||
{
|
||||
status: 202,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}
|
||||
})(),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// 后台同步队列
|
||||
const syncQueue: Array<{
|
||||
id: string
|
||||
url: string
|
||||
method: string
|
||||
data?: any
|
||||
timestamp: number
|
||||
}> = []
|
||||
|
||||
// 添加请求到同步队列
|
||||
async function addToSyncQueue(request: Request) {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const url = request.url
|
||||
const method = request.method
|
||||
|
||||
let data: any = null
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
try {
|
||||
data = await request.clone().text()
|
||||
} catch (e) {
|
||||
console.error('Failed to read request body:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const syncItem = {
|
||||
id,
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// 保存到IndexedDB (使用专用的 "sync" store)
|
||||
await set(id, syncItem, 'sync')
|
||||
syncQueue.push(syncItem)
|
||||
|
||||
// 注册后台同步
|
||||
if ('sync' in self.registration) {
|
||||
await self.registration.sync.register('sync-data')
|
||||
}
|
||||
}
|
||||
|
||||
// 执行同步队列中的请求
|
||||
async function processSyncQueue() {
|
||||
const db = await openDB()
|
||||
|
||||
// 先用只读事务获取所有同步项
|
||||
const items: Array<any> = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(['sync'], 'readonly')
|
||||
const store = tx.objectStore('sync')
|
||||
const req = store.getAll()
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
|
||||
// 收集需要删除的项目ID
|
||||
const itemsToDelete: string[] = []
|
||||
const itemsToDeleteExpired: string[] = []
|
||||
|
||||
for (const syncItem of items) {
|
||||
const key = syncItem.id
|
||||
try {
|
||||
// 构建请求
|
||||
const init: RequestInit = {
|
||||
method: syncItem.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (syncItem.data) {
|
||||
init.body = syncItem.data
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(syncItem.url, init)
|
||||
|
||||
if (response.ok) {
|
||||
// 成功后标记为需要删除
|
||||
itemsToDelete.push(key)
|
||||
|
||||
// 通知客户端同步成功
|
||||
const clients = await self.clients.matchAll()
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'SYNC_SUCCESS',
|
||||
syncId: syncItem.id,
|
||||
url: syncItem.url,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed for item:', key, error)
|
||||
|
||||
// 如果该同步项已存在超过 24 小时,则标记为需要删除
|
||||
if (Date.now() - syncItem.timestamp > 24 * 60 * 60 * 1000) {
|
||||
itemsToDeleteExpired.push(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除所有成功处理的项目和过期项目
|
||||
const allItemsToDelete = [...itemsToDelete, ...itemsToDeleteExpired]
|
||||
if (allItemsToDelete.length > 0) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(['sync'], 'readwrite')
|
||||
const store = tx.objectStore('sync')
|
||||
|
||||
// 批量删除所有标记的项目
|
||||
allItemsToDelete.forEach(id => {
|
||||
store.delete(id)
|
||||
// 发送缓存统计信息给客户端
|
||||
const clients = await self.clients.matchAll()
|
||||
clients.forEach(client => {
|
||||
client.postMessage({
|
||||
type: 'CACHE_SIZE_UPDATE',
|
||||
data: result,
|
||||
})
|
||||
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to monitor cache size:', error)
|
||||
return {
|
||||
cacheSizes: {},
|
||||
totalSize: 0,
|
||||
totalSizeMB: '0.00',
|
||||
quota: 0,
|
||||
usage: 0,
|
||||
quotaMB: '0.00',
|
||||
usageMB: '0.00',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 Workbox
|
||||
cleanupOutdatedCaches()
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
// --- 事件监听 ---
|
||||
|
||||
// 监听 sync 事件,处理后台同步
|
||||
self.addEventListener('sync', (event: SyncEvent) => {
|
||||
if (event.tag === 'sync-data') {
|
||||
event.waitUntil(processSyncQueue())
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 push 事件,显示通知
|
||||
// 监听 push 事件
|
||||
self.addEventListener('push', function (event) {
|
||||
if (!event.data) {
|
||||
return
|
||||
}
|
||||
// 解析获取推送消息
|
||||
let payload
|
||||
try {
|
||||
payload = event.data?.json()
|
||||
@@ -505,7 +410,7 @@ self.addEventListener('push', function (event) {
|
||||
title: event.data?.text(),
|
||||
}
|
||||
}
|
||||
// 根据推送消息生成桌面通知并展现出来
|
||||
|
||||
try {
|
||||
const content = {
|
||||
body: payload.body || '',
|
||||
@@ -515,7 +420,6 @@ self.addEventListener('push', function (event) {
|
||||
actions: options.actions,
|
||||
}
|
||||
|
||||
// 增加未读消息计数并持久化存储
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
@@ -525,11 +429,11 @@ self.addEventListener('push', function (event) {
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
// 静默处理错误
|
||||
// 忽略错误
|
||||
}
|
||||
})
|
||||
|
||||
// 监听通知点击事件
|
||||
// 监听通知点击
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
const info = event.notification
|
||||
if (event.action === 'close') {
|
||||
@@ -539,10 +443,9 @@ self.addEventListener('notificationclick', function (event) {
|
||||
}
|
||||
})
|
||||
|
||||
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
|
||||
// 监听消息
|
||||
self.addEventListener('message', function (event) {
|
||||
if (event.data && event.data.type === 'CLEAR_BADGE') {
|
||||
// 清除徽章
|
||||
clearBadge()
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
@@ -551,7 +454,6 @@ self.addEventListener('message', function (event) {
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
|
||||
// 更新徽章数量
|
||||
const count = event.data.count || 0
|
||||
setStoredUnreadCount(count)
|
||||
.then(() => updateBadge(count))
|
||||
@@ -562,25 +464,27 @@ self.addEventListener('message', function (event) {
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
|
||||
// 获取未读消息数量
|
||||
getStoredUnreadCount()
|
||||
.then(count => {
|
||||
event.ports[0]?.postMessage({ count })
|
||||
})
|
||||
.catch(error => {
|
||||
.catch(() => {
|
||||
event.ports[0]?.postMessage({ count: 0 })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'CLEANUP_CACHES') {
|
||||
// 手动触发缓存清理
|
||||
Promise.all([deleteOldCaches(), cleanupExpiredCaches(), monitorCacheSize()])
|
||||
.then(([, , cacheInfo]) => {
|
||||
// 手动清理: 清理所有运行时缓存
|
||||
const performCleanup = async () => {
|
||||
await cleanupRuntimeCaches(false)
|
||||
return await monitorCacheSize()
|
||||
}
|
||||
performCleanup()
|
||||
.then(cacheInfo => {
|
||||
event.ports[0]?.postMessage({ success: true, cacheInfo })
|
||||
})
|
||||
.catch(error => {
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_CACHE_INFO') {
|
||||
// 获取缓存信息
|
||||
monitorCacheSize()
|
||||
.then(cacheInfo => {
|
||||
event.ports[0]?.postMessage({ success: true, cacheInfo })
|
||||
@@ -588,5 +492,21 @@ self.addEventListener('message', function (event) {
|
||||
.catch(error => {
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'CHECK_SW_UPDATE') {
|
||||
// 检查 Service Worker 更新
|
||||
self.registration
|
||||
.update()
|
||||
.then(() => {
|
||||
// 如果没有正在安装或等待的 worker,说明没有检测到更新
|
||||
if (!self.registration.installing && !self.registration.waiting) {
|
||||
event.ports[0]?.postMessage({ type: 'SW_NO_UPDATE_DETECTED' })
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to check for SW update:', error)
|
||||
event.ports[0]?.postMessage({ type: 'SW_NO_UPDATE_DETECTED' })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,3 +11,36 @@
|
||||
@import 'vue-toastification/dist/index.css';
|
||||
@import 'vue3-perfect-scrollbar/style.css';
|
||||
@import '@vue-js-cron/vuetify/dist/vuetify.css';
|
||||
|
||||
/* 版本更新通知专用样式 */
|
||||
.version-update-toast-container {
|
||||
min-width: unset !important;
|
||||
width: fit-content !important;
|
||||
|
||||
// 移动端适配:强制靠右并修正位置
|
||||
@media only screen and (width <= 600px) {
|
||||
max-width: calc(100vw - 1rem) !important;
|
||||
margin-inline: 0 0.5rem !important;
|
||||
border-radius: 8px !important;
|
||||
position: relative !important;
|
||||
top: calc(100vh - 12rem) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 :has 选择器精准控制包含更新通知的容器
|
||||
.Vue-Toastification__container:has(.version-update-toast-container) {
|
||||
@media only screen and (width <= 600px) {
|
||||
top: auto !important;
|
||||
bottom: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-end !important;
|
||||
padding-block-end: 4.5rem !important;
|
||||
pointer-events: none;
|
||||
|
||||
.version-update-toast-container {
|
||||
pointer-events: auto;
|
||||
margin-inline-end: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
src/types/global.d.ts
vendored
3
src/types/global.d.ts
vendored
@@ -1,5 +1,8 @@
|
||||
// PWA Badge API 类型定义
|
||||
declare global {
|
||||
const __APP_VERSION__: string
|
||||
const __BUILD_TIME__: string
|
||||
|
||||
interface Navigator {
|
||||
/**
|
||||
* 设置应用徽章数量
|
||||
|
||||
@@ -32,7 +32,7 @@ const { appMode } = usePWA()
|
||||
const activeTab = ref('installed')
|
||||
|
||||
// 获取插件标签页
|
||||
const pluginTabs = computed(() => getPluginTabs())
|
||||
const pluginTabs = computed(() => getPluginTabs(t))
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -45,7 +45,7 @@ const templateTypes = ref([
|
||||
|
||||
// 编辑器主题
|
||||
const { name: themeName, global: globalTheme } = useTheme()
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? themeName)
|
||||
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
|
||||
const currentThemeName = ref(savedTheme.value)
|
||||
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
|
||||
|
||||
|
||||
106
vite.config.ts
106
vite.config.ts
@@ -14,6 +14,7 @@ import { readFileSync } from 'node:fs'
|
||||
|
||||
// 读取 package.json 获取版本号
|
||||
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
|
||||
const buildTime = new Date().getTime().toString()
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -56,104 +57,10 @@ export default defineConfig({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'service-worker.ts',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
||||
// 确保关键资源被预缓存
|
||||
additionalManifestEntries: [
|
||||
{
|
||||
url: '/offline.html',
|
||||
revision: null,
|
||||
},
|
||||
// 预缓存App Shell关键资源
|
||||
{
|
||||
url: '/logo.png',
|
||||
revision: null,
|
||||
},
|
||||
],
|
||||
// 启用导航预加载
|
||||
navigationPreload: true,
|
||||
runtimeCaching: [
|
||||
// App Shell缓存 - 优先缓存
|
||||
{
|
||||
urlPattern: /^\/$|\/index\.html$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'app-shell-cache',
|
||||
expiration: {
|
||||
maxEntries: 10,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:js|css|html)$/,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-resources',
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|ico|webp|avif|gif|bmp|tiff)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'image-cache',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30天
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:woff|woff2|ttf|otf|eot)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'font-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1年
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/v1\/.*$/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 10,
|
||||
expiration: {
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 24小时
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/image\.tmdb\.org\/.*$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'tmdb-image-cache',
|
||||
expiration: {
|
||||
maxEntries: 300,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 7天
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'document',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'pages-cache',
|
||||
},
|
||||
},
|
||||
],
|
||||
navigateFallback: '/offline.html',
|
||||
navigateFallbackDenylist: [/.*\/api\/.*/, /\/offline\.html$/],
|
||||
ignoreURLParametersMatching: [/^utm_/, /^fbclid$/, /^gclid$/],
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
},
|
||||
injectManifest: {
|
||||
rollupFormat: 'iife',
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,webp,woff,woff2,ttf,otf,eot}'],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
@@ -283,7 +190,8 @@ export default defineConfig({
|
||||
],
|
||||
define: {
|
||||
'process.env': {},
|
||||
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`)
|
||||
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
|
||||
'__BUILD_TIME__': JSON.stringify(buildTime),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -307,12 +215,6 @@ export default defineConfig({
|
||||
},
|
||||
chunkSizeWarningLimit: 5000,
|
||||
cssCodeSplit: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name].js',
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['vuetify'],
|
||||
|
||||
Reference in New Issue
Block a user