Compare commits

...

46 Commits

Author SHA1 Message Date
jxxghp
01835c0ac5 Merge pull request #420 from PKC278/v2 2026-01-06 15:19:24 +08:00
PKC278
e5749bd6ef address review comments for useVersionChecker
- Simplify props passing for VersionUpdateToast
- Remove redundant removeEventListener call
2026-01-06 15:07:20 +08:00
PKC278
689e58737b feat(service-worker): 兼容旧版前端监听逻辑 2026-01-06 14:10:57 +08:00
PKC278
38da061cf1 refactor(useVersionChecker): 优化版本检查逻辑和通知机制
feat(locales): 更新多语言版本信息
style(main.scss): 移除版本更新通知样式
2026-01-06 12:00:11 +08:00
jxxghp
e79940e52e 更新 package.json 2026-01-04 09:56:31 +08:00
jxxghp
88dd6068b6 Merge pull request #419 from PKC278/v2 2026-01-04 07:04:32 +08:00
PKC278
7dd10f9c96 fix(VersionUpdateToast): 优化消息样式和移动端适配 2026-01-03 23:28:29 +08:00
PKC278
94aaf83107 fix(index): 移除多余判断 2026-01-03 22:36:25 +08:00
PKC278
e84fc5f424 Update src/service-worker.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-03 22:32:52 +08:00
PKC278
f342b08179 fix(service-worker): 优化缓存版本控制和监控缓存大小逻辑 2026-01-03 21:53:04 +08:00
PKC278
0fcad02f3b fix(VersionUpdateToast): 优化通知样式
fix(useVersionChecker): 优化处理逻辑
2026-01-03 20:15:05 +08:00
PKC278
43d2406ee9 feat(timeout): 添加多语言页面加载超时提示 2026-01-03 19:09:37 +08:00
PKC278
78e2d05730 feat(index): 添加页面加载超时提示,修改默认主题设置为跟随系统
fix(service-worker): 优化清理运行时缓存逻辑
2026-01-03 19:08:16 +08:00
PKC278
425bf808ed fix: 修复 i18n-menu 工具函数外部调用导致的运行时错误 2026-01-03 19:03:37 +08:00
PKC278
6d2916dc9f feat(pwa): 重构 Service Worker 及版本更新机制 2026-01-02 20:36:33 +08:00
jxxghp
2281e4224b Merge pull request #418 from PKC278/v2 2026-01-01 12:54:38 +08:00
PKC278
95282f9883 perf: 优化导航栏动画流畅度 2026-01-01 12:29:42 +08:00
jxxghp
b470f182c9 Merge pull request #417 from PKC278/v2 2025-12-31 22:19:27 +08:00
PKC278
0bba1068af revert(Footer): 回滚9284d48 Footer.vue 2025-12-31 22:14:13 +08:00
jxxghp
947a7d8296 更新 package.json 2025-12-30 07:01:48 +08:00
jxxghp
bd36cbf888 Merge pull request #416 from PKC278/v2 2025-12-30 07:01:22 +08:00
PKC278
d8fa47bff7 fix(aboutDialog): 修复关于页面在手机端可左右滑动的问题 2025-12-30 04:18:09 +08:00
PKC278
1132beea5e feat(aboutDialog): 添加清除缓存按钮 2025-12-30 04:01:11 +08:00
PKC278
2e3314e6c3 fix(type): 修复Axios请求类型声明 2025-12-30 03:43:20 +08:00
PKC278
daa8f857f8 fix(ui): 移除UI模式切换后自动刷新页面
fix(locales): 修改自动布局文本为更简洁的“自动”
2025-12-30 03:25:55 +08:00
PKC278
6d14271fe8 feat(aboutDialog): 添加浏览器缓存版本信息展示 2025-12-30 02:55:08 +08:00
PKC278
9284d48f67 fix(logo): 使用外链替换页面内联svg,修复safari浏览器logo显示不全问题
fix(footer): 改善底部导航栏的动画效果
2025-12-30 02:32:13 +08:00
PKC278
c5d1c5a468 fix(type): 修复类型检查错误 2025-12-30 01:47:45 +08:00
PKC278
b98512789f feat(uiMode): 添加UI模式手动切换功能 2025-12-30 01:42:43 +08:00
PKC278
6b8ed8d527 fix(vite): 消除编译警告 2025-12-30 00:54:13 +08:00
PKC278
ec4500dcef refactor(versionChecker): 重构版本检查功能并更新通知样式 2025-12-30 00:00:37 +08:00
jxxghp
288e63ce68 更新 package.json 2025-12-28 17:46:37 +08:00
jxxghp
b3885584bb Merge pull request #415 from PKC278/v2 2025-12-28 17:17:49 +08:00
PKC278
968b24be1e feat(globalSetting): 添加版本检查与通知功能 2025-12-28 16:35:16 +08:00
jxxghp
5a23c1783a 更新 UserProfileView.vue 2025-12-23 23:18:11 +08:00
jxxghp
ddeeb5a7c3 更新 UserProfileView.vue 2025-12-23 23:16:28 +08:00
jxxghp
0b9bbcc7b8 Merge pull request #414 from PKC278/v2 2025-12-23 23:03:10 +08:00
PKC278
022c8b4515 fix(icon): 更新apple-touch-icon
refactor(html): 移除不必要的预加载链接
2025-12-23 22:43:42 +08:00
jxxghp
be04991928 Merge pull request #413 from PKC278/v2 2025-12-23 17:37:30 +08:00
PKC278
34770567a5 fix(mfa): 修复双重验证漏洞 2025-12-23 15:15:41 +08:00
jxxghp
6154fc2157 Merge pull request #412 from PKC278/v2 2025-12-23 14:40:56 +08:00
PKC278
e77dcdd3d4 feat(passkey): 添加PassKey支持并优化双重验证登录逻辑 2025-12-23 13:53:55 +08:00
jxxghp
58a3532c1b 更新 package.json 2025-12-23 12:53:07 +08:00
jxxghp
116a5eeb43 Merge pull request #411 from HankunYu/v2 2025-12-23 12:52:04 +08:00
HankunYu
decd50cb40 更新Discord模块支持互动消息 2025-12-22 20:00:06 +00:00
HankunYu
355563244c 通知渠道增加Discord 2025-12-22 02:11:09 +00:00
47 changed files with 2078 additions and 911 deletions

View File

@@ -34,7 +34,7 @@
<!-- iOS Safari PWA 优化 -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon-precomposed.png" />
<link rel="apple-touch-icon-precomposed" href="/apple-touch-icon.png" />
<link rel="apple-touch-startup-image" href="/splash/apple-splash.png" />
<!-- iOS Safari 全屏模式 -->
@@ -91,10 +91,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<!-- 预加载关键资源 -->
<link rel="preload" href="/logo.png" as="image" />
<link rel="modulepreload" href="/src/main.ts" />
<style>
#app {
block-size: 100%;
@@ -197,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>
@@ -214,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'
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
@@ -247,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>
@@ -254,114 +346,15 @@
<div id="loading-bg">
<div class="loading-logo">
<!-- Logo -->
<svg width="160px" height="160px" viewBox="0 0 192 192" version="1.1" xmlns="http://www.w3.org/2000/svg"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2">
<g transform="matrix(1,0,0,1,-2606,-236)">
<g id="a2-c" transform="matrix(1,0,0,1,2606,236)">
<rect x="0" y="0" width="192" height="192" style="fill: none" />
<g transform="matrix(-0.800798,0.462341,-0.769972,-1.33363,1869.11,-896.718)">
<path
d="M2241.27,-28.175C2238.86,-28.931 2236.64,-29.181 2234.48,-29.254L2159.78,-29.286L2165.01,-11.207C2167.16,-13.121 2169.64,-13.722 2172.26,-13.808L2222.12,-13.822C2223.52,-13.824 2225,-13.701 2226.78,-13.108L2241.27,-28.175Z"
style="fill: url(#_Linear1)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2205.67,331.428L2205.67,332.25L2205.67,352.835C2205.67,354.263 2204.91,355.583 2203.67,356.298C2202.43,357.012 2200.91,357.013 2199.67,356.3L2190.78,351.174C2189.73,350.595 2188.83,350.083 2188.03,349.59L2187.45,349.257C2186.66,348.725 2185.91,348.142 2185.21,347.461C2185.08,347.331 2184.95,347.198 2184.82,347.061C2184.26,346.457 2183.75,345.778 2183.3,344.995C2182.16,343.05 2181.69,341.024 2181.68,338.948L2181.67,268.923L2209.77,274.425C2207.5,275.639 2205.68,278.3 2205.67,281.429L2205.67,331.428Z"
style="fill: url(#_Linear2)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2295.93,363.064C2295.73,363.184 2295.53,363.301 2295.32,363.414L2295.93,363.064Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2295.93,363.064C2299.48,360.882 2301.46,357.55 2301.67,352.926L2301.67,277.879L2301.67,276.775L2301.67,252.515C2301.67,251.801 2302.05,251.14 2302.67,250.783C2303.29,250.426 2304.05,250.426 2304.67,250.784L2319.81,259.54C2321.59,260.617 2322.95,262.115 2324.04,263.875C2325.03,265.551 2325.56,267.37 2325.67,269.835L2325.67,339.91C2325.18,343.645 2323.51,346.705 2320.3,348.887L2295.93,363.064ZM2299.79,360.238C2299.79,360.238 2320.03,348.464 2320.04,348.461C2323.1,346.372 2324.69,343.444 2325.17,339.877C2325.17,339.877 2325.17,269.846 2325.17,269.839C2325.06,267.482 2324.56,265.739 2323.61,264.133C2322.56,262.445 2321.26,261.005 2319.55,259.97L2304.42,251.217C2303.96,250.949 2303.39,250.948 2302.92,251.216C2302.46,251.484 2302.17,251.979 2302.17,252.515L2302.17,276.775L2302.17,277.879L2302.17,352.926C2302.17,352.933 2302.17,352.941 2302.17,352.948C2302.04,355.861 2301.23,358.279 2299.79,360.238Z"
style="fill: url(#_Linear3)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256Z"
style="fill: rgb(165, 118, 255)" />
</g>
<g transform="matrix(1,0,0,1,-2157.67,-208.423)">
<path
d="M2253.67,223.256C2255.26,223.245 2257.02,223.56 2259.11,224.557L2275.67,234.102C2276.91,234.816 2277.67,236.138 2277.67,237.568L2277.67,259.508C2277.67,260.222 2277.29,260.882 2276.67,261.239C2276.05,261.597 2275.29,261.597 2274.67,261.24L2257.52,251.353C2256.38,250.731 2255.12,250.341 2253.67,250.347C2252.26,250.339 2250.99,250.721 2249.82,251.353L2187.87,287.04C2184.23,289.147 2181.96,292.478 2181.67,297.57L2181.68,269.865C2181.85,265.167 2183.93,261.653 2187.92,259.322L2248.23,224.557C2249.69,223.796 2251.5,223.29 2253.67,223.256ZM2253.68,223.756C2251.6,223.789 2249.87,224.269 2248.47,224.996L2188.17,259.754C2184.35,261.992 2182.35,265.367 2182.18,269.874C2182.18,269.874 2182.17,292.759 2182.17,292.757C2183.25,290.047 2185.13,288.051 2187.62,286.607L2249.57,250.919C2249.58,250.917 2249.58,250.915 2249.59,250.913C2250.83,250.243 2252.17,249.839 2253.67,249.847C2255.21,249.841 2256.54,250.253 2257.76,250.914C2257.76,250.916 2257.76,250.917 2257.76,250.919L2274.92,260.807C2275.38,261.075 2275.95,261.074 2276.42,260.806C2276.88,260.538 2277.17,260.043 2277.17,259.508L2277.17,237.568C2277.17,236.317 2276.5,235.16 2275.42,234.535C2275.42,234.535 2258.88,225 2258.87,224.996C2256.87,224.049 2255.2,223.746 2253.68,223.756Z"
style="fill: url(#_Linear4)" />
</g>
<g transform="matrix(0.800798,0.462341,0.769972,-1.33363,-1677.22,-896.858)">
<path
d="M2241.55,-28.184C2239.1,-28.989 2236.83,-29.204 2234.68,-29.295C2234.68,-29.295 2220.82,-29.3 2215.03,-29.303C2213.48,-29.303 2212.05,-28.808 2211.28,-28.004C2208.65,-25.275 2202.56,-18.936 2199.45,-15.709C2199.07,-15.306 2199.07,-14.809 2199.46,-14.406C2199.85,-14.004 2200.57,-13.758 2201.34,-13.761C2208.36,-13.788 2222.72,-13.845 2222.72,-13.845C2223.98,-13.851 2225.44,-13.657 2227.06,-13.117L2241.55,-28.184Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-4.32309,0,0,12.4454,9610.35,-1450.35)">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z"
style="fill: rgb(104, 0, 197)" />
<clipPath id="_clip5">
<path
d="M2205.31,121.966C2205.31,121.88 2205.18,121.8 2204.96,121.757C2204.74,121.714 2204.48,121.714 2204.27,121.757C2201.75,122.263 2195.36,123.547 2192.85,124.052C2192.63,124.095 2192.5,124.174 2192.5,124.261C2192.5,124.347 2192.63,124.426 2192.85,124.469C2195.36,124.974 2201.75,126.255 2204.27,126.759C2204.48,126.802 2204.74,126.802 2204.96,126.759C2205.18,126.716 2205.31,126.636 2205.31,126.55C2205.31,125.541 2205.31,122.976 2205.31,121.966Z" />
</clipPath>
<g clip-path="url(#_clip5)">
<g transform="matrix(0.124502,0.074907,0.206623,-0.0414384,1997.62,-7.40235)">
<path
d="M1726.17,-64.249L1708.16,-72.303L1708.05,-23.514L1721.88,-32.386C1722.96,-33.241 1723.09,-33.944 1723.15,-34.636L1723.15,-54.373C1723.19,-56.238 1724.96,-57.594 1726.87,-56.686L1726.17,-64.249Z"
style="fill: url(#_Linear6)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1704.47,-40.254C1706.28,-40.527 1708.14,-40.212 1708.16,-39.416L1708.16,-18.976L1726.17,-18.976L1726.17,-45.661Z"
style="fill: rgb(141, 81, 249)" />
</g>
<g transform="matrix(-0.126036,0.0767377,0.569859,0.112933,2435.01,-3.09225)">
<path
d="M1726.17,-45.661L1726.17,-18.976L1708.16,-18.976L1708.16,-39.416C1707.79,-40.732 1704.5,-40.298 1702.68,-40.025L1726.17,-45.661ZM1705.49,-40.491C1706.2,-40.507 1706.87,-40.464 1707.4,-40.327C1708.01,-40.173 1708.48,-39.899 1708.62,-39.436C1708.62,-39.429 1708.62,-39.423 1708.62,-39.416L1708.62,-19.152C1708.62,-19.152 1725.72,-19.152 1725.72,-19.152L1725.72,-45.345L1705.49,-40.491Z"
style="fill: url(#_Radial7)" />
</g>
</g>
</g>
</g>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-70.0711,-0.927611,1.54482,-42.0752,2233.59,-20.1891)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(4.78193e-15,-78.0949,78.0949,4.78193e-15,2195.72,354.021)">
<stop offset="0" style="stop-color: rgb(141, 81, 249); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
</linearGradient>
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(41.6089,41.5866,-41.5866,41.6089,2282.31,262.837)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear4" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(9.25616,16.7005,-16.7005,9.25616,2215,243.712)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</linearGradient>
<linearGradient id="_Linear6" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(-0.130164,-61.9937,59.4003,-0.135847,1711.63,-25.7957)">
<stop offset="0" style="stop-color: rgb(116, 50, 223); stop-opacity: 1" />
<stop offset="0.51" style="stop-color: rgb(110, 38, 217); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(91, 0, 197); stop-opacity: 1" />
</linearGradient>
<radialGradient id="_Radial7" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="matrix(13.8659,4.71436,-12.1609,5.37534,1708.16,-32.287)">
<stop offset="0" style="stop-color: rgb(211, 187, 255); stop-opacity: 1" />
<stop offset="1" style="stop-color: rgb(211, 187, 255); stop-opacity: 0" />
</radialGradient>
</defs>
</svg>
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
</div>
<div class="loading">
<div class="effect-1 effects"></div>
<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>

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.8.8",
"version": "2.9.2",
"private": true,
"type": "module",
"bin": "dist/service.js",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 102 KiB

1
shims.d.ts vendored
View File

@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
export default component
}
declare module 'vue-shepherd';
declare module 'colorthief';

View File

@@ -1,5 +1,5 @@
@use "sass:map";
@use "vuetify/lib/styles/settings" as vuetify_settings;
@use "vuetify/lib/styles/settings/_index.sass" as vuetify_settings;
@use "@styles/variables/_vuetify.scss" as vuetify;
@mixin themed($property, $light-value, $dark-value) {

View File

@@ -35,6 +35,19 @@ export function urlBase64ToUint8Array(base64String: string) {
return outputArray
}
// Uint8Array 转 Base64URL
export function bufferToBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// Base64URL 转 Uint8Array
export function base64UrlToUint8Array(base64Url: string): Uint8Array {
return Uint8Array.from(atob(base64Url.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
}
// 判断是否为PWA
export const isPWA = async (): Promise<boolean> => {
if ('serviceWorker' in navigator) {

View File

@@ -1,4 +1,4 @@
import type { ValidationRule } from 'vuetify/types/services/validation'
type ValidationRule = (value: any) => string | boolean
// 必输校验
export const requiredValidator: ValidationRule = (value: any) => {

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -4,6 +4,7 @@ import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
@@ -16,7 +17,7 @@ const props = defineProps({
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
axiosconfig: Object,

View File

@@ -49,6 +49,7 @@ const notificationTypeNames: { [key: string]: string } = {
vocechat: t('notification.vocechat.name'),
synologychat: t('notification.synologychat.name'),
slack: t('notification.slack.name'),
discord: t('notification.discord.name'),
webpush: t('notification.webpush.name'),
custom: t('setting.notification.custom'),
}
@@ -102,6 +103,8 @@ const getIcon = computed(() => {
return getLogoUrl('synologychat')
case 'slack':
return getLogoUrl('slack')
case 'discord':
return getLogoUrl('discord')
case 'webpush':
return getLogoUrl('chrome')
default:
@@ -350,6 +353,47 @@ function onClose() {
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'discord'">
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
persistent-hint
prepend-inner-icon="mdi-label"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_BOT_TOKEN"
:label="t('notification.discord.botToken')"
:hint="t('notification.discord.botTokenHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_GUILD_ID"
:label="t('notification.discord.guildId')"
:placeholder="t('notification.discord.guildIdPlaceholder')"
:hint="t('notification.discord.guildIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="notificationInfo.config.DISCORD_CHANNEL_ID"
:label="t('notification.discord.channelId')"
:placeholder="t('notification.discord.channelIdPlaceholder')"
:hint="t('notification.discord.channelIdHint')"
persistent-hint
prepend-inner-icon="mdi-pound-box"
/>
</VCol>
</VRow>
<VRow v-else-if="notificationInfo.type == 'synologychat'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -1,12 +1,16 @@
<script lang="ts" setup>
import { formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
// 国际化
const { t } = useI18n()
// APP版本
const appVersion = __APP_VERSION__
// 定义事件
const emit = defineEmits(['close'])
@@ -115,6 +119,13 @@ function releaseTime(releaseDate: string) {
return formatDateDifference(releaseDate)
}
// 强制清除缓存
async function clearCache() {
await clearCachesAndServiceWorker()
// 刷新页面,添加时间戳参数以强制更新
reloadWithTimestamp()
}
onMounted(() => {
querySystemEnv()
queryAllRelease()
@@ -170,6 +181,27 @@ onMounted(() => {
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.browserVersion') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow flex flex-row items-center truncate">
<code class="truncate">{{ appVersion }}</code>
<VBtn
size="x-small"
variant="tonal"
class="ms-2"
@click="clearCache"
>
<template #prepend>
<VIcon icon="mdi-refresh" size="14" />
</template>
{{ t('setting.about.clearCache') }}
</VBtn>
</span>
</dd>
</div>
</div>
<div>
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.authVersion') }}</dt>
@@ -194,7 +226,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.configDir') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<span class="flex-grow break-all">
<code>{{ systemEnv.CONFIG_DIR }}</code>
</span>
</dd>
@@ -202,7 +234,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.dataDir') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined"
<span class="flex-grow break-all"
><code>{{ t('setting.about.dataDirectory') }}</code></span
>
</dd>
@@ -212,7 +244,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.timezone') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<span class="flex-grow break-all">
<code>{{ systemEnv.TZ }}</code>
</span>
</dd>
@@ -261,7 +293,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.documentation') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<span class="flex-grow break-all">
<a
href="https://movie-pilot.org"
target="_blank"
@@ -278,7 +310,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.feedback') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<span class="flex-grow break-all">
<a
href="https://github.com/jxxghp/MoviePilot/issues/new/choose"
target="_blank"
@@ -295,7 +327,7 @@ onMounted(() => {
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
<dt class="block text-sm font-bold">{{ t('setting.about.channel') }}</dt>
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
<span class="flex-grow undefined">
<span class="flex-grow break-all">
<a
href="https://t.me/moviepilot_channel"
target="_blank"

View File

@@ -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({

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { PropType } from 'vue'
import { useConfirm } from '@/composables/useConfirm'
import { useToast } from 'vue-toastification'
@@ -28,7 +28,7 @@ const inProps = defineProps({
icons: Object,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
refreshpending: Boolean,
@@ -196,7 +196,7 @@ async function list_files() {
}
// 加载数据
const data = (await inProps.axios.request(config)) ?? []
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
// 如果当前路径已经变化,则放弃此次加载结果
if (prevURI !== takeURISnapshot()) {
return;
@@ -300,7 +300,7 @@ async function download(item: FileItem) {
responseType: 'blob',
}
// 加载数据
const result: Blob = await inProps.axios.request(config)
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) {
const downloadUrl = URL.createObjectURL(result)
window.open(downloadUrl, '_blank')
@@ -318,7 +318,7 @@ async function getImgLink(item: FileItem) {
responseType: 'blob',
}
// 加载二进制数据
const result: Blob = await inProps.axios.request(config)
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
if (result) {
// 创建图片地址
currentImgLink.value = URL.createObjectURL(result)
@@ -395,7 +395,7 @@ async function rename() {
method: inProps.endpoints?.rename.method || 'post',
data: currentItem.value,
}
const result: { [key: string]: any } = await inProps.axios?.request(config)
const result: { [key: string]: any } = (await inProps.axios?.request<any, { [key: string]: any }>(config))
if (!result.success) {
$toast.error(result.message)
}

View File

@@ -2,7 +2,7 @@
import type { PropType } from 'vue'
import type { FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
@@ -54,7 +54,7 @@ const props = defineProps({
},
endpoints: Object,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
})
@@ -131,7 +131,7 @@ async function loadSubdirectories(path: string) {
data: fakeItem,
}
const result = await props.axios?.request(config)
const result = (await props.axios?.request(config))
if (result && Array.isArray(result)) {
// 过滤出目录项
const dirs = result.filter(item => item.type === 'dir')

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import type { AxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
import type { EndPoints, FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
@@ -23,7 +23,7 @@ const inProps = defineProps({
},
endpoints: Object as PropType<EndPoints>,
axios: {
type: Function,
type: Object as PropType<AxiosInstance>,
required: true,
},
sort: {

View File

@@ -0,0 +1,82 @@
<template>
<div class="version-update-toast">
<span class="message">{{ message }}</span>
<button v-if="refreshText" class="refresh-button" @click="handleRefresh">
{{ refreshText }}
</button>
<div v-else class="spinner"></div>
</div>
</template>
<script setup lang="ts">
// 接收 props
interface Props {
message: string
refreshText?: string
onRefresh?: () => void
}
const props = defineProps<Props>()
const handleRefresh = () => {
if (props.onRefresh) {
props.onRefresh()
} else {
window.location.reload()
}
}
</script>
<style scoped>
.version-update-toast {
display: flex;
align-items: center;
gap: 12px;
}
.message {
flex: 1;
word-break: break-all;
line-height: 1.4;
}
.refresh-button {
padding: 6px 16px;
background-color: #fff;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
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);
}
.refresh-button:hover {
background-color: #f5f5f5;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.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>

View File

@@ -12,6 +12,16 @@ const globalPwaStatus = ref<{
const globalLoading = ref(false)
let initPromise: Promise<void> | null = null
// UI模式设置
export type UIMode = 'auto' | 'desktop' | 'app'
const uiMode = ref<UIMode>((localStorage.getItem('ui-mode') as UIMode) || 'auto')
// 设置UI模式
function setUIMode(mode: UIMode) {
uiMode.value = mode
localStorage.setItem('ui-mode', mode)
}
// 全局初始化函数
async function initializePWAGlobally() {
if (initPromise) return initPromise
@@ -50,6 +60,8 @@ export function usePWA() {
})
const appMode = computed(() => {
if (uiMode.value === 'app') return true
if (uiMode.value === 'desktop') return false
return pwaMode.value && display.mdAndDown.value
})
@@ -70,6 +82,8 @@ export function usePWA() {
pwaMode,
appMode,
pwaStatus,
uiMode,
setUIMode,
loading: globalLoading,
initializePWA: initializePWAGlobally,
}

View File

@@ -236,16 +236,15 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
}
}
// PWA状态确定后一次性决定是否添加事件监听器
// 监听 appMode 变化动态添加/移除事件监听器
onMounted(() => {
// 等待PWA检测完成后添加事件监听器
const stopWatcher = watch(
watch(
appMode,
newValue => {
if (newValue) {
addEventListeners()
// PWA状态确定后停止监听
stopWatcher()
} else {
removeEventListeners()
}
},
{ immediate: true },

View File

@@ -0,0 +1,174 @@
import { ref, h } from 'vue'
import { useToast } from 'vue-toastification'
import { Workbox } from 'workbox-window'
import i18n from '@/plugins/i18n'
import VersionUpdateToast from '@/components/toast/VersionUpdateToast.vue'
// 全局状态
const currentVersion = ref(__APP_VERSION__)
let isUpdateToastShown = false
let wb: Workbox | null = null
/**
* 普通刷新页面
*/
export const reloadPage = (): void => {
window.location.reload()
}
/**
* 刷新页面并添加时间戳
*/
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
*/
export const clearCachesAndServiceWorker = async (): Promise<void> => {
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 (error) {
console.error('[VersionChecker] 清除缓存失败:', error)
}
}
/**
* 清除缓存并刷新
*/
const clearCacheAndReload = async (): Promise<void> => {
await clearCachesAndServiceWorker()
reloadWithTimestamp()
}
/**
* 版本检查 Composable
*
* 功能:
* - 使用 Workbox 监听 Service Worker 更新
* - 检查浏览器版本与服务端版本是否一致
* - 显示持久化更新通知
*/
export function useVersionChecker() {
const toast = useToast()
/**
* 显示版本更新通知
* @param message 通知消息文本
* @param refreshText 按钮文本,不传则不显示按钮
* @param onRefresh 按钮点击事件
*/
const showUpdateNotification = (message: string, refreshText?: string, onRefresh?: () => void): void => {
if (isUpdateToastShown) return
isUpdateToastShown = true
const component = h(VersionUpdateToast, {
message,
refreshText,
onRefresh,
})
toast.info(component, {
timeout: false, // 不自动消失
closeButton: false,
closeOnClick: false,
draggable: false,
})
}
// 初始化 Workbox
if (!wb && 'serviceWorker' in navigator) {
wb = new Workbox('/service-worker.js')
// Service Worker 激活事件 (install -> activate)
wb.addEventListener('activated', event => {
// 只有在更新时才显示通知
if (event.isUpdate) {
console.log('[VersionChecker] Service Worker 更新已就绪,等待用户刷新')
showUpdateNotification(i18n.global.t('common.swUpdateReady'), i18n.global.t('common.refresh'), reloadPage)
}
})
// 注册 Service Worker
wb.register()
}
/**
* 检查版本并在需要时显示更新通知
* @param latestVersion 服务端返回的最新版本号
*/
const checkVersion = async (latestVersion: string): Promise<void> => {
// 如果已经显示过通知,说明已经检查过了
if (isUpdateToastShown) return
// 版本一致,无需操作
if (latestVersion === currentVersion.value) {
console.log('[VersionChecker] 版本号一致,无需操作')
return
}
console.log(`[VersionChecker] 检测到版本不一致: ${currentVersion.value} -> ${latestVersion}`)
// 尝试触发 Service Worker 更新检查
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (registration) {
console.log('[VersionChecker] 触发 Service Worker 更新检查...')
// 标记是否发现更新
let updateFound = false
const onUpdateFound = () => {
updateFound = true
}
// 监听 updatefound 事件
registration.addEventListener('updatefound', onUpdateFound, { once: true })
// 等待检查完成
await registration.update()
// 检查是否有更新正在进行
// 如果发现更新,或者正在安装/等待中,则直接返回(交由 SW activated 事件处理)
if (updateFound || registration.installing || registration.waiting) {
console.log('[VersionChecker] Service Worker 更新中...')
return
}
console.log('[VersionChecker] SW 无更新,但版本号不一致,可能是缓存问题')
}
} catch (error) {
console.log('[VersionChecker] Service Worker 更新检查失败:', error)
// 失败继续向下执行,显示通知
}
} else {
console.log('[VersionChecker] 无 Service Worker, 直接显示通知')
}
// 最终兜底:显示版本不一致通知(清除缓存)
showUpdateNotification(
i18n.global.t('common.versionMismatch'),
i18n.global.t('common.clearCache'),
clearCacheAndReload,
)
}
return {
checkVersion,
}
}

View File

@@ -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)
}

View File

@@ -15,7 +15,6 @@ defineProps({
},
})
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
@@ -50,7 +49,7 @@ const userPermissions = computed(() => {
// 获取导航菜单
const navMenus = computed(() => {
const allMenus = getNavMenus()
const allMenus = getNavMenus(t)
return filterMenusByPermission(allMenus, userPermissions.value)
})
@@ -171,51 +170,57 @@ const showDynamicButton = computed(() => {
<template>
<Teleport v-if="appMode && showNav" to="body">
<div class="footer-nav-container">
<VCard elevation="3" class="footer-nav-card border" rounded="pill" :class="{ 'shift-left': showDynamicButton }">
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<TransitionGroup name="footer-nav" tag="div" class="footer-nav-group">
<VCard key="main-nav" elevation="3" class="footer-nav-card border" rounded="pill">
<VCardText class="footer-card-content">
<!-- 添加指示器 -->
<div ref="indicator" class="nav-indicator"></div>
<VBtnToggle class="footer-btn-group" :mandatory="true" v-model="currentMenu">
<!-- 遍历底部菜单项 -->
<VBtn
v-for="menu in footerMenus"
:key="menu.to"
:to="menu.to"
:variant="currentMenu === menu.to ? 'text' : 'plain'"
color="primary"
:ripple="false"
class="footer-nav-btn"
rounded="pill"
:class="{ 'footer-nav-btn-active': currentMenu === menu.to }"
:value="menu.to"
>
<div class="btn-content">
<VIcon :icon="menu.icon" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
</div>
</VBtn>
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
<Transition name="fade-slide">
<VCard v-if="showDynamicButton" elevation="3" class="footer-nav-card dynamic-btn-card border" rounded="pill">
<!-- 更多按钮 -->
<VBtn
:variant="currentMenu === '/apps' ? 'text' : 'plain'"
color="primary"
:ripple="false"
to="/apps"
rounded="pill"
class="footer-nav-btn"
:class="{ 'footer-nav-btn-active': currentMenu === '/apps' }"
value="/apps"
>
<div class="btn-content">
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
</div>
</VBtn>
</VBtnToggle>
</VCardText>
</VCard>
<VCard
v-if="showDynamicButton"
key="dynamic-btn"
elevation="3"
class="footer-nav-card dynamic-btn-card border"
rounded="pill"
>
<VCardText class="footer-card-content">
<!-- 各页面的动态按钮 -->
<VBtn
@@ -230,7 +235,7 @@ const showDynamicButton = computed(() => {
</VBtn>
</VCardText>
</VCard>
</Transition>
</TransitionGroup>
</div>
</Teleport>
</template>
@@ -246,6 +251,12 @@ const showDynamicButton = computed(() => {
inset-inline: 0;
padding-block-end: calc(6px + env(safe-area-inset-bottom, 0px));
pointer-events: none;
}
.footer-nav-group {
display: flex;
align-items: center;
justify-content: center;
// 按钮卡片之间的间距
> .v-card + .v-card {
@@ -260,6 +271,7 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), 0.6);
pointer-events: auto;
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
will-change: transform, max-width, opacity;
// 透明主题下的特殊样式
.v-theme--transparent & {
@@ -267,10 +279,6 @@ const showDynamicButton = computed(() => {
background-color: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
}
&.shift-left {
transform: translateX(0);
}
.v-btn-toggle {
block-size: auto;
min-block-size: 56px;
@@ -328,6 +336,7 @@ const showDynamicButton = computed(() => {
block-size: auto;
inline-size: auto;
min-block-size: 0;
max-width: 60px;
.footer-card-content {
padding: 3px;
@@ -349,23 +358,25 @@ const showDynamicButton = computed(() => {
}
}
// 淡入滑动动画
.fade-slide-enter-active {
// 底部导航动画
.footer-nav-enter-active,
.footer-nav-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
overflow: hidden;
}
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
.footer-nav-enter-from,
.footer-nav-leave-to {
opacity: 0;
max-width: 0 !important;
margin-inline-start: 0 !important;
border-width: 0 !important;
padding: 0 !important;
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(20px);
.footer-nav-move {
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
@keyframes fade-in {

View File

@@ -16,6 +16,7 @@ import { saveLocalTheme } from '@/@core/utils/theme'
import type { ThemeSwitcherTheme } from '@layouts/types'
import { useConfirm } from '@/composables/useConfirm'
import { themeManager } from '@/utils/themeManager'
import { usePWA, type UIMode } from '@/composables/usePWA'
// 认证 Store
const authStore = useAuthStore()
@@ -27,6 +28,8 @@ const globalSettingsStore = useGlobalSettingsStore()
const { t } = useI18n()
// 显示器
const display = useDisplay()
// PWA
const { uiMode, setUIMode } = usePWA()
// 提示框
const $toast = useToast()
@@ -40,6 +43,9 @@ const siteAuthDialog = ref(false)
// 自定义CSS弹窗
const cssDialog = ref(false)
// UI模式菜单是否显示
const showUIModeMenu = ref(false)
// 主题菜单是否显示
const showThemeMenu = ref(false)
@@ -233,9 +239,40 @@ const isAdvancedMode = computed(() => {
return globalSettingsStore.get('ADVANCED_MODE') !== false
})
// UI模式相关
const uiModes = computed(() => [
{
name: 'auto',
title: t('theme.autoUI'),
icon: 'mdi-devices',
},
{
name: 'desktop',
title: t('pwa.platforms.desktop'),
icon: 'mdi-monitor',
},
{
name: 'app',
title: t('pwa.platforms.mobile'),
icon: 'mdi-cellphone',
},
])
// 切换UI模式
function changeUIMode(mode: UIMode) {
setUIMode(mode)
showUIModeMenu.value = false
}
// 获取当前UI模式图标
const getUIModeIcon = computed(() => {
const mode = uiModes.value.find(m => m.name === uiMode.value)
return mode?.icon || 'mdi-devices'
})
// 主题相关功能
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[] = [
@@ -546,6 +583,41 @@ onUnmounted(() => {
<VListItemTitle>{{ t('user.siteAuth') }}</VListItemTitle>
</VListItem>
<!-- 👉 UI模式设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showUIModeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }">
<VListItem v-bind="menuProps" class="mb-1 rounded-lg" hover>
<template #prepend>
<VIcon :icon="getUIModeIcon" />
</template>
<VListItemTitle>{{ t('common.uiMode') }}</VListItemTitle>
<VListItemSubtitle>
{{ uiModes.find(m => m.name === uiMode)?.title || t('theme.autoUI') }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>
</VListItem>
</template>
<VList>
<VListItem
v-for="mode in uiModes"
:key="mode.name"
@click="changeUIMode(mode.name as UIMode)"
:active="uiMode === mode.name"
class="mb-1"
>
<template #prepend>
<VIcon :icon="mode.icon" />
</template>
<VListItemTitle>{{ mode.title }}</VListItemTitle>
<template #append v-if="uiMode === mode.name">
<VIcon icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
</VMenu>
<!-- 👉 主题设置 - 使用嵌套菜单 -->
<VMenu location="end" offset-x min-width="200" v-model="showThemeMenu" :close-on-content-click="true">
<template v-slot:activator="{ props: menuProps }">
@@ -553,9 +625,10 @@ onUnmounted(() => {
<template #prepend>
<VIcon :icon="getThemeIcon" />
</template>
<VListItemTitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('common.theme') }}
</VListItemTitle>
<VListItemTitle>{{ t('common.theme') }}</VListItemTitle>
<VListItemSubtitle>
{{ themes.find(t => t.name === currentThemeName)?.title || t('theme.auto') }}
</VListItemSubtitle>
<template #append>
<VIcon icon="mdi-chevron-right" size="small" />
</template>

View File

@@ -30,6 +30,7 @@ export default {
saving: 'Saving',
reset: 'Reset',
theme: 'Theme',
uiMode: 'UI Layout',
language: 'Language',
pleaseWait: 'Please wait...',
viewDetails: 'View Details',
@@ -66,6 +67,10 @@ export default {
serviceUnavailable: 'Service Unavailable',
status: 'Status',
preset: 'Preset',
refresh: 'Refresh',
swUpdateReady: 'New version is ready, please refresh the page to get the latest features',
versionMismatch: 'Browser cache version does not match server version, please try clearing cache',
clearCache: 'Clear Cache',
},
mediaType: {
movie: 'Movie',
@@ -129,6 +134,7 @@ export default {
light: 'Light',
dark: 'Dark',
auto: 'Follow System',
autoUI: 'Auto',
transparent: 'Transparent',
purple: 'Purple',
custom: 'Custom Style',
@@ -248,6 +254,22 @@ export default {
serverError: 'Login failed, server error!',
loginFailed: 'Login Failed',
checkCredentials: 'Please check your username, password or two-factor authentication code!',
twoFactorAuth: 'Two-Factor Authentication',
loginWithPasskey: 'Login with Passkey',
loginWithOtp: 'Login with OTP',
orUsePasskey: 'Or use Passkey for verification',
verifyWithPasskey: 'Verify with Passkey',
otpPlaceholder: 'Enter 6-digit code',
passkeyLoginStartFailed: 'Failed to start Passkey authentication',
passkeyNotSelected: 'No Passkey selected',
passkeyLoginFailed: 'Passkey login failed',
passkeyAuthCanceled: 'Passkey authentication canceled',
passkeyLoginRetry: 'Passkey login failed, please try again',
passkeyVerifyFailed: 'Passkey verification failed',
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
mfa: {
selectVerificationMethod: 'Please select a verification method',
},
},
menu: {
start: 'Start',
@@ -380,7 +402,7 @@ export default {
username: 'Username',
usernameHint: 'Username for system login',
password: 'Password',
passwordHint: 'Password for system login',
passwordHint: 'Please enter your login password',
confirmPassword: 'Confirm Password',
confirmPasswordHint: 'Please enter the password again to confirm',
role: 'Role',
@@ -458,6 +480,18 @@ export default {
channelHint: 'Channel to send messages, default is "all"',
channelRequired: 'Channel Name cannot be empty',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token (enable Message Content Intent in Dev Portal)',
botTokenRequired: 'Bot Token is required',
guildId: 'Guild ID',
guildIdHint: 'Optional, restrict to a specific guild; leave blank to use any joined guild',
guildIdPlaceholder: '123456789012345678',
channelId: 'Channel ID',
channelIdHint: 'Optional, default broadcast channel; leave blank to auto-pick a writable channel',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: 'Webhook URL',
@@ -1197,6 +1231,7 @@ export default {
title: 'About MoviePilot',
softwareVersion: 'Software Version',
frontendVersion: 'Frontend Version',
browserVersion: 'Browser Cached Version',
authVersion: 'Auth Resource Version',
indexerVersion: 'Indexer Resource Version',
configDir: 'Config Directory',
@@ -1216,6 +1251,7 @@ export default {
dataDirectory: '/moviepilot',
expand: 'Expand',
collapse: 'Collapse',
clearCache: 'Clear Cache',
},
system: {
custom: 'Custom',
@@ -2548,15 +2584,47 @@ export default {
vocechatUser: 'VoceChat User',
synologychatUser: 'SynologyChat User',
doubanUser: 'Douban User',
twoFactorAuthentication: 'Two-Factor Authentication',
setupAuthenticator: 'Setup Authenticator',
authenticatorManagement: 'Authenticator Management',
authenticatorEnabled: 'You have enabled authenticator two-factor authentication',
clearAuthenticatorTip: 'To set up a new authenticator, please clear the current configuration first.',
clearAuthenticator: 'Clear Authenticator',
enableTwoFactor: 'Enable Two-Factor Authentication',
disableTwoFactor: 'Disable Two-Factor Authentication',
setupMfa: 'Setup Two-Factor Authentication',
enableMfa: 'Enable Two-Factor Authentication',
useAuthenticator: 'Use Authenticator',
usePasskey: 'Use Passkey',
enabled: 'Enabled',
keysCount: '{count} keys',
passkeyManagement: 'Passkey Management',
registerNewPasskey: 'Register New Passkey',
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
passkeyName: 'Passkey Name',
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
registerPasskey: 'Register Passkey',
registeredPasskeys: 'Registered Passkeys',
createdAt: 'Created At',
noPasskeys: 'You have not registered any passkeys yet',
passkeyNameRequired: 'Please enter a passkey name',
passkeyRegisterSuccess: 'Passkey registered successfully',
passkeyRegisterFailed: 'Registration failed',
passkeyRegisterCancelled: 'Registration cancelled',
passkeyDeleteSuccess: 'Passkey deleted',
passkeyDeleteFailed: 'Delete failed',
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
accessDomain: 'access domain name',
otpAuthenticator: 'OTP Authenticator',
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
otpDisableSuccess: 'Two-factor authentication disabled successfully!',
otpDisableFailed: 'Failed to disable OTP: {message}!',
otpCodeRequired: 'Please enter the 6-digit verification code',
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
otpEnableFailed: 'Failed to enable OTP: {message}!',
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
authenticatorApp: 'Authenticator App',
authenticatorAppDescription:
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code. It will generate a 6-digit code for you to enter below.',

View File

@@ -30,6 +30,7 @@ export default {
saving: '保存中',
reset: '重置',
theme: '主题',
uiMode: '界面布局',
language: '语言',
pleaseWait: '请稍候...',
viewDetails: '查看详情',
@@ -66,6 +67,10 @@ export default {
serviceUnavailable: '服务不可用',
status: '状态',
preset: '预设',
refresh: '刷新',
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
clearCache: '清除缓存',
},
mediaType: {
movie: '电影',
@@ -129,6 +134,7 @@ export default {
light: '浅色',
dark: '深色',
auto: '跟随系统',
autoUI: '自动',
transparent: '透明',
purple: '幻紫',
custom: '附加样式',
@@ -247,6 +253,22 @@ export default {
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
twoFactorAuth: '双重验证',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证',
verifyWithPasskey: '使用通行密钥验证',
otpPlaceholder: '请输入6位验证码',
passkeyLoginStartFailed: '启动通行密钥认证失败',
passkeyNotSelected: '未选择通行密钥',
passkeyLoginFailed: '通行密钥登录失败',
passkeyAuthCanceled: '通行密钥认证被取消',
passkeyLoginRetry: '通行密钥登录失败,请重试',
passkeyVerifyFailed: '通行密钥验证失败',
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
mfa: {
selectVerificationMethod: '请选择验证方式',
},
},
menu: {
start: '开始',
@@ -379,7 +401,7 @@ export default {
username: '用户名',
usernameHint: '用于登录系统的用户名',
password: '密码',
passwordHint: '用于登录系统的密码',
passwordHint: '请输入登录密码',
confirmPassword: '确认密码',
confirmPasswordHint: '请再次输入密码以确认',
role: '角色',
@@ -456,6 +478,18 @@ export default {
channelHint: '消息发送频道,默认`全体`',
channelRequired: '频道名称不能为空',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在开发者后台开启 Message Content Intent',
botTokenRequired: 'Bot Token不能为空',
guildId: '服务器 ID',
guildIdHint: '可选,限制使用的服务器;为空则使用已加入的任意服务器',
guildIdPlaceholder: '123456789012345678',
channelId: '频道 ID',
channelIdHint: '可选,默认广播频道;为空则自动选择可发送消息的频道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '机器人传入URL',
@@ -1194,6 +1228,7 @@ export default {
title: '关于 MoviePilot',
softwareVersion: '软件版本',
frontendVersion: '前端版本',
browserVersion: '浏览器缓存版本',
authVersion: '认证资源版本',
indexerVersion: '站点资源版本',
configDir: '配置目录',
@@ -1201,7 +1236,7 @@ export default {
timezone: '时区',
latest: '最新',
supportingSites: '支持站点',
support: '支',
support: '支',
documentation: '文档',
feedback: '问题反馈',
channel: '发布频道',
@@ -1213,6 +1248,7 @@ export default {
dataDirectory: '/moviepilot',
expand: '展开',
collapse: '收起',
clearCache: '清除缓存',
},
system: {
custom: '自定义',
@@ -2517,15 +2553,47 @@ export default {
vocechatUser: 'VoceChat用户',
synologychatUser: 'SynologyChat用户',
doubanUser: '豆瓣用户',
twoFactorAuthentication: '登录双重验证',
setupAuthenticator: '设置身份验证',
authenticatorManagement: '身份验证器管理',
authenticatorEnabled: '您已启用身份验证器双重验证',
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
clearAuthenticator: '清除身份验证器',
enableTwoFactor: '开启双重验证',
disableTwoFactor: '关闭双重验证',
setupMfa: '设置双重验证',
enableMfa: '开启双重验证',
useAuthenticator: '使用身份验证器',
usePasskey: '使用通行密钥',
enabled: '已启用',
keysCount: '{count} 个密钥',
passkeyManagement: '通行密钥管理',
registerNewPasskey: '注册新通行密钥',
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
passkeyName: '通行密钥名称',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '注册通行密钥',
registeredPasskeys: '已注册的通行密钥',
createdAt: '创建时间',
noPasskeys: '您还没有注册任何通行密钥',
passkeyNameRequired: '请输入通行密钥名称',
passkeyRegisterSuccess: '通行密钥注册成功',
passkeyRegisterFailed: '注册失败',
passkeyRegisterCancelled: '注册被取消',
passkeyDeleteSuccess: '通行密钥已删除',
passkeyDeleteFailed: '删除失败',
passkeyDomainWarning: '通行密钥PassKey的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
accessDomain: '访问域名',
otpAuthenticator: 'OTP 身份验证器',
otpGenerateFailed: '获取otp uri失败{message}',
otpDisableSuccess: '关闭登录双重验证成功!',
otpDisableFailed: '关闭otp失败{message}',
otpCodeRequired: '请填写6位验证码',
otpEnableSuccess: '开启登录双重验证成功!',
otpEnableFailed: '开启otp失败{message}',
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
authenticatorApp: '身份验证器',
authenticatorAppDescription:
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序扫描二维码。它将为您生成一个6位数的代码供您在下方输入。',

View File

@@ -30,6 +30,7 @@ export default {
saving: '保存中',
reset: '重置',
theme: '主題',
uiMode: '界面佈局',
language: '語言',
pleaseWait: '請稍候...',
viewDetails: '查看詳情',
@@ -66,6 +67,10 @@ export default {
serviceUnavailable: '服務不可用',
status: '狀態',
preset: '預設',
refresh: '刷新',
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
versionMismatch: '瀏覽器快取版本與伺服器版本不一致,請嘗試清除快取',
clearCache: '清除快取',
},
mediaType: {
movie: '電影',
@@ -129,6 +134,7 @@ export default {
light: '淺色',
dark: '深色',
auto: '跟隨系統',
autoUI: '自動',
transparent: '透明',
purple: '幻紫',
custom: '附加樣式',
@@ -248,6 +254,22 @@ export default {
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗',
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
twoFactorAuth: '雙重驗證',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
verifyWithPasskey: '使用通行密鑰驗證',
otpPlaceholder: '請輸入6位驗證碼',
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
passkeyNotSelected: '未選擇通行密鑰',
passkeyLoginFailed: '通行密鑰登錄失敗',
passkeyAuthCanceled: '通行密鑰驗證被取消',
passkeyLoginRetry: '通行密鑰登錄失敗,請重試',
passkeyVerifyFailed: '通行密鑰驗证失敗',
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
mfa: {
selectVerificationMethod: '請選擇驗证方式',
},
},
menu: {
start: '開始',
@@ -380,7 +402,7 @@ export default {
username: '用戶名',
usernameHint: '用於登入系統的用戶名',
password: '密碼',
passwordHint: '用於登入系統的密碼',
passwordHint: '請輸入登入密碼',
confirmPassword: '確認密碼',
confirmPasswordHint: '請再次輸入密碼以確認',
role: '角色',
@@ -447,6 +469,18 @@ export default {
channel: '頻道名稱',
channelHint: '消息發送頻道,默認`全體`',
},
discord: {
name: 'Discord',
botToken: 'Bot Token',
botTokenHint: 'Discord Bot Token需在開發者後台開啟 Message Content Intent',
botTokenRequired: 'Bot Token不能為空',
guildId: '伺服器 ID',
guildIdHint: '可選,限制使用的伺服器;空白則使用已加入的任意伺服器',
guildIdPlaceholder: '123456789012345678',
channelId: '頻道 ID',
channelIdHint: '可選,預設廣播頻道;空白則自動選擇可發送消息的頻道',
channelIdPlaceholder: '123456789012345678',
},
synologychat: {
name: 'Synology Chat',
webhook: '機器人傳入URL',
@@ -1182,6 +1216,7 @@ export default {
title: '關於 MoviePilot',
softwareVersion: '軟件版本',
frontendVersion: '前端版本',
browserVersion: '瀏覽器緩存版本',
authVersion: '認證資源版本',
indexerVersion: '站點資源版本',
configDir: '配置目錄',
@@ -1201,6 +1236,7 @@ export default {
dataDirectory: '/moviepilot',
expand: '展開',
collapse: '收起',
clearCache: '清除快取',
},
system: {
custom: '自定義',
@@ -2503,15 +2539,47 @@ export default {
vocechatUser: 'VoceChat用戶',
synologychatUser: 'SynologyChat用戶',
doubanUser: '豆瓣用戶',
twoFactorAuthentication: '登錄雙重驗證',
setupAuthenticator: '設置身份驗證',
authenticatorManagement: '身份驗證器管理',
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
clearAuthenticator: '清除身份驗證器',
enableTwoFactor: '開啟雙重驗證',
disableTwoFactor: '關閉雙重驗證',
setupMfa: '設置雙重驗證',
enableMfa: '開啟雙重驗證',
useAuthenticator: '使用身份驗證器',
usePasskey: '使用通行密鑰',
enabled: '已啟用',
keysCount: '{count} 個密鑰',
passkeyManagement: '通行密鑰管理',
registerNewPasskey: '註冊新通行密鑰',
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
passkeyName: '通行密鑰名稱',
passkeyNamePlaceholder: '例如iPhone、Windows Hello',
registerPasskey: '註冊通行密鑰',
registeredPasskeys: '已註冊的通行密鑰',
createdAt: '建立時間',
noPasskeys: '您還沒有註冊任何通行密鑰',
passkeyNameRequired: '請輸入通行密鑰名稱',
passkeyRegisterSuccess: '通行密鑰註冊成功',
passkeyRegisterFailed: '註冊失敗',
passkeyRegisterCancelled: '註冊被取消',
passkeyDeleteSuccess: '通行密鑰已刪除',
passkeyDeleteFailed: '刪除失敗',
passkeyDomainWarning: '通行密鑰PassKey的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
otpRequiredForPasskey: '為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
accessDomain: '訪問域名',
otpAuthenticator: 'OTP 身份驗證器',
otpGenerateFailed: '獲取otp uri失敗{message}',
otpDisableSuccess: '關閉登錄雙重驗證成功!',
otpDisableFailed: '關閉otp失敗{message}',
otpCodeRequired: '請填寫6位驗證碼',
otpEnableSuccess: '開啟登錄雙重驗證成功!',
otpEnableFailed: '開啟otp失敗{message}',
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
authenticatorApp: '身份驗證器',
authenticatorAppDescription:
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序掃描二維碼。它將為您生成一個6位數的代碼供您在下方輸入。',

View File

@@ -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)

View File

@@ -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,

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { debounce } from 'lodash-es'
import { VForm } from 'vuetify/components/VForm'
import { useAuthStore, useUserStore } from '@/stores'
import { authState, userState } from '@/stores/types'
@@ -7,7 +6,7 @@ import { requiredValidator } from '@/@validators'
import api from '@/api'
import router from '@/router'
import logo from '@images/logo.png'
import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { bufferToBase64Url, base64UrlToUint8Array, urlBase64ToUint8Array } from '@/@core/utils/navigator'
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
import { useTheme } from 'vuetify'
@@ -22,7 +21,7 @@ const authStore = useAuthStore()
const userStore = useUserStore()
// 获取有权限的菜单
const navMenus = getNavMenus()
const navMenus = computed(() => getNavMenus(t))
// 表单
const form = ref({
@@ -43,6 +42,12 @@ const errorMessage = ref('')
// 是否开启双重验证
const isOTP = ref(false)
// 双重验证对话框
const mfaDialog = ref(false)
// MFA PassKey loading
const mfaPasskeyLoading = ref(false)
// 用户名称输入框
const usernameInput = ref()
@@ -66,6 +71,79 @@ const locales = Object.values(SUPPORTED_LOCALES)
// 登录按钮 loading
const loading = ref(false)
// PassKey 登录按钮 loading
const passkeyLoading = ref(false)
// 使用PassKey登录
async function loginWithPassKey() {
errorMessage.value = ''
passkeyLoading.value = true
try {
// 1. 开始认证流程
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {})
if (!startResponse.success) {
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
errorMessage.value = t('login.passkeyNotSelected')
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
signature: bufferToBase64Url((credential as any).response.signature),
userHandle: (credential as any).response.userHandle
? bufferToBase64Url((credential as any).response.userHandle)
: null,
},
}
// 4. 完成认证
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
await handleLoginSuccess(finishResponse)
} catch (error: any) {
console.error('PassKey login failed:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyLoginFailed')
} else if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else {
errorMessage.value = t('login.passkeyLoginRetry')
}
} finally {
passkeyLoading.value = false
}
}
// 切换语言
async function switchLanguage(locale: SupportedLocale) {
await setI18nLanguage(locale)
@@ -74,21 +152,21 @@ async function switchLanguage(locale: SupportedLocale) {
}
// 查询是否开启双重验证
const fetchOTP = debounce(async () => {
const userid = usernameInput.value?.value
if (!userid) {
async function fetchOTP(): Promise<boolean> {
if (!form.value.username) {
isOTP.value = false
return
return false
}
api
.get(`/user/otp/${userid}`)
.then((response: any) => {
isOTP.value = response.success
})
.catch((error: any) => {
console.log(error)
})
}, 500)
try {
const response: any = await api.get(`/mfa/status/${form.value.username}`)
isOTP.value = response.success
return response.success
} catch (error: any) {
console.log(error)
isOTP.value = false
return false
}
}
// 订阅推送通知
async function subscribeForPushNotifications() {
@@ -132,84 +210,181 @@ async function afterLogin(superuser: boolean, userPayload: userState, filteredMe
// 订阅推送通知
if (superuser) await subscribeForPushNotifications()
// 登录按钮 loading
loading.value = false
}
// 处理登录成功
async function handleLoginSuccess(response: any) {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.wizard,
}
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus.value, userPermissions)
if (filteredMenus.length === 0) {
errorMessage.value = t('login.noPermission')
return
}
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
await afterLogin(userPayload.superUser, userPayload, filteredMenus)
}
// 登录获取token事件
function login() {
async function login() {
errorMessage.value = ''
// 进行表单校验
if (!form.value.username || !form.value.password || (isOTP.value && !form.value.otp_password)) {
if (!form.value.username || !form.value.password) {
return
}
// 登录按钮 loading
loading.value = true
// 用户名密码
const formData = new FormData()
try {
// 用户名密码
const formData = new FormData()
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
formData.append('username', form.value.username)
formData.append('password', form.value.password)
formData.append('otp_password', form.value.otp_password)
// 请求token
api
.post('/login/access-token', formData, {
// 请求token
const response: any = await api.post('/login/access-token', formData, {
headers: {
Accept: 'application/json', // 设置 Accept 类型
},
})
.then((response: any) => {
const userPayload: userState = {
superUser: response.super_user,
userID: response.user_id,
userName: response.user_name,
avatar: response.avatar,
level: response.level,
permissions: response.permissions,
wizard: response.widzard,
}
// 在保存用户信息之前检查权限
const userPermissions = {
is_superuser: userPayload.superUser,
...userPayload.permissions,
}
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
// 如果用户没有任何可用菜单,拒绝登录
if (filteredMenus.length === 0) {
// 显示错误信息
errorMessage.value = t('login.noPermission')
loading.value = false
await handleLoginSuccess(response)
} catch (error: any) {
// 登录失败,显示错误提示
if (!error.response) {
errorMessage.value = t('login.networkError')
} else if (error.response.status === 401) {
// 401错误可能是需要MFA或者认证失败
// 检查响应头是否有MFA要求标识
const mfaRequired = error.response.headers?.['x-mfa-required'] === 'true'
if (mfaRequired && !form.value.otp_password) {
// 需要MFA验证弹出对话框
isOTP.value = true
mfaDialog.value = true
return
}
// 不需要MFA或已填写OTP但认证失败
errorMessage.value = t('login.authFailure')
// 认证失败后清空OTP密码防止下次点击不弹出对话框
form.value.otp_password = ''
} else if (error.response.status === 403) {
errorMessage.value = t('login.permissionDenied')
} else if (error.response.status === 500) {
errorMessage.value = t('login.serverError')
} else {
errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
}
} finally {
loading.value = false
}
}
// 权限检查通过,保存用户信息
const authPayLoad: authState = {
token: response.access_token,
remember: form.value.remember,
}
// 使用OTP码继续登录
function loginWithOTP() {
mfaDialog.value = false
login()
}
authStore.login(authPayLoad)
userStore.loginUser(userPayload)
// 使用PassKey进行MFA验证
async function verifyWithPassKey() {
if (!form.value.username) return
// 登录后处理
afterLogin(userPayload.superUser, userPayload, filteredMenus)
mfaPasskeyLoading.value = true
errorMessage.value = ''
try {
// 1. 开始认证流程(指定用户名)
const startResponse: any = await api.post('/mfa/passkey/authenticate/start', {
username: form.value.username,
})
.catch((error: any) => {
// 登录失败,显示错误提示
if (!error.response) errorMessage.value = t('login.networkError')
else if (error.response.status === 401) errorMessage.value = t('login.authFailure')
else if (error.response.status === 403) errorMessage.value = t('login.permissionDenied')
else if (error.response.status === 500) errorMessage.value = t('login.serverError')
else errorMessage.value = `${t('login.loginFailed')} ${error.response.status}${t('login.checkCredentials')}`
// 登录按钮 loading
loading.value = false
if (!startResponse.success) {
errorMessage.value = startResponse.message || t('login.passkeyLoginStartFailed')
return
}
const { options, challenge } = startResponse.data
const publicKeyOptions = JSON.parse(options)
// 2. 调用WebAuthn API
const credential = await navigator.credentials.get({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
errorMessage.value = t('login.passkeyNotSelected')
return
}
// 3. 转换credential
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
authenticatorData: bufferToBase64Url((credential as any).response.authenticatorData),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
signature: bufferToBase64Url((credential as any).response.signature),
userHandle: (credential as any).response.userHandle
? bufferToBase64Url((credential as any).response.userHandle)
: null,
},
}
// 4. 完成认证(直接登录,不需要密码)
const finishResponse: any = await api.post('/mfa/passkey/authenticate/finish', {
credential: credentialJSON,
challenge: challenge,
})
// 关闭MFA对话框
mfaDialog.value = false
await handleLoginSuccess(finishResponse)
} catch (error: any) {
console.error('PassKey MFA verification failed:', error)
if (error.response) {
errorMessage.value = error.response.data?.detail || t('login.passkeyVerifyFailed')
} else if (error.name === 'NotAllowedError') {
errorMessage.value = t('login.passkeyAuthCanceled')
} else {
errorMessage.value = t('login.passkeyVerifyFailedRetry')
}
} finally {
mfaPasskeyLoading.value = false
}
}
// 自动登录
@@ -274,7 +449,7 @@ onMounted(async () => {
</template>
</VCardItem>
<VCardText>
<VForm ref="refForm" autocomplete="on" @submit.prevent="() => {}">
<VForm ref="refForm" autocomplete="on" @submit.prevent="login">
<VRow>
<!-- username -->
<VCol cols="12">
@@ -286,7 +461,7 @@ onMounted(async () => {
name="username"
autocomplete="username"
:rules="[requiredValidator]"
@input="fetchOTP"
hide-details
/>
</VCol>
<!-- password -->
@@ -299,11 +474,11 @@ onMounted(async () => {
autocomplete="current-password"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
hide-details
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VTextField v-if="isOTP" v-model="form.otp_password" :label="t('login.otpCode')" type="input" />
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -311,9 +486,21 @@ onMounted(async () => {
</VCol>
<VCol cols="12">
<!-- login button -->
<VBtn block type="submit" @click="login" prepend-icon="mdi-login" :loading="loading">
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading">
{{ t('login.login') }}
</VBtn>
<!-- passkey login button -->
<VBtn
block
variant="tonal"
color="success"
class="mt-3"
prepend-icon="mdi-key-variant"
:loading="passkeyLoading"
@click="loginWithPassKey"
>
{{ t('login.loginWithPasskey') }}
</VBtn>
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</VAlert>
@@ -323,6 +510,56 @@ onMounted(async () => {
</VCardText>
</VCard>
</div>
<!-- MFA双重验证对话框 -->
<VDialog v-model="mfaDialog" max-width="400" persistent>
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ t('login.twoFactorAuth') }}</VCardTitle>
<VCardText>
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
<!-- TOTP验证 -->
<VCard variant="tonal" class="mb-3">
<VCardText>
<VForm @submit.prevent="loginWithOTP">
<VTextField
v-model="form.otp_password"
:label="t('login.otpCode')"
:placeholder="t('login.otpPlaceholder')"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
prepend-inner-icon="mdi-shield-key"
class="mb-2"
/>
<VBtn block type="submit" color="primary" :disabled="!form.otp_password">
{{ t('login.loginWithOtp') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- PassKey验证 -->
<VCard variant="tonal">
<VCardText>
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
<VBtn
block
variant="tonal"
color="success"
prepend-icon="mdi-key-variant"
:loading="mfaPasskeyLoading"
@click="verifyWithPassKey"
>
{{ t('login.verifyWithPasskey') }}
</VBtn>
</VCardText>
</VCard>
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
</VCardText>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -231,12 +231,23 @@ registerHeaderTab({
],
})
// 页面是否准备就绪
const isReady = ref(false)
// 定时器
let timer: ReturnType<typeof setTimeout>
onBeforeMount(async () => {
await loadConfig()
initializeColors()
})
onMounted(async () => {
// 延迟渲染内容,避免阻塞页面切换动画
timer = setTimeout(() => {
isReady.value = true
}, 400)
await loadExtraRecommendSources()
// 为新增的数据源也生成颜色
extraRecommendSources.value.forEach(source => {
@@ -246,6 +257,10 @@ onMounted(async () => {
})
})
onUnmounted(() => {
if (timer) clearTimeout(timer)
})
onActivated(async () => {
await loadExtraRecommendSources()
})
@@ -256,10 +271,16 @@ onActivated(async () => {
<!-- 滚动内容区域 -->
<div class="recommend-content">
<TransitionGroup name="fade">
<MediaCardSlideView v-for="item in filteredViews" :key="item.title" v-bind="item" class="content-group" />
<MediaCardSlideView
v-for="item in filteredViews"
:key="item.title"
v-bind="item"
:ready="isReady"
class="content-group"
/>
</TransitionGroup>
<div v-if="filteredViews.length === 0" class="empty-category">
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
<p class="empty-text">{{ t('recommend.noCategoryContent') }}</p>
<VBtn color="primary" variant="tonal" size="small" @click="dialog = true">

View File

@@ -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()

View File

@@ -22,9 +22,9 @@ const shareViewKey = ref(0)
// 获取标签页
const subscribeTabs = computed(() => {
if (subType === '电影') {
return getSubscribeMovieTabs()
return getSubscribeMovieTabs(t)
} else {
return getSubscribeTvTabs()
return getSubscribeTvTabs(t)
}
})

View File

@@ -17,7 +17,7 @@ const listViewKey = ref(0)
// 获取标签页
const workflowTabs = computed(() => {
return getWorkflowTabs()
return getWorkflowTabs(t)
})
// 新增工作流对话框

View File

@@ -1,5 +1,5 @@
import { Icon } from '@iconify/vue'
import { aliases } from 'vuetify/lib/iconsets/mdi'
import { aliases } from 'vuetify/iconsets/mdi'
const alertTypeIcon = {
success: 'mdi-check-circle-outline',

View File

@@ -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'),

View File

@@ -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()
// 检查是否是更新(兼容旧版前端监听逻辑)
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,7 @@ 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 === 'SKIP_WAITING') {
self.skipWaiting()
}
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import type { globalSettingsState } from '@/stores/types'
import { fetchGlobalSettings } from '@/utils/globalSetting'
import { useVersionChecker } from '@/composables/useVersionChecker'
export const useGlobalSettingsStore = defineStore('globalSettings', {
state: (): globalSettingsState => ({
@@ -18,6 +19,12 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
const result = await fetchGlobalSettings()
this.data = result || {}
this.initialized = true
// 检查版本更新
if (result.FRONTEND_VERSION) {
const { checkVersion } = useVersionChecker()
await checkVersion(result.FRONTEND_VERSION)
}
} catch (error) {
console.error('Failed to initialize global settings', error)
} finally {

View File

@@ -74,17 +74,17 @@ html.v-overlay-scroll-blocked body {
// 路由过渡动画
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.6s;
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-45px);
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(45px);
transform: translateX(20px);
}
// 网格布局样式

View File

@@ -1,5 +1,8 @@
// PWA Badge API 类型定义
declare global {
const __APP_VERSION__: string
const __BUILD_TIME__: string
interface Navigator {
/**
* 设置应用徽章数量

View File

@@ -13,6 +13,7 @@ import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
import wechatLogo from '@/assets/images/logos/wechat.png'
import telegramLogo from '@/assets/images/logos/telegram.webp'
import slackLogo from '@/assets/images/logos/slack.webp'
import discordLogo from '@/assets/images/logos/discord.png'
import synologychatLogo from '@/assets/images/logos/synologychat.png'
import vocechatLogo from '@/assets/images/logos/vocechat.png'
import downloaderLogo from '@/assets/images/logos/downloader.png'
@@ -40,6 +41,7 @@ const logoMap: Record<string, string> = {
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,
discord: discordLogo,
synologychat: synologychatLogo,
vocechat: vocechatLogo,
downloader: downloaderLogo,

View File

@@ -4,6 +4,7 @@ import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import SlideView from '@/components/slide/SlideView.vue'
import { useI18n } from 'vue-i18n'
import { useIntersectionObserver, until } from '@vueuse/core'
const { t } = useI18n()
@@ -12,6 +13,10 @@ const props = defineProps({
apipath: String,
linkurl: String,
title: String,
ready: {
type: Boolean,
default: true,
},
})
// 提供给子组件的属性
@@ -19,38 +24,71 @@ provide('rankingPropsKey', reactive({ ...props }))
// 组件加载完成
const componentLoaded = ref(false)
// 是否已尝试加载
const hasTriedLoading = ref(false)
// 数据列表
const dataList = ref<MediaInfo[]>([])
// 容器引用
const containerRef = ref<HTMLElement | null>(null)
// 获取订阅列表数据
async function fetchData() {
try {
if (!props.apipath) return
dataList.value = await api.get(props.apipath)
if (dataList.value.length > 0) componentLoaded.value = true
if (dataList.value.length > 0) {
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
await until(() => props.ready).toBe(true)
}
componentLoaded.value = true
} catch (error) {
console.error(error)
componentLoaded.value = true
} finally {
hasTriedLoading.value = true
}
}
// 加载时获取数据
onMounted(() => {
fetchData()
})
// 使用 IntersectionObserver 实现懒加载
const { stop } = useIntersectionObserver(
containerRef,
([{ isIntersecting }]) => {
if (isIntersecting) {
fetchData()
stop()
}
},
{
rootMargin: '300px', // 提前加载距离
},
)
onActivated(() => {
if (dataList.value.length == 0) {
if (dataList.value.length == 0 && hasTriedLoading.value) {
fetchData()
}
})
</script>
<template>
<SlideView v-if="componentLoaded">
<template #content>
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" width="9rem" />
<div ref="containerRef">
<SlideView v-if="componentLoaded">
<template #content>
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
<MediaCard :media="data" width="9rem" />
</template>
</template>
</template>
</SlideView>
</SlideView>
<SlideView v-else-if="!componentLoaded">
<template #content>
<div v-for="i in 10" :key="i" style="width: 9rem">
<VCard class="outline-none overflow-hidden">
<div style="padding-bottom: 150%"></div>
</VCard>
</div>
</template>
</SlideView>
</div>
</template>

View File

@@ -32,7 +32,7 @@ const { appMode } = usePWA()
const activeTab = ref('installed')
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs())
const pluginTabs = computed(() => getPluginTabs(t))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()

View File

@@ -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'))
@@ -294,6 +294,9 @@ onMounted(() => {
<VListItem @click="addNotification('slack')">
<VListItemTitle>{{ t('setting.notification.slack') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('discord')">
<VListItemTitle>Discord</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('synologychat')">
<VListItemTitle>{{ t('setting.notification.synologyChat') }}</VListItemTitle>
</VListItem>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
import { useToast } from 'vue-toastification'
import QrcodeVue from 'qrcode.vue'
import { VForm } from 'vuetify/lib/components/index.mjs'
@@ -10,7 +11,7 @@ import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
const { t, locale } = useI18n()
// 显示器宽度
const display = useDisplay()
@@ -67,6 +68,54 @@ const accountInfo = ref<User>({
// 二维码信息
const qrCode = ref('')
// PassKey类型
interface PassKey {
id: number
name: string
created_at: string
last_used_at?: string
aaguid?: string
transports?: string
}
// PassKey列表
const passkeyList = ref<PassKey[]>([])
// PassKey对话框
const passkeyDialog = ref(false)
// PassKey注册loading
const passkeyRegistering = ref(false)
// PassKey名称
const passkeyName = ref('')
// PassKey challenge
const passkeyChallenge = ref('')
// 双重验证菜单
const mfaMenu = ref(false)
// 密码验证对话框
const verifyPasswordDialog = ref(false)
// 验证密码
const verifyPassword = ref('')
// 验证后的回调
const verifyCallback = ref<(() => void) | null>(null)
// 验证对话框标题
const verifyTitle = ref('')
// 验证对话框提示
const verifyText = ref('')
// 检查是否已启用任何双重验证
const hasMfaEnabled = computed(() => {
return accountInfo.value.is_otp || passkeyList.value.length > 0
})
// 更新头像
function changeAvatar(file: Event) {
const fileReader = new FileReader()
@@ -116,13 +165,15 @@ async function fetchUserInfo() {
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
currentUserName.value = accountInfo.value.name
currentAvatar.value = accountInfo.value.avatar
// 同时加载PassKey列表
await fetchPassKeyList()
}
} catch (error) {
console.log(error)
}
}
// 保存户信息
// 保存户信息
async function saveAccountInfo() {
if (isSaving.value) {
$toast.error(t('profile.savingInProgress'))
@@ -197,8 +248,16 @@ async function saveAccountInfo() {
// 为当前用户获取Otp Uri
async function getOtpUri() {
// 如果已经启用OTP只打开对话框不生成新的二维码
if (accountInfo.value.is_otp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
otpDialog.value = true
return
}
// 未启用OTP生成新的二维码
try {
const result: { [key: string]: any } = await api.post('user/otp/generate')
const result: { [key: string]: any } = await api.post('mfa/otp/generate')
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
@@ -212,19 +271,49 @@ async function getOtpUri() {
}
}
// 密码验证并执行回调
function withPasswordVerification(title: string, text: string, callback: () => void) {
verifyTitle.value = title
verifyText.value = text
verifyCallback.value = callback
verifyPassword.value = ''
verifyPasswordDialog.value = true
}
// 确认密码验证
async function confirmVerifyPassword() {
if (!verifyPassword.value) {
$toast.error(t('user.passwordHint'))
return
}
if (verifyCallback.value) {
verifyCallback.value()
}
verifyPasswordDialog.value = false
}
// 关闭当前用户的双重验证
async function disableOtp() {
try {
const result: { [key: string]: any } = await api.post('user/otp/disable')
if (result.success) {
accountInfo.value.is_otp = false
$toast.success(t('profile.otpDisableSuccess'))
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
if (passkeyList.value.length > 0) {
$toast.error(t('profile.otpDisableRestrictedByPasskey'))
return
}
withPasswordVerification(t('profile.disableTwoFactor'), t('profile.confirmToDisableOtp'), async () => {
try {
const result: { [key: string]: any } = await api.post('mfa/otp/disable', {
password: verifyPassword.value,
})
if (result.success) {
accountInfo.value.is_otp = false
$toast.success(t('profile.otpDisableSuccess'))
otpDialog.value = false
} else {
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
}
} catch (error) {
console.log(error)
}
})
}
// 启用Otp
@@ -234,7 +323,7 @@ async function judgeOtpPassword() {
return
}
try {
const result: { [key: string]: any } = await api.post('user/otp/judge', {
const result: { [key: string]: any } = await api.post('mfa/otp/verify', {
uri: otpUri.value,
otpPassword: otpPassword.value,
})
@@ -251,6 +340,128 @@ async function judgeOtpPassword() {
}
}
// 获取PassKey列表
async function fetchPassKeyList() {
try {
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
if (result.success) {
passkeyList.value = result.data || []
}
} catch (error) {
console.log(error)
}
}
// 打开PassKey注册对话框
async function openPassKeyDialog() {
passkeyName.value = ''
passkeyDialog.value = true
await fetchPassKeyList()
}
// 注册PassKey
async function registerPassKey() {
if (!passkeyName.value) {
$toast.error(t('profile.passkeyNameRequired'))
return
}
passkeyRegistering.value = true
try {
// 1. 开始注册
const startResult: { [key: string]: any } = await api.post('mfa/passkey/register/start', {
name: passkeyName.value,
})
if (!startResult.success) {
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
passkeyRegistering.value = false
return
}
const { options, challenge } = startResult.data
const publicKeyOptions = JSON.parse(options)
passkeyChallenge.value = challenge
// 2. 调用WebAuthn API
const credential = await navigator.credentials.create({
publicKey: {
...publicKeyOptions,
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
user: {
...publicKeyOptions.user,
id: base64UrlToUint8Array(publicKeyOptions.user.id),
},
excludeCredentials: publicKeyOptions.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64UrlToUint8Array(cred.id),
})),
},
})
if (!credential) {
$toast.error(t('profile.passkeyRegisterCancelled'))
return
}
// 3. 转换credential为可传输格式
const credentialJSON = {
id: credential.id,
rawId: bufferToBase64Url((credential as any).rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64Url((credential as any).response.attestationObject),
clientDataJSON: bufferToBase64Url((credential as any).response.clientDataJSON),
transports: (credential as any).response.getTransports ? (credential as any).response.getTransports() : [],
},
}
// 4. 完成注册
const finishResult: { [key: string]: any } = await api.post('mfa/passkey/register/finish', {
credential: credentialJSON,
challenge: passkeyChallenge.value,
name: passkeyName.value,
})
if (finishResult.success) {
$toast.success(t('profile.passkeyRegisterSuccess'))
passkeyName.value = ''
await fetchPassKeyList()
} else {
$toast.error(finishResult.message || t('profile.passkeyRegisterFailed'))
}
} catch (error: any) {
console.error('PassKey注册失败:', error)
if (error.name === 'NotAllowedError') {
$toast.error(t('profile.passkeyRegisterCancelled'))
} else {
$toast.error(t('profile.passkeyRegisterFailed'))
}
}
passkeyRegistering.value = false
}
// 删除PassKey
async function deletePassKey(passkeyId: number) {
withPasswordVerification(t('common.delete') + t('profile.usePasskey'), t('profile.confirmToDeletePasskey'), async () => {
try {
const result: { [key: string]: any } = await api.post('mfa/passkey/delete', {
passkey_id: passkeyId,
password: verifyPassword.value,
})
if (result.success) {
$toast.success(t('profile.passkeyDeleteSuccess'))
await fetchPassKeyList()
} else {
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
}
} catch (error) {
console.log(error)
$toast.error(t('profile.passkeyDeleteFailed'))
}
})
}
// 加载当前用户数据
onMounted(() => {
fetchUserInfo()
@@ -301,16 +512,42 @@ watch(
<span v-if="display.mdAndUp.value" class="ms-2">{{ t('common.default') }}</span>
</VBtn>
<VBtn
:color="accountInfo.is_otp ? 'warning' : 'success'"
variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>
<VIcon icon="mdi-account-key" />
<span v-if="display.mdAndUp.value" class="ms-2">{{
accountInfo.is_otp ? t('profile.disableTwoFactor') : t('profile.enableTwoFactor')
}}</span>
</VBtn>
<!-- 双重验证菜单按钮 -->
<VMenu v-model="mfaMenu" :close-on-content-click="false">
<template #activator="{ props }">
<VBtn
:color="hasMfaEnabled ? 'warning' : 'success'"
variant="tonal"
v-bind="props"
>
<VIcon icon="mdi-shield-key" />
<span v-if="display.mdAndUp.value" class="ms-2">
{{ hasMfaEnabled ? t('profile.setupMfa') : t('profile.enableMfa') }}
</span>
<VIcon icon="mdi-menu-down" class="ms-1" />
</VBtn>
</template>
<VList>
<VListItem @click="getOtpUri(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-cellphone-key" />
</template>
<VListItemTitle>{{ t('profile.useAuthenticator') }}</VListItemTitle>
<VListItemSubtitle v-if="accountInfo.is_otp" class="text-success">
{{ t('profile.enabled') }}
</VListItemSubtitle>
</VListItem>
<VListItem @click="openPassKeyDialog(); mfaMenu = false">
<template #prepend>
<VIcon icon="mdi-key-variant" />
</template>
<VListItemTitle>{{ t('profile.usePasskey') }}</VListItemTitle>
<VListItemSubtitle v-if="passkeyList.length > 0" class="text-success">
{{ t('profile.keysCount', { count: passkeyList.length }) }}
</VListItemSubtitle>
</VListItem>
</VList>
</VMenu>
</div>
<p class="text-body-1 mb-0">{{ t('profile.avatarFormatTip') }}</p>
@@ -455,37 +692,201 @@ watch(
<!-- 双重验证弹窗 -->
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
<!-- 开启双重验证弹窗内容 -->
<VCard>
<VDialogCloseBtn @click="otpDialog = false" />
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.twoFactorAuthentication') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm>
<VTextField
v-model="otpPassword"
type="text"
:label="t('profile.enterVerificationCode')"
autocomplete=""
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<!-- 如果已启用OTP显示清除界面 -->
<template v-if="accountInfo.is_otp && !qrCode">
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.authenticatorManagement') }}</h4>
<VAlert type="success" variant="tonal" class="mb-4">
{{ t('profile.authenticatorEnabled') }}
</VAlert>
<p class="mb-6">
{{ t('profile.clearAuthenticatorTip') }}
</p>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false"> {{ t('common.cancel') }} </VBtn>
<VBtn @click="judgeOtpPassword">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn color="error" @click="disableOtp(); otpDialog = false">
<template #prepend>
<VIcon icon="mdi-check" />
<VIcon icon="mdi-delete" />
</template>
{{ t('profile.clearAuthenticator') }}
</VBtn>
</div>
</template>
<!-- 设置新的OTP -->
<template v-else>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.setupAuthenticator') }}</h4>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.authenticatorApp') }}</h5>
<p class="mb-6">
{{ t('profile.authenticatorAppDescription') }}
</p>
<div class="my-6">
<QrcodeVue class="mx-auto" :value="qrCode" :size="200" max-width="25rem" />
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
<VTextField
v-model="otpPassword"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
:label="t('profile.enterVerificationCode')"
class="mb-8"
variant="outlined"
prepend-inner-icon="mdi-shield-key"
/>
<div class="d-flex justify-end flex-wrap gap-4">
<VBtn variant="outlined" color="secondary" @click="otpDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
{{ t('common.confirm') }}
</VBtn>
</div>
</VForm>
</template>
</VCardText>
<VDialogCloseBtn @click="otpDialog = false" />
</VCard>
</VDialog>
<!-- PassKey管理对话框 -->
<VDialog v-if="passkeyDialog" v-model="passkeyDialog" max-width="45rem" scrollable>
<VCard>
<VCardText>
<h4 class="text-h4 text-center mb-6 mt-5">{{ t('profile.passkeyManagement') }}</h4>
<!-- 安全警告 -->
<VAlert
type="warning"
variant="tonal"
class="mb-6"
icon="mdi-alert"
>
<i18n-t keypath="profile.passkeyDomainWarning" tag="span">
<template #domain>
<b>{{ t('profile.accessDomain') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 注册新通行密钥 -->
<VCard v-if="accountInfo.is_otp" variant="tonal" class="mb-6">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registerNewPasskey') }}</h5>
<p class="mb-4">{{ t('profile.passkeyDescription') }}</p>
<VForm @submit.prevent="registerPassKey">
<VTextField
v-model="passkeyName"
:label="t('profile.passkeyName')"
:placeholder="t('profile.passkeyNamePlaceholder')"
class="mb-4"
variant="outlined"
prepend-inner-icon="mdi-form-textbox"
/>
<VBtn
color="primary"
type="submit"
:loading="passkeyRegistering"
prepend-icon="mdi-plus"
>
{{ t('profile.registerPasskey') }}
</VBtn>
</VForm>
</VCardText>
</VCard>
<!-- 未启用 OTP 提示 -->
<VAlert
v-else
type="error"
variant="tonal"
class="mb-6"
icon="mdi-shield-lock"
>
<i18n-t keypath="profile.otpRequiredForPasskey" tag="span">
<template #otp>
<b>{{ t('profile.otpAuthenticator') }}</b>
</template>
</i18n-t>
</VAlert>
<!-- 已注册的通行密钥列表 -->
<VCard variant="tonal">
<VCardText>
<h5 class="text-h5 font-weight-medium mb-2">{{ t('profile.registeredPasskeys') }}</h5>
<VList v-if="passkeyList.length > 0" class="mt-4">
<VListItem
v-for="passkey in passkeyList"
:key="passkey.id"
class="mb-2 py-4"
rounded="lg"
border
>
<template #prepend>
<VIcon icon="mdi-key-variant" size="32" class="me-4" />
</template>
<VListItemTitle class="font-weight-medium">
{{ passkey.name }}
</VListItemTitle>
<VListItemSubtitle>
{{ t('profile.createdAt') }}: {{ new Date(passkey.created_at).toLocaleString(locale) }}
</VListItemSubtitle>
<template #append>
<VBtn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="deletePassKey(passkey.id)"
/>
</template>
</VListItem>
</VList>
<VAlert v-else type="info" variant="tonal" class="mt-4">
{{ t('profile.noPasskeys') }}
</VAlert>
</VCardText>
</VCard>
<div class="d-flex justify-end mt-6">
<VBtn variant="outlined" @click="passkeyDialog = false">{{ t('common.close') }}</VBtn>
</div>
</VCardText>
<VDialogCloseBtn @click="passkeyDialog = false" />
</VCard>
</VDialog>
<!-- 密码验证对话框 -->
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
<VCard>
<VCardTitle class="text-h5 text-center mt-4">{{ verifyTitle }}</VCardTitle>
<VCardText>
<p class="mb-4">{{ verifyText }}</p>
<VForm @submit.prevent="confirmVerifyPassword">
<VTextField
v-model="verifyPassword"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:label="t('user.password')"
:append-inner-icon="isConfirmPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
variant="outlined"
prepend-inner-icon="mdi-lock"
autocomplete="current-password"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
<div class="d-flex justify-end gap-4 mt-4">
<VBtn variant="outlined" color="secondary" @click="verifyPasswordDialog = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit" color="primary">
{{ t('common.confirm') }}
</VBtn>
</div>

View File

@@ -10,6 +10,11 @@ import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import { resolve } from 'node:path'
import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await'
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({
@@ -52,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,
@@ -277,7 +188,11 @@ export default defineConfig({
promiseImportName: i => `__mp_tla_${i}`,
}),
],
define: { 'process.env': {} },
define: {
'process.env': {},
'__APP_VERSION__': JSON.stringify(`v${packageJson.version}`),
'__BUILD_TIME__': JSON.stringify(buildTime),
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -300,12 +215,6 @@ export default defineConfig({
},
chunkSizeWarningLimit: 5000,
cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name].js',
},
},
},
optimizeDeps: {
exclude: ['vuetify'],
@@ -324,6 +233,7 @@ export default defineConfig({
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
quietDeps: true,
},
},

View File

@@ -3017,9 +3017,9 @@ camelcase-css@^2.0.1:
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
version "1.0.30001715"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz"
integrity sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==
version "1.0.30001761"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz"
integrity sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==
chalk@^4.0.0, chalk@^4.0.2:
version "4.1.2"
@@ -6856,6 +6856,7 @@ std-env@^3.9.0:
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
name string-width-cjs
version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -6941,6 +6942,7 @@ stringify-object@^3.3.0:
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==