mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 06:32:45 +08:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5382108ee7 | ||
|
|
514063d3fb | ||
|
|
b08f396fec | ||
|
|
d37a7f06f1 | ||
|
|
ad7bca3aae | ||
|
|
4fb70ba80e | ||
|
|
1225b2eb9e | ||
|
|
24b2f103b9 | ||
|
|
0d304b58ca | ||
|
|
f419dbd794 | ||
|
|
7854cc81a8 | ||
|
|
9ad1bd29bd | ||
|
|
b88d4f0ecb | ||
|
|
44168b62d2 | ||
|
|
1dab013436 | ||
|
|
64a4a7aff5 | ||
|
|
e43b545c89 | ||
|
|
69fcde250e | ||
|
|
63d6290166 | ||
|
|
c1d759f3f3 | ||
|
|
3a782bc69c | ||
|
|
bea752879c | ||
|
|
a48fcb3819 | ||
|
|
68a07bc952 | ||
|
|
828dba09b0 | ||
|
|
0d2189e9e8 | ||
|
|
f0f0ab81e4 | ||
|
|
64b5fa7038 | ||
|
|
1d04c9b9c9 | ||
|
|
dee719ac25 | ||
|
|
ea676876f1 | ||
|
|
c1a4d5d81e | ||
|
|
95d88804e4 | ||
|
|
1fa072790f | ||
|
|
fe19c1183c | ||
|
|
be40f55bd9 | ||
|
|
30a10eaf6d | ||
|
|
3bc0c86df4 | ||
|
|
03c8726e6e | ||
|
|
de47491ded | ||
|
|
c691cdaa0e | ||
|
|
53efdc2802 | ||
|
|
9644076463 | ||
|
|
cb4e88f8aa | ||
|
|
adc16fc58d | ||
|
|
d6860a3e24 | ||
|
|
7e6116de45 | ||
|
|
1688a2ca25 | ||
|
|
fe57acfce0 | ||
|
|
1ae49b28b1 | ||
|
|
ef4e9c8b40 | ||
|
|
5da0758e89 | ||
|
|
816cab252d | ||
|
|
843f638835 | ||
|
|
e4684b2e12 | ||
|
|
c17365b6c9 | ||
|
|
01835c0ac5 | ||
|
|
e5749bd6ef | ||
|
|
689e58737b | ||
|
|
38da061cf1 | ||
|
|
e79940e52e | ||
|
|
88dd6068b6 | ||
|
|
7dd10f9c96 | ||
|
|
94aaf83107 | ||
|
|
e84fc5f424 | ||
|
|
f342b08179 | ||
|
|
0fcad02f3b | ||
|
|
43d2406ee9 | ||
|
|
78e2d05730 | ||
|
|
425bf808ed | ||
|
|
6d2916dc9f | ||
|
|
2281e4224b | ||
|
|
95282f9883 | ||
|
|
b470f182c9 | ||
|
|
0bba1068af | ||
|
|
947a7d8296 | ||
|
|
bd36cbf888 | ||
|
|
d8fa47bff7 | ||
|
|
1132beea5e | ||
|
|
2e3314e6c3 | ||
|
|
daa8f857f8 | ||
|
|
6d14271fe8 | ||
|
|
9284d48f67 | ||
|
|
c5d1c5a468 | ||
|
|
b98512789f | ||
|
|
6b8ed8d527 | ||
|
|
ec4500dcef | ||
|
|
288e63ce68 | ||
|
|
b3885584bb | ||
|
|
968b24be1e | ||
|
|
5a23c1783a | ||
|
|
ddeeb5a7c3 | ||
|
|
0b9bbcc7b8 | ||
|
|
022c8b4515 | ||
|
|
be04991928 | ||
|
|
34770567a5 | ||
|
|
6154fc2157 | ||
|
|
e77dcdd3d4 | ||
|
|
58a3532c1b | ||
|
|
116a5eeb43 | ||
|
|
decd50cb40 | ||
|
|
355563244c | ||
|
|
51aad628b5 | ||
|
|
7dd7a2cf34 | ||
|
|
4c0ff7c7f2 | ||
|
|
8aba3cbe00 | ||
|
|
e21c3ec507 | ||
|
|
fdbb0b2ca8 | ||
|
|
180195ab7d | ||
|
|
8add4e6b46 | ||
|
|
3d622d2efe | ||
|
|
bb7ed7b963 | ||
|
|
d541ea41ad | ||
|
|
7c7ebc9eb7 | ||
|
|
22275c3b12 | ||
|
|
8744a34e8e | ||
|
|
e98836fd0e | ||
|
|
feb62196a2 | ||
|
|
9fd29a2958 | ||
|
|
546c82ca40 | ||
|
|
f132dc38f4 | ||
|
|
58c70b8ca6 | ||
|
|
147f55eefe | ||
|
|
229b7b0c12 | ||
|
|
4b7b5ff8a4 | ||
|
|
4906bde746 | ||
|
|
a87a1a8988 | ||
|
|
e05f45e681 | ||
|
|
b4acacea81 |
212
index.html
212
index.html
@@ -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,14 +91,9 @@
|
||||
<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%;
|
||||
overflow: auto;
|
||||
min-block-size: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -197,6 +192,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 +238,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 +271,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 +345,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>
|
||||
|
||||
13
package.json
13
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.8.2",
|
||||
"version": "2.9.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run build:icons",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
@@ -51,11 +52,13 @@
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"sass": "^1.83.4",
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
@@ -69,6 +72,9 @@
|
||||
"webfontloader": "^1.6.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/line-md": "^1.2.13",
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
@@ -77,9 +83,12 @@
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^20.1.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/webfontloader": "^1.6.34",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
|
||||
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
1
shims.d.ts
vendored
@@ -12,3 +12,4 @@ declare module 'vue-prism-component' {
|
||||
export default component
|
||||
}
|
||||
declare module 'vue-shepherd';
|
||||
declare module 'colorthief';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -92,6 +92,9 @@ const sources: BundleScriptConfig = {
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
@@ -154,7 +157,13 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
// Sort icons by prefix
|
||||
const organizedList = organizeIconsList(sources.icons)
|
||||
for (const prefix in organizedList) {
|
||||
const filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
let filename
|
||||
try {
|
||||
filename = require.resolve(`@iconify-json/${prefix}/icons.json`)
|
||||
}
|
||||
catch (err) {
|
||||
filename = require.resolve(`@iconify/json/json/${prefix}.json`)
|
||||
}
|
||||
|
||||
sourcesJSON.push({
|
||||
filename,
|
||||
|
||||
@@ -142,7 +142,7 @@ export default defineComponent({
|
||||
|
||||
.layout-wrapper.layout-nav-type-vertical {
|
||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||
block-size: 100%;
|
||||
min-block-size: 100%;
|
||||
|
||||
.layout-content-wrapper {
|
||||
display: flex;
|
||||
@@ -224,7 +224,9 @@ export default defineComponent({
|
||||
|
||||
.layout-page-content {
|
||||
// display: flex;
|
||||
overflow: hidden;
|
||||
// 使用 clip 替代 hidden,避免 Chrome 144+ 滚动锁定问题
|
||||
overflow-x: clip;
|
||||
overflow-y: auto;
|
||||
|
||||
.page-content-container {
|
||||
inline-size: 100%;
|
||||
|
||||
@@ -13,6 +13,8 @@ html {
|
||||
body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
// Chrome 144+ 兼容性:覆盖 Vuetify 的内联 overflow: hidden 样式
|
||||
overflow: visible !important;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@@ -35,7 +37,9 @@ body,
|
||||
.layout-page-content {
|
||||
@include mixins.boxed-content(true);
|
||||
|
||||
overflow: hidden;
|
||||
// Chrome 144+ 兼容性:使用 clip 替代 hidden,避免滚动锁定问题
|
||||
// overflow: hidden 在新版 Chrome 中可能意外阻止垂直滚动
|
||||
overflow: clip;
|
||||
flex-grow: 1;
|
||||
|
||||
// TODO: Use grid gutter variable here;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ValidationRule } from 'vuetify/types/services/validation'
|
||||
type ValidationRule = (value: any) => string | boolean
|
||||
|
||||
// 必输校验
|
||||
export const requiredValidator: ValidationRule = (value: any) => {
|
||||
|
||||
50
src/App.vue
50
src/App.vue
@@ -15,7 +15,7 @@ import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
let themeValue = localStorage.getItem('theme') || 'light'
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
@@ -38,6 +38,9 @@ const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -45,6 +48,33 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('dashboard/cpu')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
@@ -162,7 +192,11 @@ async function removeLoadingWithStateCheck() {
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(() => {
|
||||
globalSettingsStore.initialize().then(async () => {
|
||||
// 如果已登录,加载用户相关设置
|
||||
if (isLogin.value) {
|
||||
await globalSettingsStore.loadUserSettings()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
@@ -207,6 +241,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()
|
||||
|
||||
@@ -234,11 +276,15 @@ onMounted(async () => {
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
// 启动心跳
|
||||
startHeartbeat()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
removeBackgroundTimer('background-rotation')
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ export interface MediaInfo {
|
||||
vote_average?: number
|
||||
// 描述
|
||||
overview?: string
|
||||
// 自定义剧集组
|
||||
episode_group?: string
|
||||
// 二级分类
|
||||
category?: string
|
||||
// 详情页面
|
||||
@@ -861,6 +863,16 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
name: string
|
||||
created_at: string
|
||||
last_used_at?: string
|
||||
aaguid?: string
|
||||
transports?: string
|
||||
}
|
||||
|
||||
// 存储空间
|
||||
export interface Storage {
|
||||
// 总空间
|
||||
@@ -1084,6 +1096,8 @@ export interface DownloaderConf {
|
||||
config: { [key: string]: any }
|
||||
// 是否启用
|
||||
enabled: boolean
|
||||
// 路径映射
|
||||
path_mapping?: Array<[storagePath: string, downloadPath: string]>
|
||||
}
|
||||
|
||||
// 通知配置
|
||||
@@ -1427,3 +1441,25 @@ export interface SubscribeShareStatistics {
|
||||
// 总复用人次
|
||||
total_reuse_count?: number
|
||||
}
|
||||
|
||||
// 通用API响应
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
export interface CategoryRule {
|
||||
genre_ids?: string
|
||||
original_language?: string
|
||||
production_countries?: string
|
||||
origin_country?: string
|
||||
release_year?: string
|
||||
}
|
||||
|
||||
// 分类配置
|
||||
export interface CategoryConfig {
|
||||
movie?: { [key: string]: CategoryRule }
|
||||
tv?: { [key: string]: CategoryRule }
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/discord.png
Normal file
BIN
src/assets/images/logos/discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -4,6 +4,12 @@ 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'
|
||||
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
|
||||
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -11,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,
|
||||
@@ -119,22 +125,33 @@ const fileIcons = {
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
// 当前存储
|
||||
const activeStorage = ref('local')
|
||||
|
||||
// 刷新
|
||||
const refreshPending = ref(false)
|
||||
// 排序
|
||||
const sort = ref('name')
|
||||
// 排序 - 从localStorage恢复
|
||||
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
|
||||
|
||||
// 是否显示目录树
|
||||
const showDirTree = ref(false)
|
||||
// 是否显示目录树 - 从localStorage恢复
|
||||
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
|
||||
|
||||
// 拖动分隔条相关
|
||||
const navigatorWidth = ref(280) // 初始宽度
|
||||
// 拖动分隔条相关 - 从localStorage恢复宽度
|
||||
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
|
||||
const isDragging = ref(false)
|
||||
const dragStartX = ref(0)
|
||||
const dragStartWidth = ref(0)
|
||||
|
||||
watch(sort, (val) => {
|
||||
localStorage.setItem(SORT_KEY, val)
|
||||
})
|
||||
|
||||
watch(showDirTree, (val) => {
|
||||
localStorage.setItem(SHOW_TREE_KEY, String(val))
|
||||
})
|
||||
|
||||
watch(navigatorWidth, (val) => {
|
||||
localStorage.setItem(NAV_WIDTH_KEY, String(val))
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const storagesArray = computed(() => {
|
||||
return props.storages?.map(item => ({
|
||||
@@ -144,15 +161,15 @@ const storagesArray = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
// 方法
|
||||
function loadingChanged(loading: number) {
|
||||
if (loading) loading++
|
||||
else if (loading > 0) loading--
|
||||
function loadingChanged(isLoading: number) {
|
||||
if (isLoading) loading.value++
|
||||
else if (loading.value > 0) loading.value--
|
||||
}
|
||||
|
||||
// 存储切换
|
||||
async function storageChanged(storage: string) {
|
||||
activeStorage.value = storage
|
||||
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
|
||||
}
|
||||
|
||||
@@ -235,12 +252,12 @@ function stopDrag() {
|
||||
|
||||
<template>
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="activeStorage && item">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:storage="activeStorage"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
@storagechanged="storageChanged"
|
||||
@@ -251,7 +268,7 @@ function stopDrag() {
|
||||
<div class="flex">
|
||||
<FileNavigator
|
||||
v-if="showDirTree"
|
||||
:storage="activeStorage"
|
||||
:storage="item.storage"
|
||||
:currentPath="item.path"
|
||||
:items="fileListItems"
|
||||
:endpoints="endpoints"
|
||||
@@ -266,7 +283,6 @@ function stopDrag() {
|
||||
</div>
|
||||
<FileList
|
||||
:item="item"
|
||||
:storage="activeStorage"
|
||||
:icons="fileIcons"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { DownloaderInfo } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { downloaderDict } from '@/api/constants'
|
||||
import { downloaderDict, storageAttributes } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
|
||||
@@ -52,6 +52,54 @@ const download_rate = ref(0)
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = ref(false)
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
// 查找匹配的存储类型
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
// 获取存储路径前后缀
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
// 更新存储路径前缀
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
// 更新存储路径后缀
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
@@ -59,8 +107,24 @@ const downloaderInfo = ref<DownloaderConf>({
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 生成随机ID
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
// 下载器是否应该刷新数据的计算属性
|
||||
const shouldRefresh = computed(() => props.allowRefresh && props.downloader.enabled)
|
||||
|
||||
@@ -92,11 +156,24 @@ async function loadDownloaderInfo() {
|
||||
function openDownloaderInfoDialog() {
|
||||
// 深复制
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
// 初始化路径映射行数据
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
downloaderInfoDialog.value = true
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
function saveDownloaderInfo() {
|
||||
async function saveDownloaderInfo() {
|
||||
// 表单校验
|
||||
const { valid } = await downloaderForm.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
// 同步路径映射数据
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
// 为空不保存,跳出警告框
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
@@ -134,6 +211,20 @@ const getIcon = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 添加路径映射
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
// 移除路径映射
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
emit('close')
|
||||
@@ -152,6 +243,7 @@ onUnmounted(() => {
|
||||
stopRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover v-slot="hover">
|
||||
@@ -212,7 +304,7 @@ onUnmounted(() => {
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
@@ -373,6 +465,89 @@ onUnmounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="pe-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
@@ -19,6 +21,21 @@ const isImageLoaded = ref(false)
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
@@ -42,10 +59,10 @@ function noteToJson() {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 将\n转换为html属性的换行符
|
||||
function replaceNewLine(value: string) {
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return value.replace(/\n/g, '<br/>')
|
||||
return md.render(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -85,19 +102,23 @@ function replaceNewLine(value: string) {
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<p class="mb-0">{{ props.message?.text }}</p>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
<VCardText v-if="props.message?.text && props.message?.action === 1" v-html="replaceNewLine(props.message?.text)" />
|
||||
<VCardText
|
||||
v-if="props.message?.text && props.message?.action === 1"
|
||||
class="markdown-body"
|
||||
v-html="renderMarkdown(props.message?.text)"
|
||||
/>
|
||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title_year }}
|
||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
||||
{{ key + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle v-if="value.type">
|
||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
||||
@@ -116,3 +137,89 @@ function replaceNewLine(value: string) {
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.markdown-body {
|
||||
word-break: break-all;
|
||||
|
||||
p {
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
font-family: monospace;
|
||||
padding-block: 0.2rem;
|
||||
padding-inline: 0.4rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
margin-block-end: 0.5rem;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
||||
font-style: italic;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 1rem;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: rgba(var(--v-border-color), 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: auto;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -136,8 +136,8 @@ onMounted(() => {
|
||||
|
||||
<!-- 媒体标题 -->
|
||||
<VCardItem class="pt-3 pb-0">
|
||||
<div class="d-flex flex-row flex-wrap justify-start mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold text-truncate me-2">
|
||||
<div class="d-flex flex-row flex-wrap justify-start align-center mb-2 pr-8">
|
||||
<span class="text-h6 font-weight-bold me-2">
|
||||
{{ media?.title ?? meta?.name }}
|
||||
</span>
|
||||
<VChip
|
||||
@@ -183,14 +183,14 @@ onMounted(() => {
|
||||
<!-- 种子内容 -->
|
||||
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||
<!-- 种子标题 -->
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-1 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<!-- 种子描述 -->
|
||||
<div
|
||||
v-if="meta?.subtitle || torrent?.description"
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
|
||||
@@ -140,7 +140,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VListItemTitle>
|
||||
<VListItemTitle class="whitespace-normal">
|
||||
<div class="d-flex flex-row flex-wrap align-center mb-2">
|
||||
<span class="text-h6 font-weight-bold me-2">{{ media?.title ?? meta?.name }}</span>
|
||||
<VChip
|
||||
@@ -153,12 +153,12 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2" :title="torrent?.title">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="torrent?.title">
|
||||
{{ torrent?.title }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-body-2 text-medium-emphasis mb-2"
|
||||
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||
:title="meta?.subtitle || torrent?.description || '暂无描述'"
|
||||
>
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,10 +6,20 @@ import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } fr
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { VCardTitle, VChip } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
@@ -38,6 +48,18 @@ const directories = ref<TransferDirectoryConf[]>([])
|
||||
// 是否正在加载
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否显示高级选项
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// TMDB ID
|
||||
const tmdbid = ref<number | undefined>(undefined)
|
||||
|
||||
// 豆瓣ID
|
||||
const doubanId = ref<string | undefined>(undefined)
|
||||
|
||||
// TMDB选择对话框
|
||||
const mediaSelectorDialog = ref(false)
|
||||
|
||||
// 计算按钮图标
|
||||
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||
|
||||
@@ -56,9 +78,21 @@ async function loadDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
function convertToUri(item: TransferDirectoryConf) {
|
||||
if (!item.download_path) {
|
||||
return undefined
|
||||
}
|
||||
if (item.storage === 'local') {
|
||||
return item.download_path
|
||||
}
|
||||
return item.storage + ':' + item.download_path
|
||||
}
|
||||
|
||||
// 获取保存目录
|
||||
const targetDirectories = computed(() => {
|
||||
const downloadDirectories = directories.value.map(item => item.download_path)
|
||||
const downloadDirectories = directories.value
|
||||
.map(item => convertToUri(item))
|
||||
.filter((item): item is string => item !== undefined)
|
||||
return [...new Set(downloadDirectories)]
|
||||
})
|
||||
|
||||
@@ -96,6 +130,14 @@ async function addDownload() {
|
||||
payload.media_in = props.media
|
||||
}
|
||||
|
||||
// 添加媒体ID辅助识别
|
||||
if (tmdbid.value) {
|
||||
payload.tmdbid = tmdbid.value
|
||||
}
|
||||
if (doubanId.value) {
|
||||
payload.doubanid = doubanId.value
|
||||
}
|
||||
|
||||
const endpoint = props.media ? 'download/' : 'download/add'
|
||||
|
||||
result = await api.post(endpoint, payload)
|
||||
@@ -181,7 +223,6 @@ onMounted(() => {
|
||||
<VSelect
|
||||
v-model="selectedDownloader"
|
||||
:items="downloaderOptions"
|
||||
size="small"
|
||||
:label="t('dialog.addDownload.downloader')"
|
||||
variant="underlined"
|
||||
:placeholder="t('dialog.addDownload.defaultPlaceholder')"
|
||||
@@ -194,7 +235,6 @@ onMounted(() => {
|
||||
v-model="selectedDirectory"
|
||||
:items="targetDirectories"
|
||||
:label="t('dialog.addDownload.saveDirectory')"
|
||||
size="small"
|
||||
:placeholder="t('dialog.addDownload.autoPlaceholder')"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@@ -202,6 +242,53 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow class="px-5 mt-2">
|
||||
<VCol cols="12">
|
||||
<VBtn
|
||||
variant="text"
|
||||
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||
>
|
||||
{{
|
||||
showAdvancedOptions
|
||||
? t('dialog.addDownload.hideAdvancedOptions')
|
||||
: t('dialog.addDownload.showAdvancedOptions')
|
||||
}}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
:label="t('dialog.reorganize.tmdbId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('dialog.reorganize.doubanId')"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
append-inner-icon="mdi-magnify"
|
||||
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="underlined"
|
||||
density="comfortable"
|
||||
@click:append-inner="mediaSelectorDialog = true"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardText class="text-center">
|
||||
<VBtn variant="elevated" :disabled="loading" @click="addDownload" :prepend-icon="icon" class="px-5">
|
||||
@@ -209,5 +296,15 @@ onMounted(() => {
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 媒体ID选择器 -->
|
||||
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||
<MediaIdSelector
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
663
src/components/dialog/CategoryEditDialog.vue
Normal file
663
src/components/dialog/CategoryEditDialog.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
235
src/components/dialog/OTPAuthDialog.vue
Normal file
235
src/components/dialog/OTPAuthDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QRCode from 'qrcode'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
passkeyList?: PassKey[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
passkeyList: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:isOtp', 'verifyPassword'])
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
// 如果已经启用OTP,只打开对话框,不生成新的二维码
|
||||
if (props.isOtp) {
|
||||
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
|
||||
qrCodeImage.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 未启用OTP,生成新的二维码
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
|
||||
uri: string
|
||||
secret: string
|
||||
}>
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/verify', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})) as ApiResponse
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
show.value = false
|
||||
emit('update:isOtp', true)
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpEnableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
function disableOtp() {
|
||||
// 如果已绑定PassKey,不允许关闭OTP
|
||||
if (props.passkeyList && props.passkeyList.length > 0 && !allowPasskeyWithoutOtp.value) {
|
||||
$toast.error(t('profile.disableOtpWithPasskeyError'))
|
||||
return
|
||||
}
|
||||
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.disableTwoFactor'),
|
||||
text: t('profile.confirmToDisableOtp'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/otp/disable', {
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
emit('update:isOtp', false)
|
||||
$toast.success(t('profile.otpDisableSuccess'))
|
||||
show.value = false
|
||||
} else {
|
||||
$toast.error(t('profile.otpDisableFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.otpDisableFailed', { message: error instanceof Error ? error.message : String(error) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动获取 URI
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
getOtpUri()
|
||||
otpPassword.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
qrCodeImage.value = ''
|
||||
qrCode.value = ''
|
||||
otpUri.value = ''
|
||||
secret.value = ''
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-cellphone-key" class="me-2" />
|
||||
{{ props.isOtp && !qrCode ? t('profile.authenticatorManagement') : t('profile.setupAuthenticator') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.authenticatorAppDescription') }}
|
||||
</p>
|
||||
<!-- 如果已启用OTP,显示清除界面 -->
|
||||
<template v-if="props.isOtp && !qrCode">
|
||||
<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="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" @click="disableOtp">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
{{ t('profile.clearAuthenticator') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设置新的OTP -->
|
||||
<template v-else>
|
||||
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</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="show = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
321
src/components/dialog/PasskeyDialog.vue
Normal file
321
src/components/dialog/PasskeyDialog.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script lang="ts" setup>
|
||||
import { bufferToBase64Url, base64UrlToUint8Array } from '@/@core/utils/navigator'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateDifference } from '@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, PassKey } from '@/api/types'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
isOtp: boolean
|
||||
}
|
||||
|
||||
// WebAuthn 相关接口定义
|
||||
interface PublicKeyCredentialDescriptorJSON {
|
||||
id: string
|
||||
type: 'public-key'
|
||||
transports?: AuthenticatorTransport[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'update:passkeyList', 'verifyPassword'])
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const display = useDisplay()
|
||||
const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 内部状态
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey注册loading
|
||||
const passkeyRegistering = ref(false)
|
||||
|
||||
// PassKey名称
|
||||
const passkeyName = ref('')
|
||||
|
||||
// PassKey challenge
|
||||
const passkeyChallenge = ref('')
|
||||
|
||||
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
|
||||
const canRegisterPasskey = computed(() => props.isOtp || allowPasskeyWithoutOtp.value)
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString(locale.value)
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result = (await api.get('mfa/passkey/list')) as ApiResponse<PassKey[]>
|
||||
if (result.success) {
|
||||
passkeyList.value = result.data || []
|
||||
emit('update:passkeyList', passkeyList.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册PassKey
|
||||
async function registerPassKey() {
|
||||
if (!passkeyName.value) {
|
||||
$toast.error(t('profile.passkeyNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!window.isSecureContext) {
|
||||
$toast.error(t('login.passkeySecureContextRequired'))
|
||||
} else {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
passkeyRegistering.value = true
|
||||
try {
|
||||
// 1. 开始注册
|
||||
const startResult = (await api.post('mfa/passkey/register/start', {
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse<{ options: string; challenge: string }>
|
||||
|
||||
if (!startResult.success) {
|
||||
$toast.error(startResult.message || t('profile.passkeyRegisterFailed'))
|
||||
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: PublicKeyCredentialDescriptorJSON) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
})) as PublicKeyCredential
|
||||
|
||||
if (!credential) {
|
||||
$toast.error(t('profile.passkeyRegisterCancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const response = credential.response as AuthenticatorAttestationResponse
|
||||
const credentialJSON = {
|
||||
id: credential.id,
|
||||
rawId: bufferToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||
transports: typeof response.getTransports === 'function' ? response.getTransports() : [],
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成注册
|
||||
const finishResult = (await api.post('mfa/passkey/register/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: passkeyChallenge.value,
|
||||
name: passkeyName.value,
|
||||
})) as ApiResponse
|
||||
|
||||
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 if (error.name === 'NotSupportedError') {
|
||||
$toast.error(t('login.passkeyNotSupported'))
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
} finally {
|
||||
passkeyRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除PassKey
|
||||
async function deletePassKey(passkeyId: number) {
|
||||
emit('verifyPassword', {
|
||||
title: t('profile.deletePasskey'),
|
||||
text: t('profile.confirmToDeletePasskey'),
|
||||
callback: async (password: string) => {
|
||||
try {
|
||||
const result = (await api.post('mfa/passkey/delete', {
|
||||
passkey_id: passkeyId,
|
||||
password,
|
||||
})) as ApiResponse
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.passkeyDeleteSuccess'))
|
||||
await fetchPassKeyList()
|
||||
} else {
|
||||
$toast.error(result.message || t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('profile.passkeyDeleteFailed'))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗打开,自动加载列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
if (val) {
|
||||
fetchPassKeyList()
|
||||
passkeyName.value = ''
|
||||
} else {
|
||||
// 弹窗关闭时,清空数据
|
||||
passkeyName.value = ''
|
||||
passkeyChallenge.value = ''
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="show" max-width="45rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="material-symbols:passkey" class="me-2" />
|
||||
{{ t('profile.passkeyManagement') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="show = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="mb-6">
|
||||
{{ t('profile.passkeyAppDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- 安全警告 -->
|
||||
<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="canRegisterPasskey" 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>
|
||||
|
||||
<!-- 已注册的通行密钥列表 -->
|
||||
<div v-if="passkeyList.length > 0" class="mt-6 px-4">
|
||||
<div
|
||||
v-for="passkey in passkeyList"
|
||||
:key="passkey.id"
|
||||
class="py-4 d-flex align-center justify-space-between border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<div class="text-body-1 font-weight-bold mb-1">{{ passkey.name }}</div>
|
||||
<div class="text-caption text-disabled d-flex flex-wrap gap-x-3">
|
||||
<span>{{ t('profile.createdAt') }} {{ formatDate(passkey.created_at) }}</span>
|
||||
<span v-if="passkey.last_used_at">
|
||||
{{ t('profile.lastUsedAt') }} {{ formatDateDifference(passkey.last_used_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<VBtn
|
||||
variant="flat"
|
||||
color="error"
|
||||
size="small"
|
||||
class="rounded delete-btn"
|
||||
@click="deletePassKey(passkey.id)"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<VAlert v-else type="info" variant="tonal" class="mt-6">
|
||||
{{ t('profile.noPasskeys') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-btn.delete-btn {
|
||||
min-width: 45px;
|
||||
padding: 0;
|
||||
background-color: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-btn.delete-btn:hover {
|
||||
background-color: rgba(var(--v-theme-error), 0.2);
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
@@ -122,7 +122,7 @@ function loadRecentSearches() {
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
getNavMenus().forEach(
|
||||
getNavMenus(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
@@ -134,11 +134,11 @@ function getMenus(): NavMenu[] {
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
getSettingTabs().forEach(
|
||||
getSettingTabs(t).forEach(
|
||||
item =>
|
||||
item &&
|
||||
menus.push({
|
||||
title: t('setting') + ' -> ' + item.title,
|
||||
title: t('navItems.setting') + ' -> ' + item.title,
|
||||
icon: item.icon,
|
||||
to: `/setting?tab=${item.tab}`,
|
||||
header: '',
|
||||
|
||||
@@ -140,7 +140,7 @@ onMounted(async () => {
|
||||
await fetchSiteInfo()
|
||||
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
|
||||
isLimit.value = true
|
||||
if (siteForm.value.apikey) siteType.value = 'api'
|
||||
if (siteForm.value.apikey || siteForm.value.token) siteType.value = 'api'
|
||||
}
|
||||
await loadDownloaderSetting()
|
||||
})
|
||||
@@ -224,15 +224,15 @@ onMounted(async () => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="cookie" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
|
||||
<VIcon size="20" start icon="mdi-cookie" />
|
||||
Cookie
|
||||
</div>
|
||||
</VTab>
|
||||
<VTab selected-class="v-tab--selected">
|
||||
<VTab value="api" selected-class="v-tab--selected">
|
||||
<div>
|
||||
<VIcon size="20" start icon="mdi-api" value="api" />
|
||||
<VIcon size="20" start icon="mdi-api" />
|
||||
API
|
||||
</div>
|
||||
</VTab>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import QRCode from 'qrcode'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -24,6 +24,9 @@ const emit = defineEmits(['done', 'close'])
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref(t('dialog.u115Auth.scanQrCode'))
|
||||
|
||||
@@ -61,6 +64,11 @@ async function getQrcode() {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
@@ -129,7 +137,13 @@ onUnmounted(() => {
|
||||
<VDivider />
|
||||
<VCardText class="pt-2 flex flex-col items-center justify-center">
|
||||
<div class="mt-6 rounded text-center p-3 border">
|
||||
<QrcodeVue class="mx-auto" :value="qrCodeContent" :size="200" />
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
|
||||
@@ -93,6 +93,7 @@ const userForm = ref<ExtendedUser>({
|
||||
wechat_userid: null,
|
||||
telegram_userid: null,
|
||||
slack_userid: null,
|
||||
discord_userid: null,
|
||||
vocechat_userid: null,
|
||||
synologychat_userid: null,
|
||||
},
|
||||
@@ -198,6 +199,7 @@ async function fetchUserInfo() {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
if (userForm.value) {
|
||||
userForm.value.avatar = userForm.value.avatar || avatar1
|
||||
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
currentUserName.value = userForm.value.name
|
||||
userName.value = userForm.value.name
|
||||
@@ -272,12 +274,10 @@ async function updateUser() {
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (userForm.value.nickname) {
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname ?? ''
|
||||
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
@@ -521,6 +521,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-slack"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.discord_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('dialog.userAddEdit.discord')"
|
||||
prepend-inner-icon="mdi-discord"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.vocechat_userid"
|
||||
|
||||
@@ -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'
|
||||
@@ -26,10 +26,9 @@ const { appMode } = usePWA()
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
storage: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
@@ -183,6 +182,8 @@ function changeSelectMode() {
|
||||
// 调API加载文件夹内的内容
|
||||
async function list_files() {
|
||||
loading.value = true
|
||||
const takeURISnapshot = () => [inProps.item.storage, inProps.item.path].join(':/');
|
||||
const prevURI = takeURISnapshot();
|
||||
emit('loading', true)
|
||||
|
||||
// 参数
|
||||
@@ -195,7 +196,12 @@ async function list_files() {
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
items.value = (await inProps.axios.request(config)) ?? []
|
||||
const data = ((await inProps.axios.request<FileItem[], FileItem[]>(config))) ?? []
|
||||
// 如果当前路径已经变化,则放弃此次加载结果
|
||||
if (prevURI !== takeURISnapshot()) {
|
||||
return;
|
||||
}
|
||||
items.value = data
|
||||
emit('loading', false)
|
||||
loading.value = false
|
||||
|
||||
@@ -294,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')
|
||||
@@ -312,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)
|
||||
@@ -389,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)
|
||||
}
|
||||
@@ -446,9 +452,9 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 监听item变化或者storage变化
|
||||
// 监听item变化
|
||||
watch(
|
||||
[() => inProps.item, () => inProps.storage],
|
||||
[() => inProps.item],
|
||||
async () => {
|
||||
// 清空列表
|
||||
items.value = []
|
||||
@@ -550,7 +556,7 @@ async function scrape(item: FileItem, confirm: boolean = true) {
|
||||
progressDialog.value = true
|
||||
progressText.value = t('file.scraping', { path: item.path })
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.storage}`, item)
|
||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
|
||||
|
||||
// 关闭进度条
|
||||
progressDialog.value = false
|
||||
@@ -808,7 +814,7 @@ onMounted(() => {
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
:target_storage="inProps.item.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -42,7 +42,7 @@ const availableHeight = computed(() => {
|
||||
const props = defineProps({
|
||||
storage: {
|
||||
type: String,
|
||||
default: 'local',
|
||||
required: true,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
@@ -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')
|
||||
@@ -223,7 +223,7 @@ watch(
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems && newItems.length > 0) {
|
||||
if (newItems) {
|
||||
// 过滤出目录项
|
||||
const dirs = newItems.filter(item => item.type === 'dir')
|
||||
|
||||
@@ -283,9 +283,6 @@ onMounted(async () => {
|
||||
await loadRootDirectories()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
updateHeight()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -309,7 +306,6 @@ onActivated(() => {
|
||||
<span>{{ t('file.rootDirectory') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载根目录 -->
|
||||
<div v-if="loading['/']" class="tree-loading">
|
||||
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
|
||||
|
||||
@@ -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'
|
||||
@@ -13,7 +13,6 @@ const display = useDisplay()
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
storages: Array as PropType<any[]>,
|
||||
storage: String,
|
||||
item: {
|
||||
type: Object as PropType<FileItem>,
|
||||
required: true,
|
||||
@@ -24,9 +23,13 @@ const inProps = defineProps({
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Function,
|
||||
type: Object as PropType<AxiosInstance>,
|
||||
required: true,
|
||||
},
|
||||
sort: {
|
||||
type: String,
|
||||
default: 'name',
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -38,15 +41,10 @@ const newFolderPopper = ref(false)
|
||||
// 新建文件名称
|
||||
const newFolderName = ref('')
|
||||
|
||||
// 排序方式
|
||||
const sort = ref('name')
|
||||
|
||||
// 调整排序方式
|
||||
function changeSort() {
|
||||
if (sort.value === 'name') sort.value = 'time'
|
||||
else sort.value = 'name'
|
||||
|
||||
emit('sortchanged', sort.value)
|
||||
const newSort = inProps.sort === 'name' ? 'time' : 'name'
|
||||
emit('sortchanged', newSort)
|
||||
}
|
||||
|
||||
// 计算PATH面包屑
|
||||
@@ -67,12 +65,12 @@ const pathSegments = computed(() => {
|
||||
|
||||
// 当前存储
|
||||
const storageObject = computed(() => {
|
||||
return inProps.storages?.find(item => item.value === inProps.storage)
|
||||
return inProps.storages?.find(item => item.value === inProps.item.storage)
|
||||
})
|
||||
|
||||
// 切换存储
|
||||
function changeStorage(code: string) {
|
||||
if (inProps.storage !== code) {
|
||||
if (inProps.item.storage!== code) {
|
||||
emit('storagechanged', code)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +111,7 @@ async function mkdir() {
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (sort.value === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
</script>
|
||||
|
||||
817
src/components/filter/TorrentFilterBar.vue
Normal file
817
src/components/filter/TorrentFilterBar.vue
Normal file
@@ -0,0 +1,817 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps<{
|
||||
// 筛选表单
|
||||
filterForm: Record<string, string[]>
|
||||
// 筛选选项
|
||||
filterOptions: Record<string, string[]>
|
||||
// 排序字段
|
||||
sortField: string
|
||||
// 排序方向
|
||||
sortType: 'asc' | 'desc'
|
||||
// 筛选后的总数量
|
||||
totalFilteredCount: number
|
||||
// 过滤项标题映射
|
||||
filterTitles: Record<string, string>
|
||||
// 排序标题映射
|
||||
sortTitles: Record<string, string>
|
||||
// 是否启用滚动动画
|
||||
enableAnimation?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
'update:sortField': [value: string]
|
||||
'update:sortType': [value: 'asc' | 'desc']
|
||||
'update:filterForm': [key: string, values: string[]]
|
||||
'selectAll': [key: string]
|
||||
'clearFilter': [key: string]
|
||||
'clearAllFilters': []
|
||||
'removeFilter': [key: string, value: string]
|
||||
}>()
|
||||
|
||||
// 过滤菜单相关
|
||||
const filterMenuOpen = ref(false)
|
||||
const currentFilter = ref('site')
|
||||
const currentFilterTitle = computed(() => props.filterTitles[currentFilter.value])
|
||||
const currentFilterOptions = computed(() => {
|
||||
return props.filterOptions[currentFilter.value]
|
||||
})
|
||||
|
||||
// 添加全部筛选菜单相关
|
||||
const allFilterMenuOpen = ref(false)
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in props.filterForm) {
|
||||
count += props.filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in props.filterForm) {
|
||||
if (props.filterForm[key].length > 0) {
|
||||
filters[key] = [...props.filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 开关全部筛选菜单
|
||||
function toggleAllFilterMenu() {
|
||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
||||
}
|
||||
|
||||
// 添加toggleFilterMenu函数
|
||||
function toggleFilterMenu(key: string) {
|
||||
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||
filterMenuOpen.value = false
|
||||
} else {
|
||||
currentFilter.value = key
|
||||
filterMenuOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选值变化
|
||||
function handleFilterChange(key: string, values: string[]) {
|
||||
emit('update:filterForm', key, values)
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
emit('selectAll', key)
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
emit('clearFilter', key)
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
emit('clearAllFilters')
|
||||
}
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
emit('removeFilter', key, value)
|
||||
}
|
||||
|
||||
// 滚动条引用
|
||||
const filterBarRef = ref<HTMLElement>()
|
||||
|
||||
/**
|
||||
* 自定义平滑滚动
|
||||
* @param element 元素
|
||||
* @param target 目标位置
|
||||
* @param duration 持续时间(ms)
|
||||
*/
|
||||
function smoothScroll(element: HTMLElement, target: number, duration: number) {
|
||||
const start = element.scrollLeft
|
||||
const change = target - start
|
||||
let startTime: number | null = null
|
||||
|
||||
function animate(currentTime: number) {
|
||||
if (startTime === null) startTime = currentTime
|
||||
const timeElapsed = currentTime - startTime
|
||||
const progress = Math.min(timeElapsed / duration, 1)
|
||||
|
||||
// 使用 ease-in-out 缓动函数
|
||||
const ease = progress < 0.5 ? 2 * progress * progress : -1 + (4 - 2 * progress) * progress
|
||||
element.scrollLeft = start + change * ease
|
||||
|
||||
if (timeElapsed < duration) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// 初始滚动动画
|
||||
onMounted(() => {
|
||||
if (filterBarRef.value) {
|
||||
useEventListener(filterBarRef, 'wheel', (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault()
|
||||
filterBarRef.value!.scrollLeft += e.deltaY
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (props.enableAnimation === false) return
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const el = filterBarRef.value
|
||||
if (el && el.clientWidth > 0 && el.scrollWidth > el.clientWidth) {
|
||||
// 检查当前视口范围内的最后一个元素(即右侧边缘处的元素)
|
||||
const containerRect = el.getBoundingClientRect()
|
||||
const children = Array.from(el.children) as HTMLElement[]
|
||||
const lastInViewport = children
|
||||
.filter(c => {
|
||||
const rect = c.getBoundingClientRect()
|
||||
return rect.left < containerRect.right
|
||||
})
|
||||
.pop()
|
||||
|
||||
if (lastInViewport) {
|
||||
const rect = lastInViewport.getBoundingClientRect()
|
||||
const visibleWidth = Math.min(rect.right, containerRect.right) - rect.left
|
||||
const visibleRatio = visibleWidth / rect.width
|
||||
|
||||
// 判断是否是列表最后一个元素
|
||||
const isLastItem = lastInViewport === children[children.length - 1]
|
||||
|
||||
// 1. 如果是最后一个元素,且显示比例超过80%,说明基本已经展示完了,不需要动画
|
||||
if (isLastItem && visibleRatio > 0.8) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 如果视口内最后一个元素显示比例在30%到80%之间(明显的截断状态),用户能感知到后面还有内容,不需要滚动提示
|
||||
// 比例过小(<0.3)可能看不清,非最后一个元素且比例过大(>0.8)可能误以为是结尾,这两种情况都需要提示
|
||||
if (visibleRatio > 0.3 && visibleRatio < 0.8) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动到底部 (1100ms)
|
||||
smoothScroll(el, el.scrollWidth - el.clientWidth, 1100)
|
||||
// 短暂停止后滚动回顶部 (1100ms)
|
||||
setTimeout(() => {
|
||||
smoothScroll(el, 0, 1100)
|
||||
}, 1600)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- PC端头部和筛选栏 -->
|
||||
<div class="search-header d-none d-sm-block">
|
||||
<VCard class="view-header mb-3">
|
||||
<div class="d-flex align-center pa-3">
|
||||
<!-- 固定位置:资源数量和排序 -->
|
||||
<div class="d-flex align-center flex-shrink-0">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="small"
|
||||
class="search-count me-3 flex-shrink-0"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<VBtn variant="text" size="small" class="sort-btn" :color="undefined">
|
||||
<template #prepend>
|
||||
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||
</template>
|
||||
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||
|
||||
<VMenu activator="parent" transition="slide-y-transition">
|
||||
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||
<!-- 升序/降序 选项 -->
|
||||
<VListItem
|
||||
value="asc"
|
||||
:active="sortType === 'asc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'asc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
value="desc"
|
||||
:active="sortType === 'desc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'desc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-1" />
|
||||
|
||||
<!-- 排序字段选项 -->
|
||||
<VListItem
|
||||
v-for="(title, key) in sortTitles"
|
||||
:key="key"
|
||||
:value="key"
|
||||
:active="sortField === key"
|
||||
color="primary"
|
||||
@click="emit('update:sortField', key as string)"
|
||||
class="px-3"
|
||||
>
|
||||
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<div class="filter-divider"></div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动区域:筛选条件 -->
|
||||
<div class="filter-bar" ref="filterBarRef">
|
||||
<!-- 筛选按钮 -->
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
>
|
||||
{{ title }}
|
||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ filterForm[key].length }}
|
||||
</VChip>
|
||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
||||
<VCard max-width="20rem">
|
||||
<VCardText class="filter-menu-content">
|
||||
<div class="flex justify-between">
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[key]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="filter-btn me-2"
|
||||
prepend-icon="mdi-filter-variant"
|
||||
rounded="pill"
|
||||
@click="toggleAllFilterMenu"
|
||||
>
|
||||
{{ t('torrent.allFilters') }}
|
||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ getFilterCount }}
|
||||
</VChip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getFilterCount > 0" class="selected-filters">
|
||||
<div class="d-flex align-center">
|
||||
<div class="d-flex flex-wrap align-center flex-grow-1">
|
||||
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||
<VChip
|
||||
v-for="(value, index) in values"
|
||||
:key="`${key}-${index}`"
|
||||
color="primary"
|
||||
size="small"
|
||||
closable
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 mt-1 filter-tag"
|
||||
@click:close="removeFilter(key as string, value)"
|
||||
>
|
||||
<VIcon size="small" :icon="getFilterIcon(key as string)" class="me-1"></VIcon>
|
||||
<strong>{{ filterTitles[key as string] }}:</strong> {{ value }}
|
||||
</VChip>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 清除全部筛选按钮 -->
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
class="ms-2 flex-shrink-0"
|
||||
prepend-icon="mdi-close-circle-outline"
|
||||
>
|
||||
{{ t('torrent.clearFilters') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- 移动端头部和筛选区域 -->
|
||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
||||
<div class="view-header">
|
||||
<div class="d-flex align-center flex-wrap pa-2">
|
||||
<div class="d-flex align-center w-100">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="search-count me-auto"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ totalFilteredCount }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<!-- 排序选择 -->
|
||||
<VBtn variant="text" size="small" class="sort-btn mobile-sort-btn" :color="undefined">
|
||||
<template #prepend>
|
||||
<VIcon :icon="sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending'" class="me-1" />
|
||||
</template>
|
||||
<span class="text-subtitle-2">{{ sortTitles[sortField] }}</span>
|
||||
<VIcon icon="mdi-chevron-down" size="16" class="ms-1" />
|
||||
|
||||
<VMenu activator="parent" transition="slide-y-transition">
|
||||
<VList density="compact" min-width="120" class="sort-menu-list">
|
||||
<!-- 升序/降序 选项 -->
|
||||
<VListItem
|
||||
value="asc"
|
||||
:active="sortType === 'asc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'asc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-ascending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.ascending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem
|
||||
value="desc"
|
||||
:active="sortType === 'desc'"
|
||||
color="primary"
|
||||
@click="emit('update:sortType', 'desc')"
|
||||
class="px-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-sort-descending" size="small" class="me-2" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('common.descending') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-1" />
|
||||
|
||||
<!-- 排序字段选项 -->
|
||||
<VListItem
|
||||
v-for="(title, key) in sortTitles"
|
||||
:key="key"
|
||||
:value="key"
|
||||
:active="sortField === key"
|
||||
color="primary"
|
||||
@click="emit('update:sortField', key as string)"
|
||||
class="px-3"
|
||||
>
|
||||
<VListItemTitle>{{ title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 筛选图标按钮区域 -->
|
||||
<div class="filter-buttons-grid w-100 mt-2">
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
>
|
||||
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ title }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="filterForm[key].length > 0"
|
||||
:content="filterForm[key].length"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="getFilterCount > 0"
|
||||
:content="getFilterCount"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 全部筛选弹窗 -->
|
||||
<VDialog
|
||||
v-model="allFilterMenuOpen"
|
||||
max-width="50rem"
|
||||
location="center"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||
<span>{{ t('torrent.allFilters') }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
class="me-10"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
{{ t('torrent.clearAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="all-filters-grid">
|
||||
<VCard
|
||||
v-for="(title, key) in filterTitles"
|
||||
variant="tonal"
|
||||
:key="key"
|
||||
class="filter-section"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ title }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[key]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(key, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||
<span>{{ currentFilterTitle }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="filterForm[currentFilter].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(currentFilter)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="filterForm[currentFilter]"
|
||||
@update:model-value="(val: string[]) => handleFilterChange(currentFilter, val)"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in currentFilterOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header,
|
||||
.search-header-mobile {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
height: 32px !important;
|
||||
font-weight: 500;
|
||||
padding-inline: 12px 6px !important;
|
||||
}
|
||||
|
||||
.sort-btn .v-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
}
|
||||
|
||||
.sort-btn :deep(.v-btn__prepend) {
|
||||
margin-inline-end: 2px !important;
|
||||
}
|
||||
|
||||
.sort-menu-list {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.sort-menu-list :deep(.v-list-item__prepend > .v-icon) {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.filter-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-bar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 24px;
|
||||
inline-size: 1px;
|
||||
margin-block: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.filter-menu-content {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
font-size: 18px;
|
||||
margin-block-end: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.all-filters-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
}
|
||||
</style>
|
||||
82
src/components/toast/VersionUpdateToast.vue
Normal file
82
src/components/toast/VersionUpdateToast.vue
Normal 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>
|
||||
60
src/composables/useInfiniteScroll.ts
Normal file
60
src/composables/useInfiniteScroll.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
|
||||
/**
|
||||
* 无限滚动 composable
|
||||
* 用于管理分页显示和无限滚动加载
|
||||
* @param sourceData - 源数据(响应式引用)
|
||||
* @param pageSize - 每页显示数量,默认20
|
||||
*/
|
||||
export function useInfiniteScroll<T>(
|
||||
sourceData: Ref<T[]>,
|
||||
pageSize: number = 20
|
||||
) {
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<T[]>([])
|
||||
|
||||
// 剩余数据列表(用于无限滚动)
|
||||
const remainingDataList = ref<T[]>([]) as Ref<T[]>
|
||||
|
||||
// 初始化数据
|
||||
function initData() {
|
||||
if (sourceData.value?.length) {
|
||||
// 显示前 pageSize 个
|
||||
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
|
||||
// 保存剩余数据
|
||||
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
|
||||
} else {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
|
||||
// 从 remainingDataList 中获取最前面的 pageSize 个元素
|
||||
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
|
||||
;(displayDataList.value as T[]).push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 重置数据
|
||||
function reset() {
|
||||
displayDataList.value = []
|
||||
remainingDataList.value = []
|
||||
}
|
||||
|
||||
// 监听源数据变化,重新初始化
|
||||
watch(sourceData, () => {
|
||||
initData()
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
return {
|
||||
displayDataList,
|
||||
remainingDataList,
|
||||
initData,
|
||||
loadMore,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
502
src/composables/useTorrentFilter.ts
Normal file
502
src/composables/useTorrentFilter.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import type { Context } from '@/api/types'
|
||||
import { cloneDeepWith } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 卡片视图的分组数据类型
|
||||
interface SearchTorrent extends Context {
|
||||
more?: Array<Context>
|
||||
}
|
||||
|
||||
interface GroupedItem {
|
||||
data: SearchTorrent
|
||||
originalIndex: number
|
||||
}
|
||||
|
||||
// 筛选状态类型
|
||||
export interface FilterState {
|
||||
filterForm: Record<string, string[]>
|
||||
filterOptions: Record<string, string[]>
|
||||
sortField: string
|
||||
sortType: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// useTorrentFilter composable
|
||||
export function useTorrentFilter() {
|
||||
const { t } = useI18n()
|
||||
|
||||
// 过滤表单
|
||||
const filterForm: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 统一存储过滤选项
|
||||
const filterOptions: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
})
|
||||
|
||||
// 排序字段
|
||||
const sortField = ref('default')
|
||||
// 排序方向
|
||||
const sortType = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// 过滤项映射
|
||||
const filterTitles: Record<string, string> = {
|
||||
site: t('torrent.filterSite'),
|
||||
season: t('torrent.filterSeason'),
|
||||
freeState: t('torrent.filterFreeState'),
|
||||
videoCode: t('torrent.filterVideoCode'),
|
||||
edition: t('torrent.filterEdition'),
|
||||
resolution: t('torrent.filterResolution'),
|
||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
||||
}
|
||||
|
||||
// 排序中文名
|
||||
const sortTitles: Record<string, string> = {
|
||||
default: t('torrent.sortDefault'),
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 筛选后数据的原始索引列表
|
||||
const filteredIndices = ref<number[]>([])
|
||||
|
||||
// 筛选后的总数量
|
||||
const totalFilteredCount = ref(0)
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(data: Context) {
|
||||
const { torrent_info, meta_info } = data
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
if (value && !options.includes(value)) {
|
||||
options.push(value)
|
||||
// 如果是season选项,立即触发重新计算
|
||||
if (options === filterOptions.season) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
||||
optionValue(filterOptions.edition, meta_info?.edition)
|
||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 直接对季集选项进行排序的函数
|
||||
function sortSeasonOptions() {
|
||||
if (filterOptions.season.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
original: option,
|
||||
seasonNum: 0,
|
||||
episodeNum: 0,
|
||||
maxEpisodeNum: 0,
|
||||
isWholeSeason: false,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
const seasonNum = parseInt(match[1], 10)
|
||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
||||
const isWholeSeason = !match[3]
|
||||
|
||||
return {
|
||||
original: option,
|
||||
seasonNum,
|
||||
episodeNum,
|
||||
maxEpisodeNum,
|
||||
isWholeSeason,
|
||||
index,
|
||||
}
|
||||
})
|
||||
|
||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
||||
|
||||
wholeSeasons.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum
|
||||
}
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
episodes.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum
|
||||
}
|
||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
||||
if (aMaxEp !== bMaxEp) {
|
||||
return bMaxEp - aMaxEp
|
||||
}
|
||||
if (a.episodeNum !== b.episodeNum) {
|
||||
return b.episodeNum - a.episodeNum
|
||||
}
|
||||
return a.index - b.index
|
||||
})
|
||||
|
||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
||||
filterOptions.season = sortedOptions
|
||||
}
|
||||
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
// 筛选列表视图数据(不分组)
|
||||
function filterRowData(items: Context[] | undefined): Context[] {
|
||||
// 重置状态
|
||||
filteredIndices.value = []
|
||||
|
||||
// 清空并重新初始化过滤选项
|
||||
for (const key in filterOptions) {
|
||||
filterOptions[key] = []
|
||||
}
|
||||
|
||||
if (!items?.length) {
|
||||
totalFilteredCount.value = 0
|
||||
return []
|
||||
}
|
||||
|
||||
// 首先收集所有过滤选项
|
||||
items.forEach(data => {
|
||||
initOptions(data)
|
||||
})
|
||||
|
||||
// 筛选数据
|
||||
let filteredData: Context[] = []
|
||||
|
||||
items.forEach((data, index) => {
|
||||
const { meta_info, torrent_info } = data
|
||||
if (
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
) {
|
||||
filteredData.push(data)
|
||||
filteredIndices.value.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
totalFilteredCount.value = filteredData.length
|
||||
|
||||
// 排序
|
||||
filteredData = sortData(filteredData)
|
||||
|
||||
// 确保季集选项排序
|
||||
if (filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
|
||||
return filteredData
|
||||
}
|
||||
|
||||
// 筛选卡片视图数据(分组)
|
||||
function filterCardData(items: Context[] | undefined): SearchTorrent[] {
|
||||
// 重置状态
|
||||
filteredIndices.value = []
|
||||
|
||||
// 清空并重新初始化过滤选项
|
||||
for (const key in filterOptions) {
|
||||
filterOptions[key] = []
|
||||
}
|
||||
|
||||
if (!items?.length) {
|
||||
totalFilteredCount.value = 0
|
||||
return []
|
||||
}
|
||||
|
||||
// 数据分组
|
||||
const groupMap = new Map<string, GroupedItem[]>()
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const { torrent_info, meta_info } = item
|
||||
// init options
|
||||
initOptions(item)
|
||||
// group data
|
||||
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||
const groupedItem = { data: item, originalIndex: index }
|
||||
if (groupMap.has(key)) {
|
||||
const group = groupMap.get(key)
|
||||
group?.push(groupedItem)
|
||||
} else {
|
||||
groupMap.set(key, [groupedItem])
|
||||
}
|
||||
})
|
||||
|
||||
// 筛选数据
|
||||
const filteredData: SearchTorrent[] = []
|
||||
let matchCount = 0
|
||||
// 临时存储:每个分组的第一个原始索引
|
||||
const groupIndexMap = new Map<SearchTorrent, number>()
|
||||
|
||||
groupMap.forEach(value => {
|
||||
if (value.length > 0) {
|
||||
const matchData = value.filter(item => {
|
||||
const { meta_info, torrent_info } = item.data
|
||||
return (
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
)
|
||||
})
|
||||
if (matchData.length > 0) {
|
||||
matchCount += matchData.length
|
||||
const firstItem = matchData[0]
|
||||
const firstData = cloneDeepWith(firstItem.data) as SearchTorrent
|
||||
if (matchData.length > 1) firstData.more = matchData.slice(1).map(x => x.data)
|
||||
filteredData.push(firstData)
|
||||
// 存储该分组的第一个原始索引
|
||||
groupIndexMap.set(firstData, firstItem.originalIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
totalFilteredCount.value = matchCount
|
||||
|
||||
// 排序数据
|
||||
const sortedData = sortCardData(filteredData)
|
||||
|
||||
// 在排序后重新构建 filteredIndices,保持与排序后顺序一致
|
||||
filteredIndices.value = sortedData.map(item => groupIndexMap.get(item) || 0)
|
||||
|
||||
// 确保季集选项排序
|
||||
if (filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
|
||||
return sortedData
|
||||
}
|
||||
|
||||
// 排序列表数据
|
||||
function sortData(data: Context[]): Context[] {
|
||||
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||
|
||||
return data.sort((a, b) => {
|
||||
let result = 0
|
||||
switch (sortField.value) {
|
||||
case 'site':
|
||||
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||
break
|
||||
case 'size':
|
||||
result = a.torrent_info.size - b.torrent_info.size
|
||||
break
|
||||
case 'seeder':
|
||||
result = a.torrent_info.seeders - b.torrent_info.seeders
|
||||
break
|
||||
case 'publishTime':
|
||||
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||
break
|
||||
case 'default':
|
||||
default:
|
||||
result = a.torrent_info.pri_order - b.torrent_info.pri_order
|
||||
break
|
||||
}
|
||||
return result * sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 排序卡片数据
|
||||
function sortCardData(data: SearchTorrent[]): SearchTorrent[] {
|
||||
if (sortField.value === 'default') {
|
||||
return data
|
||||
}
|
||||
const sortOrder = sortType.value === 'asc' ? 1 : -1
|
||||
return data.sort((a, b) => {
|
||||
let result = 0
|
||||
switch (sortField.value) {
|
||||
case 'site':
|
||||
result = (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||
break
|
||||
case 'size':
|
||||
result = (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
|
||||
break
|
||||
case 'seeder':
|
||||
result = (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
|
||||
break
|
||||
case 'publishTime':
|
||||
result = new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||
break
|
||||
}
|
||||
return result * sortOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in filterForm) {
|
||||
count += filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key].length > 0) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
const index = filterForm[key].indexOf(value)
|
||||
if (index !== -1) {
|
||||
filterForm[key].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
filterForm[key] = [...filterOptions[key]]
|
||||
}
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 处理排序图标点击
|
||||
const handleSortIconClick = () => {
|
||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
// 获取筛选后的原始索引列表
|
||||
function getFilteredIndices() {
|
||||
return filteredIndices.value
|
||||
}
|
||||
|
||||
// 检查是否有活动的筛选条件
|
||||
function hasActiveFilters() {
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key] && filterForm[key].length > 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当前筛选条件
|
||||
function getFilterForm() {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
return filters
|
||||
}
|
||||
|
||||
// 设置筛选条件
|
||||
function setFilterForm(filters: Record<string, string[]>) {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = filters[key] ? [...filters[key]] : []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取完整的筛选状态
|
||||
function getFilterState(): FilterState {
|
||||
return {
|
||||
filterForm: getFilterForm(),
|
||||
filterOptions: { ...filterOptions },
|
||||
sortField: sortField.value,
|
||||
sortType: sortType.value,
|
||||
}
|
||||
}
|
||||
|
||||
// 设置完整的筛选状态
|
||||
function setFilterState(state: FilterState) {
|
||||
setFilterForm(state.filterForm)
|
||||
sortField.value = state.sortField
|
||||
sortType.value = state.sortType
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
filterForm,
|
||||
filterOptions,
|
||||
sortField,
|
||||
sortType,
|
||||
filteredIndices,
|
||||
totalFilteredCount,
|
||||
// 标题映射
|
||||
filterTitles,
|
||||
sortTitles,
|
||||
// 计算属性
|
||||
getFilterCount,
|
||||
getSelectedFilters,
|
||||
// 筛选方法
|
||||
filterRowData,
|
||||
filterCardData,
|
||||
// 操作方法
|
||||
removeFilter,
|
||||
clearAllFilters,
|
||||
clearFilter,
|
||||
selectAll,
|
||||
getFilterIcon,
|
||||
handleSortIconClick,
|
||||
// 状态管理方法
|
||||
getFilteredIndices,
|
||||
hasActiveFilters,
|
||||
getFilterForm,
|
||||
setFilterForm,
|
||||
getFilterState,
|
||||
setFilterState,
|
||||
sortSeasonOptions,
|
||||
}
|
||||
}
|
||||
174
src/composables/useVersionChecker.ts
Normal file
174
src/composables/useVersionChecker.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,12 @@ 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',
|
||||
ascending: 'Ascending',
|
||||
descending: 'Descending',
|
||||
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
||||
clearCache: 'Clear Cache',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -129,6 +136,7 @@ export default {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
auto: 'Follow System',
|
||||
autoUI: 'Auto',
|
||||
transparent: 'Transparent',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
@@ -238,16 +246,32 @@ export default {
|
||||
wallpapers: 'Wallpapers',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
otpCode: 'Two-Factor Code',
|
||||
otpCode: 'Verification Code',
|
||||
stayLoggedIn: 'Stay Logged In',
|
||||
login: 'Login',
|
||||
networkError: 'Login failed, please check your network connection!',
|
||||
authFailure: 'Login failed, please check your username, password or two-factor authentication!',
|
||||
authFailure: 'Login failed, please check your username, password or secondary verification!',
|
||||
permissionDenied: 'Login failed, you do not have permission to access!',
|
||||
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
|
||||
serverError: 'Login failed, server error!',
|
||||
loginFailed: 'Login Failed',
|
||||
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
||||
secondaryVerification: 'Secondary Verification',
|
||||
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',
|
||||
passkeyNotSupported: 'Current browser does not support Passkeys',
|
||||
passkeySecureContextRequired: 'Passkey requires HTTPS secure connection',
|
||||
passkeyVerifyFailed: 'Passkey verification failed',
|
||||
passkeyVerifyFailedRetry: 'Passkey verification failed, please try again',
|
||||
mfa: {
|
||||
selectVerificationMethod: 'Please select a verification method',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: 'Start',
|
||||
@@ -380,7 +404,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 +482,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',
|
||||
@@ -924,6 +960,9 @@ export default {
|
||||
searching: 'Searching, please wait...',
|
||||
noData: 'No Data',
|
||||
noResourceFound: 'No resources found',
|
||||
aiRecommend: 'AI Recommendation',
|
||||
reRecommend: 'Regenerate Recommendation',
|
||||
aiRecommendError: 'AI Recommendation Failed',
|
||||
},
|
||||
browse: {
|
||||
actor: 'Actor',
|
||||
@@ -1197,6 +1236,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 +1256,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
clearCache: 'Clear Cache',
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
@@ -1251,15 +1292,29 @@ export default {
|
||||
llmProviderHint: 'Select the LLM service provider to use',
|
||||
llmModel: 'LLM Model Name',
|
||||
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
|
||||
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
|
||||
llmApiKey: 'LLM API Key',
|
||||
llmApiKeyHint: 'API key from the LLM service provider for authentication',
|
||||
llmApiKeyPlaceholder: 'Please enter API key',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||
aiAgentGlobal: 'Global AI Assistant',
|
||||
aiAgentGlobalHint:
|
||||
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
||||
downloaders: 'Downloaders',
|
||||
downloadersDesc: 'Only the default downloader will be used by default.',
|
||||
aiRecommendEnabled: 'AI Search Recommendation',
|
||||
aiRecommendEnabledHint:
|
||||
'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
|
||||
aiRecommendUserPreference: 'User Preference',
|
||||
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
|
||||
aiRecommendMaxItemsHint:
|
||||
'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
|
||||
mediaServers: 'Media Servers',
|
||||
mediaServersDesc: 'All enabled media servers will be used.',
|
||||
trimeMedia: 'TrimeMedia',
|
||||
@@ -1361,6 +1416,8 @@ export default {
|
||||
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
|
||||
encodingDetectionPerformanceModeHint:
|
||||
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
|
||||
transferThreads: 'File Transfer Threads',
|
||||
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
|
||||
tokenizedSearch: 'Tokenized Search',
|
||||
tokenizedSearchHint:
|
||||
'Improve organization history search precision, but may increase performance overhead and unexpected results',
|
||||
@@ -1621,6 +1678,7 @@ export default {
|
||||
storage: 'Storage',
|
||||
storageDesc: 'Set up local or cloud storage.',
|
||||
directory: 'Directory',
|
||||
mediaType: 'Media Type',
|
||||
directoryDesc: 'Set up media file organization directory structure, matching in sequence.',
|
||||
organizeAndScrap: 'Organization & Scraping',
|
||||
organizeAndScrapDesc: 'Set rename format, scraping options, etc.',
|
||||
@@ -1642,6 +1700,25 @@ export default {
|
||||
storageSaveSuccess: 'Storage settings saved successfully',
|
||||
storageSaveFailed: 'Failed to save storage settings!',
|
||||
},
|
||||
category: {
|
||||
title: 'Category Policy',
|
||||
subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',
|
||||
movie: 'Movies',
|
||||
tv: 'TV Shows',
|
||||
name: 'Category Name (Directory)',
|
||||
genre: 'Genre',
|
||||
language: 'Language',
|
||||
languagePlaceholder: 'e.g., en,fr,zh (comma separated)',
|
||||
country: 'Country/Region',
|
||||
countryPlaceholder: 'e.g., US,CN,JP',
|
||||
year: 'Year',
|
||||
yearPlaceholder: 'e.g., 2023, 2020-2024',
|
||||
addMovie: 'Add Movie Category',
|
||||
addTv: 'Add TV Category',
|
||||
saveSuccess: 'Category policy saved successfully',
|
||||
loadFailed: 'Failed to load category configuration',
|
||||
saveFailed: 'Save failed: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: 'Custom Rules',
|
||||
customRulesDesc: 'Custom priority rule items',
|
||||
@@ -1737,7 +1814,7 @@ export default {
|
||||
},
|
||||
cache: {
|
||||
title: 'Cache Management',
|
||||
subtitle: 'Manage torrent cache data',
|
||||
subtitle: 'Manage cached site resources',
|
||||
totalCount: 'Total Count',
|
||||
siteCount: 'Site Count',
|
||||
filterByTitle: 'Filter by Title',
|
||||
@@ -1833,6 +1910,7 @@ export default {
|
||||
wechat: 'WeChat UserID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
vocechat: 'VoceChat UserID',
|
||||
synologyChat: 'SynologyChat UserID',
|
||||
webPush: 'WebPush',
|
||||
@@ -1914,6 +1992,8 @@ export default {
|
||||
startDownload: 'Start Download',
|
||||
downloadSuccess: '{site} {title} downloaded successfully!',
|
||||
downloadFailed: '{site} {title} download failed: {message}!',
|
||||
showAdvancedOptions: 'Show Advanced Options',
|
||||
hideAdvancedOptions: 'Hide Advanced Options',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: 'Share Subscription',
|
||||
@@ -2521,6 +2601,7 @@ export default {
|
||||
noRecentPlugins: 'None',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: 'Please delete all Passkeys before clearing the authenticator!',
|
||||
personalInfo: 'Personal Information',
|
||||
uploadNewAvatar: 'Upload New Avatar',
|
||||
avatarFormatError: 'The uploaded file does not meet requirements, please select a new avatar',
|
||||
@@ -2541,21 +2622,60 @@ export default {
|
||||
wechatUser: 'WeChat User',
|
||||
telegramUser: 'Telegram User',
|
||||
slackUser: 'Slack User',
|
||||
discordUser: 'Discord User',
|
||||
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.',
|
||||
passkeyAppDescription:
|
||||
'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
|
||||
passkeyName: 'Passkey Name',
|
||||
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
|
||||
registerPasskey: 'Register Passkey',
|
||||
createdAt: 'Created',
|
||||
lastUsedAt: 'Last used',
|
||||
noPasskeys: 'You haven’t 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',
|
||||
deletePasskey: 'Delete Passkey',
|
||||
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}!',
|
||||
authenticatorApp: 'Authenticator App',
|
||||
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.',
|
||||
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.',
|
||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
|
||||
secretKeyTip:
|
||||
"If you're having trouble with the QR code, select manual entry in your app and enter the code above.",
|
||||
enterVerificationCode: 'Enter verification code to confirm enabling two-factor authentication',
|
||||
@@ -2672,6 +2792,11 @@ export default {
|
||||
hostRequired: 'Host cannot be empty',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
passwordRequired: 'Password cannot be empty',
|
||||
pathMapping: 'Path Mapping',
|
||||
pathMappingRequired: 'Path cannot be empty',
|
||||
pathMappingError: 'Must start with /',
|
||||
storagePath: 'Storage Path',
|
||||
downloadPath: 'Download Path',
|
||||
},
|
||||
filterRule: {
|
||||
title: 'Filter Rule',
|
||||
@@ -2956,6 +3081,26 @@ export default {
|
||||
unsupportedDownloaderType: 'Unsupported downloader type: {type}',
|
||||
unsupportedMediaServerType: 'Unsupported media server type: {type}',
|
||||
unsupportedNotificationType: 'Unsupported notification type: {type}',
|
||||
storageTestFailed: 'Storage test failed',
|
||||
downloaderTestFailed: 'Downloader test failed',
|
||||
downloaderNotSelected: 'No downloader selected',
|
||||
mediaServerTestFailed: 'Media server test failed',
|
||||
mediaServerNotSelected: 'No media server selected',
|
||||
notificationTestFailed: 'Notification test failed',
|
||||
notificationNotSelected: 'No notification type selected',
|
||||
saveStepFailed: 'Failed to save step settings',
|
||||
basicSettingsSaved: 'Basic settings saved successfully',
|
||||
saveBasicSettingsFailed: 'Failed to save basic settings',
|
||||
storageSettingsSaved: 'Storage settings saved successfully',
|
||||
saveStorageSettingsFailed: 'Failed to save storage settings',
|
||||
downloaderSettingsSaved: 'Downloader settings saved successfully',
|
||||
saveDownloaderSettingsFailed: 'Failed to save downloader settings',
|
||||
mediaServerSettingsSaved: 'Media server settings saved successfully',
|
||||
saveMediaServerSettingsFailed: 'Failed to save media server settings',
|
||||
notificationSettingsSaved: 'Notification settings saved successfully',
|
||||
saveNotificationSettingsFailed: 'Failed to save notification settings',
|
||||
preferenceSettingsSaved: 'Preference settings saved successfully',
|
||||
savePreferenceSettingsFailed: 'Failed to save preference settings',
|
||||
passwordUpdateSuccess: 'Password updated successfully',
|
||||
userCreateSuccess: 'User created successfully',
|
||||
passwordUpdateFailed: 'Failed to update password',
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: '保存中',
|
||||
reset: '重置',
|
||||
theme: '主题',
|
||||
uiMode: '界面布局',
|
||||
language: '语言',
|
||||
pleaseWait: '请稍候...',
|
||||
viewDetails: '查看详情',
|
||||
@@ -66,6 +67,12 @@ export default {
|
||||
serviceUnavailable: '服务不可用',
|
||||
status: '状态',
|
||||
preset: '预设',
|
||||
refresh: '刷新',
|
||||
swUpdateReady: '新版本已就绪,请刷新页面以获取最新功能',
|
||||
ascending: '升序',
|
||||
descending: '降序',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -129,6 +136,7 @@ export default {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
auto: '跟随系统',
|
||||
autoUI: '自动',
|
||||
transparent: '透明',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
@@ -237,16 +245,32 @@ export default {
|
||||
wallpapers: '壁纸',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
otpCode: '双重验证码',
|
||||
otpCode: '验证码',
|
||||
stayLoggedIn: '保持登录',
|
||||
login: '登录',
|
||||
networkError: '登录失败,请检查网络连接!',
|
||||
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
|
||||
authFailure: '登录失败,请检查用户名、密码或二次验证是否正确!',
|
||||
permissionDenied: '登录失败,您没有权限访问!',
|
||||
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||
secondaryVerification: '二次验证',
|
||||
loginWithPasskey: '使用通行密钥登录',
|
||||
loginWithOtp: '使用验证码登录',
|
||||
orUsePasskey: '或使用通行密钥进行验证',
|
||||
verifyWithPasskey: '使用通行密钥验证',
|
||||
otpPlaceholder: '请输入6位验证码',
|
||||
passkeyLoginStartFailed: '启动通行密钥认证失败',
|
||||
passkeyNotSelected: '未选择通行密钥',
|
||||
passkeyLoginFailed: '通行密钥登录失败',
|
||||
passkeyAuthCanceled: '通行密钥认证被取消',
|
||||
passkeyNotSupported: '当前浏览器不支持通行密钥',
|
||||
passkeySecureContextRequired: '通行密钥需要 HTTPS 安全连接',
|
||||
passkeyVerifyFailed: '通行密钥验证失败',
|
||||
passkeyVerifyFailedRetry: '通行密钥验证失败,请重试',
|
||||
mfa: {
|
||||
selectVerificationMethod: '请选择验证方式',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: '开始',
|
||||
@@ -379,7 +403,7 @@ export default {
|
||||
username: '用户名',
|
||||
usernameHint: '用于登录系统的用户名',
|
||||
password: '密码',
|
||||
passwordHint: '用于登录系统的密码',
|
||||
passwordHint: '请输入登录密码',
|
||||
confirmPassword: '确认密码',
|
||||
confirmPasswordHint: '请再次输入密码以确认',
|
||||
role: '角色',
|
||||
@@ -456,6 +480,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',
|
||||
@@ -921,6 +957,9 @@ export default {
|
||||
searching: '正在搜索,请稍候...',
|
||||
noData: '没有数据',
|
||||
noResourceFound: '未搜索到任何资源',
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
},
|
||||
browse: {
|
||||
actor: '演员',
|
||||
@@ -1194,6 +1233,7 @@ export default {
|
||||
title: '关于 MoviePilot',
|
||||
softwareVersion: '软件版本',
|
||||
frontendVersion: '前端版本',
|
||||
browserVersion: '浏览器缓存版本',
|
||||
authVersion: '认证资源版本',
|
||||
indexerVersion: '站点资源版本',
|
||||
configDir: '配置目录',
|
||||
@@ -1201,7 +1241,7 @@ export default {
|
||||
timezone: '时区',
|
||||
latest: '最新',
|
||||
supportingSites: '支持站点',
|
||||
support: '支援',
|
||||
support: '支持',
|
||||
documentation: '文档',
|
||||
feedback: '问题反馈',
|
||||
channel: '发布频道',
|
||||
@@ -1213,6 +1253,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
clearCache: '清除缓存',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1247,15 +1288,28 @@ export default {
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||
llmApiKey: 'LLM API密钥',
|
||||
llmApiKeyHint: 'LLM服务提供商的API密钥,用于身份验证',
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
|
||||
advancedSettings: '高级设置',
|
||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||
downloaders: '下载器',
|
||||
downloadersDesc: '只有默认下载器才会被默认使用。',
|
||||
aiRecommendEnabled: '搜索结果智能推荐',
|
||||
aiRecommendEnabledHint:
|
||||
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
|
||||
aiRecommendUserPreference: '用户偏好',
|
||||
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好,例如:4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: '智能推荐分析条目上限',
|
||||
aiRecommendMaxItemsHint:
|
||||
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
|
||||
mediaServers: '媒体服务器',
|
||||
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
|
||||
trimeMedia: '飞牛影视',
|
||||
@@ -1352,6 +1406,8 @@ export default {
|
||||
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
||||
encodingDetectionPerformanceMode: '编码探测性能模式',
|
||||
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
|
||||
transferThreads: '文件整理线程数',
|
||||
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
|
||||
tokenizedSearch: '分词搜索',
|
||||
tokenizedSearchHint: '提升整理历史记录搜索精度,但可能增加性能开销和意外结果',
|
||||
tmdbLanguage: {
|
||||
@@ -1621,6 +1677,25 @@ export default {
|
||||
storageSaveSuccess: '存储设置保存成功',
|
||||
storageSaveFailed: '存储设置保存失败!',
|
||||
},
|
||||
category: {
|
||||
title: '分类策略',
|
||||
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
|
||||
movie: '电影 (Movie)',
|
||||
tv: '电视剧 (TV)',
|
||||
name: '分类名称 (目录名)',
|
||||
genre: '内容类型 (Genre)',
|
||||
language: '语种 (Language)',
|
||||
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
|
||||
country: '国家/地区 (Country)',
|
||||
countryPlaceholder: '如: US,CN,JP',
|
||||
year: '年份 (Year)',
|
||||
yearPlaceholder: '如: 2023, 2020-2024',
|
||||
addMovie: '添加电影分类',
|
||||
addTv: '添加电视剧分类',
|
||||
saveSuccess: '分类策略保存成功',
|
||||
loadFailed: '加载分类配置失败',
|
||||
saveFailed: '保存失败: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: '自定义规则',
|
||||
customRulesDesc: '自定义优先级规则项',
|
||||
@@ -1809,6 +1884,7 @@ export default {
|
||||
wechat: '微信ID',
|
||||
telegram: 'Telegram ID',
|
||||
slack: 'Slack ID',
|
||||
discord: 'Discord ID',
|
||||
vocechat: 'VoceChat ID',
|
||||
synologyChat: 'SynologyChat ID',
|
||||
webPush: 'WebPush',
|
||||
@@ -1890,6 +1966,8 @@ export default {
|
||||
startDownload: '开始下载',
|
||||
downloadSuccess: '{site} {title} 下载成功!',
|
||||
downloadFailed: '{site} {title} 下载失败:{message}!',
|
||||
showAdvancedOptions: '显示高级选项',
|
||||
hideAdvancedOptions: '隐藏高级选项',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享订阅',
|
||||
@@ -2490,6 +2568,7 @@ export default {
|
||||
noRecentPlugins: '无',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: '请先删除所有通行密钥后再清除身份验证器!',
|
||||
personalInfo: '个人信息',
|
||||
uploadNewAvatar: '上传新头像',
|
||||
avatarFormatError: '上传的文件不符合要求,请重新选择头像',
|
||||
@@ -2510,21 +2589,58 @@ export default {
|
||||
wechatUser: '微信用户',
|
||||
telegramUser: 'Telegram用户',
|
||||
slackUser: 'Slack用户',
|
||||
discordUser: 'Discord用户',
|
||||
vocechatUser: 'VoceChat用户',
|
||||
synologychatUser: 'SynologyChat用户',
|
||||
doubanUser: '豆瓣用户',
|
||||
twoFactorAuthentication: '登录双重验证',
|
||||
setupAuthenticator: '设置身份验证器',
|
||||
authenticatorManagement: '身份验证器管理',
|
||||
authenticatorEnabled: '您已启用身份验证器双重验证',
|
||||
clearAuthenticatorTip: '如需设置新的身份验证器,请先清除当前配置。',
|
||||
clearAuthenticator: '清除身份验证器',
|
||||
enableTwoFactor: '开启双重验证',
|
||||
disableTwoFactor: '关闭双重验证',
|
||||
setupMfa: '设置双重验证',
|
||||
enableMfa: '开启双重验证',
|
||||
useAuthenticator: '使用身份验证器',
|
||||
usePasskey: '使用通行密钥',
|
||||
enabled: '已启用',
|
||||
keysCount: '{count} 个密钥',
|
||||
passkeyManagement: '通行密钥管理',
|
||||
registerNewPasskey: '注册新通行密钥',
|
||||
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
|
||||
passkeyAppDescription:
|
||||
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
|
||||
passkeyName: '通行密钥名称',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '注册通行密钥',
|
||||
createdAt: '创建于',
|
||||
lastUsedAt: '最后使用时间',
|
||||
noPasskeys: '您还没有注册任何通行密钥',
|
||||
passkeyNameRequired: '请输入通行密钥名称',
|
||||
passkeyRegisterSuccess: '通行密钥注册成功',
|
||||
passkeyRegisterFailed: '注册失败',
|
||||
passkeyRegisterCancelled: '注册被取消',
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyDeleteFailed: '删除失败',
|
||||
deletePasskey: '删除通行密钥',
|
||||
passkeyDomainWarning:
|
||||
'通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey:
|
||||
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
accessDomain: '访问域名',
|
||||
otpAuthenticator: 'OTP 身份验证器',
|
||||
otpGenerateFailed: '获取otp uri失败:{message}!',
|
||||
otpDisableSuccess: '关闭登录双重验证成功!',
|
||||
otpDisableFailed: '关闭otp失败:{message}!',
|
||||
otpCodeRequired: '请填写6位验证码',
|
||||
otpEnableSuccess: '开启登录双重验证成功!',
|
||||
otpEnableFailed: '开启otp失败:{message}!',
|
||||
authenticatorApp: '身份验证器',
|
||||
otpDisableRestrictedByPasskey: '您已注册通行密钥,请先删除所有通行密钥再关闭 OTP 验证。',
|
||||
confirmToDisableOtp: '为了安全起见,关闭双重验证需要验证您的登录密码。',
|
||||
confirmToDeletePasskey: '为了安全起见,删除通行密钥需要验证您的登录密码。',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password这样的身份验证器应用程序,扫描二维码。它将为您生成一个6位数的代码,供您在下方输入。',
|
||||
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等验证器应用扫描二维码,获取 6 位验证码。',
|
||||
secretKeyTip: '如果您在使用二维码时遇到困难,请在您的应用程序中选择手动输入以上代码。',
|
||||
enterVerificationCode: '输入验证码以确认开启双重验证',
|
||||
avatarFormatTip: '允许 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
|
||||
@@ -2640,6 +2756,11 @@ export default {
|
||||
hostRequired: '地址不能为空',
|
||||
usernameRequired: '用户名不能为空',
|
||||
passwordRequired: '密码不能为空',
|
||||
pathMapping: '路径映射',
|
||||
pathMappingRequired: '路径不能为空',
|
||||
pathMappingError: '必须以 / 开头',
|
||||
storagePath: '存储路径',
|
||||
downloadPath: '下载路径',
|
||||
},
|
||||
filterRule: {
|
||||
title: '过滤规则',
|
||||
@@ -3067,3 +3188,7 @@ export default {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Apply patch to add category strings
|
||||
// This is a temporary placeholder command to show intent.
|
||||
// I will use replace_file_content to actually edit the file safely.
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
saving: '保存中',
|
||||
reset: '重置',
|
||||
theme: '主題',
|
||||
uiMode: '界面佈局',
|
||||
language: '語言',
|
||||
pleaseWait: '請稍候...',
|
||||
viewDetails: '查看詳情',
|
||||
@@ -66,6 +67,12 @@ export default {
|
||||
serviceUnavailable: '服務不可用',
|
||||
status: '狀態',
|
||||
preset: '預設',
|
||||
refresh: '刷新',
|
||||
swUpdateReady: '新版本已就緒,請刷新頁面以獲取最新功能',
|
||||
ascending: '升序',
|
||||
descending: '降序',
|
||||
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||
clearCache: '清除快取',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -129,6 +136,7 @@ export default {
|
||||
light: '淺色',
|
||||
dark: '深色',
|
||||
auto: '跟隨系統',
|
||||
autoUI: '自動',
|
||||
transparent: '透明',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
@@ -151,7 +159,6 @@ export default {
|
||||
subscribeMovie: '電影訂閱',
|
||||
subscribeTv: '電視劇訂閱',
|
||||
settings: '設置',
|
||||
language: '語言設置',
|
||||
selectLanguage: '選擇語言',
|
||||
logout: '退出登錄',
|
||||
restarting: '正在重啟...',
|
||||
@@ -238,16 +245,32 @@ export default {
|
||||
wallpapers: '壁紙',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
otpCode: '雙重驗證碼',
|
||||
otpCode: '驗證碼',
|
||||
stayLoggedIn: '保持登錄',
|
||||
login: '登錄',
|
||||
networkError: '登錄失敗,請檢查網絡連接!',
|
||||
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!',
|
||||
authFailure: '登錄失敗,請檢查用戶名、密碼或二次驗證是否正確!',
|
||||
permissionDenied: '登錄失敗,您沒有權限訪問!',
|
||||
serverError: '登錄失敗,服務器錯誤!',
|
||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||
loginFailed: '登錄失敗',
|
||||
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
||||
secondaryVerification: '二次驗證',
|
||||
loginWithPasskey: '使用通行密鑰登錄',
|
||||
loginWithOtp: '使用驗證碼登錄',
|
||||
orUsePasskey: '或使用通行密鑰進行驗證',
|
||||
verifyWithPasskey: '使用通行密鑰驗證',
|
||||
otpPlaceholder: '請輸入6位驗證碼',
|
||||
passkeyLoginStartFailed: '啟動通行密鑰驗證失敗',
|
||||
passkeyNotSelected: '未選擇通行密鑰',
|
||||
passkeyLoginFailed: '通行密鑰登錄失敗',
|
||||
passkeyAuthCanceled: '通行密鑰驗證被取消',
|
||||
passkeyNotSupported: '當前瀏覽器不支援通行密鑰',
|
||||
passkeySecureContextRequired: '通行密鑰需要 HTTPS 安全連接',
|
||||
passkeyVerifyFailed: '通行密鑰驗证失敗',
|
||||
passkeyVerifyFailedRetry: '通行密鑰驗证失敗,請重試',
|
||||
mfa: {
|
||||
selectVerificationMethod: '請選擇驗证方式',
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
start: '開始',
|
||||
@@ -380,7 +403,7 @@ export default {
|
||||
username: '用戶名',
|
||||
usernameHint: '用於登入系統的用戶名',
|
||||
password: '密碼',
|
||||
passwordHint: '用於登入系統的密碼',
|
||||
passwordHint: '請輸入登入密碼',
|
||||
confirmPassword: '確認密碼',
|
||||
confirmPasswordHint: '請再次輸入密碼以確認',
|
||||
role: '角色',
|
||||
@@ -411,10 +434,13 @@ export default {
|
||||
name: '企業微信',
|
||||
corpId: '企業ID',
|
||||
corpIdHint: '企業微信後台企業信息中的企業ID',
|
||||
corpIdRequired: '企業ID不能為空',
|
||||
appId: '應用 AgentId',
|
||||
appIdHint: '企業微信自建應用的AgentId',
|
||||
appIdRequired: '應用AgentId不能為空',
|
||||
appSecret: '應用 Secret',
|
||||
appSecretHint: '企業微信自建應用的Secret',
|
||||
appSecretRequired: '應用Secret不能為空',
|
||||
proxy: '代理地址',
|
||||
proxyHint: '微信消息的轉發代理地址,2022年6月20日後創建的自建應用才需要,不使用代理時需要保留默認值',
|
||||
token: 'Token',
|
||||
@@ -429,28 +455,48 @@ export default {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
tokenHint: 'Telegram機器人token,格式:123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||
tokenRequired: 'Bot Token不能為空',
|
||||
chatId: 'Chat ID',
|
||||
chatIdHint: '接受消息通知的用戶、群組或頻道Chat ID',
|
||||
chatIdRequired: 'Chat ID不能為空',
|
||||
users: '用戶白名單',
|
||||
usersHint: '可使用Telegram機器人的用戶ID清單,多個用戶用,分隔,不填寫則所有用戶都能使用',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '可使用管理菜單及命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
usersPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
apiUrl: '代理API地址',
|
||||
apiUrlHint: '自定義代理API地址,格式:https://api.telegram.org',
|
||||
apiUrlPlaceholder: 'https://api.telegram.org',
|
||||
},
|
||||
slack: {
|
||||
name: 'Slack',
|
||||
oauthToken: 'Slack Bot User OAuth Token',
|
||||
oauthTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`Bot User OAuth Token`',
|
||||
oauthTokenRequired: 'OAuth Token不能為空',
|
||||
appToken: 'Slack App-Level Token',
|
||||
appTokenHint: 'Slack應用`OAuth & Permissions`頁面中的`App-Level Token`',
|
||||
channel: '頻道名稱',
|
||||
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',
|
||||
webhookHint: 'Synology Chat機器人傳入URL',
|
||||
webhookRequired: 'Webhook URL不能為空',
|
||||
token: '令牌',
|
||||
tokenHint: 'Synology Chat機器人令牌',
|
||||
},
|
||||
@@ -458,8 +504,10 @@ export default {
|
||||
name: 'VoceChat',
|
||||
host: '地址',
|
||||
hostHint: 'VoceChat服務端地址,格式:http(s)://ip:port',
|
||||
hostRequired: '地址不能為空',
|
||||
apiKey: '機器人密鑰',
|
||||
apiKeyHint: 'VoceChat機器人密鑰',
|
||||
apiKeyRequired: 'API密鑰不能為空',
|
||||
channelId: '頻道ID',
|
||||
channelIdHint: 'VoceChat的頻道ID,不包含#號',
|
||||
},
|
||||
@@ -467,6 +515,7 @@ export default {
|
||||
name: 'WebPush',
|
||||
username: '登錄用戶名',
|
||||
usernameHint: '只有對應的用戶登錄後才會推送消息',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
@@ -908,6 +957,9 @@ export default {
|
||||
searching: '正在搜索,請稍候...',
|
||||
noData: '沒有數據',
|
||||
noResourceFound: '未搜索到任何資源',
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
},
|
||||
browse: {
|
||||
actor: '演員',
|
||||
@@ -1182,6 +1234,7 @@ export default {
|
||||
title: '關於 MoviePilot',
|
||||
softwareVersion: '軟件版本',
|
||||
frontendVersion: '前端版本',
|
||||
browserVersion: '瀏覽器緩存版本',
|
||||
authVersion: '認證資源版本',
|
||||
indexerVersion: '站點資源版本',
|
||||
configDir: '配置目錄',
|
||||
@@ -1201,6 +1254,7 @@ export default {
|
||||
dataDirectory: '/moviepilot',
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
clearCache: '清除快取',
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
@@ -1235,15 +1289,28 @@ export default {
|
||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||
llmModel: 'LLM模型名稱',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
||||
llmApiKey: 'LLM API密鑰',
|
||||
llmApiKeyHint: 'LLM服務提供商的API密鑰,用於身份驗證',
|
||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||
llmBaseUrl: 'LLM基礎URL',
|
||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
|
||||
advancedSettings: '高級設置',
|
||||
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
||||
downloaders: '下載器',
|
||||
downloadersDesc: '只有默認下載器才會被默認使用。',
|
||||
aiRecommendEnabled: '搜索結果智能推薦',
|
||||
aiRecommendEnabledHint:
|
||||
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
|
||||
aiRecommendUserPreference: '用戶偏好',
|
||||
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好,例如:4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: '智能推薦分析條目上限',
|
||||
aiRecommendMaxItemsHint:
|
||||
'限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
|
||||
mediaServers: '媒體服務器',
|
||||
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
|
||||
trimeMedia: '飛牛影視',
|
||||
@@ -1340,6 +1407,8 @@ export default {
|
||||
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
|
||||
encodingDetectionPerformanceMode: '編碼探測性能模式',
|
||||
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
|
||||
transferThreads: '文件整理線程數',
|
||||
transferThreadsHint: '多線程整理文件可以提高速度,但可能增加系統資源佔用',
|
||||
tokenizedSearch: '分詞搜索',
|
||||
tokenizedSearchHint: '提升整理歷史記錄搜索精度,但可能增加性能開銷和意外結果',
|
||||
tmdbLanguage: {
|
||||
@@ -1588,6 +1657,7 @@ export default {
|
||||
storage: '存儲',
|
||||
storageDesc: '設置本地或網盤存儲',
|
||||
directory: '目錄',
|
||||
mediaType: '媒體類型',
|
||||
directoryDesc: '設置媒體文件整理目錄結構,按先後順序依次匹配。',
|
||||
organizeAndScrap: '整理 & 刮削',
|
||||
organizeAndScrapDesc: '設置重命名格式、刮削選項等。',
|
||||
@@ -1608,6 +1678,25 @@ export default {
|
||||
storageSaveSuccess: '存儲設置保存成功',
|
||||
storageSaveFailed: '存儲設置保存失敗!',
|
||||
},
|
||||
category: {
|
||||
title: '分類策略',
|
||||
subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',
|
||||
movie: '電影 (Movie)',
|
||||
tv: '電視劇 (TV)',
|
||||
name: '分類名稱 (目錄名)',
|
||||
genre: '內容類型 (Genre)',
|
||||
language: '語種 (Language)',
|
||||
languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',
|
||||
country: '國家/地區 (Country)',
|
||||
countryPlaceholder: '如: US,CN,JP',
|
||||
year: '年份 (Year)',
|
||||
yearPlaceholder: '如: 2023, 2020-2024',
|
||||
addMovie: '添加電影分類',
|
||||
addTv: '添加電視劇分類',
|
||||
saveSuccess: '分類策略保存成功',
|
||||
loadFailed: '加載分類配置失敗',
|
||||
saveFailed: '保存失敗: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: '自定義規則',
|
||||
customRulesDesc: '自定義優先級規則項',
|
||||
@@ -1646,8 +1735,8 @@ export default {
|
||||
importHasId: '導入失敗!發現有規則存在相同ID,可能屬於自定義規則!',
|
||||
},
|
||||
scheduler: {
|
||||
scheduledTasks: '定時作業',
|
||||
scheduledTasksDesc: '包含系統內置服務以及插件提供的服務',
|
||||
title: '定時作業',
|
||||
subtitle: '包含系統內置服務以及插件提供的服務',
|
||||
provider: '提供者',
|
||||
taskName: '任務名稱',
|
||||
taskStatus: '任務狀態',
|
||||
@@ -1699,9 +1788,10 @@ export default {
|
||||
settingsSaveFailed: '訂閱基礎設置保存失敗!',
|
||||
},
|
||||
cache: {
|
||||
title: '緩存',
|
||||
description: '種子緩存、圖片文件緩存管理',
|
||||
title: '緩存管理',
|
||||
subtitle: '管理緩存的站點資源',
|
||||
totalCount: '總條數',
|
||||
siteCount: '站點數',
|
||||
filterByTitle: '按標題篩選',
|
||||
filterBySite: '按站點篩選',
|
||||
selectSite: '選擇站點',
|
||||
@@ -1795,6 +1885,7 @@ export default {
|
||||
wechat: '微信UserID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
vocechat: 'VoceChat UserID',
|
||||
synologyChat: 'SynologyChat UserID',
|
||||
webPush: 'WebPush',
|
||||
@@ -1876,6 +1967,8 @@ export default {
|
||||
startDownload: '開始下載',
|
||||
downloadSuccess: '{site} {title} 下載成功!',
|
||||
downloadFailed: '{site} {title} 下載失敗:{message}!',
|
||||
showAdvancedOptions: '顯示高級選項',
|
||||
hideAdvancedOptions: '隱藏高級選項',
|
||||
},
|
||||
subscribeShare: {
|
||||
shareSubscription: '分享訂閱',
|
||||
@@ -2476,6 +2569,7 @@ export default {
|
||||
noRecentPlugins: '無',
|
||||
},
|
||||
profile: {
|
||||
disableOtpWithPasskeyError: '請先刪除所有通行密鑰後再清除身份驗證器!',
|
||||
personalInfo: '個人信息',
|
||||
uploadNewAvatar: '上傳新頭像',
|
||||
avatarFormatError: '上傳的文件不符合要求,請重新選擇頭像',
|
||||
@@ -2496,21 +2590,58 @@ export default {
|
||||
wechatUser: '微信用戶',
|
||||
telegramUser: 'Telegram用戶',
|
||||
slackUser: 'Slack用戶',
|
||||
discordUser: 'Discord用戶',
|
||||
vocechatUser: 'VoceChat用戶',
|
||||
synologychatUser: 'SynologyChat用戶',
|
||||
doubanUser: '豆瓣用戶',
|
||||
twoFactorAuthentication: '登錄雙重驗證',
|
||||
setupAuthenticator: '設置身份驗證器',
|
||||
authenticatorManagement: '身份驗證器管理',
|
||||
authenticatorEnabled: '您已啟用身份驗證器雙重驗證',
|
||||
clearAuthenticatorTip: '如需設置新的身份驗證器,請先清除當前配置。',
|
||||
clearAuthenticator: '清除身份驗證器',
|
||||
enableTwoFactor: '開啟雙重驗證',
|
||||
disableTwoFactor: '關閉雙重驗證',
|
||||
setupMfa: '設置雙重驗證',
|
||||
enableMfa: '開啟雙重驗證',
|
||||
useAuthenticator: '使用身份驗證器',
|
||||
usePasskey: '使用通行密鑰',
|
||||
enabled: '已啟用',
|
||||
keysCount: '{count} 個密鑰',
|
||||
passkeyManagement: '通行密鑰管理',
|
||||
registerNewPasskey: '註冊新通行密鑰',
|
||||
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
|
||||
passkeyAppDescription:
|
||||
'通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
|
||||
passkeyName: '通行密鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '註冊通行密鑰',
|
||||
createdAt: '建立於',
|
||||
lastUsedAt: '最後使用時間',
|
||||
noPasskeys: '您還沒有註冊任何通行密鑰',
|
||||
passkeyNameRequired: '請輸入通行密鑰名稱',
|
||||
passkeyRegisterSuccess: '通行密鑰註冊成功',
|
||||
passkeyRegisterFailed: '註冊失敗',
|
||||
passkeyRegisterCancelled: '註冊被取消',
|
||||
passkeyDeleteSuccess: '通行密鑰已刪除',
|
||||
passkeyDeleteFailed: '刪除失敗',
|
||||
deletePasskey: '刪除通行密鑰',
|
||||
passkeyDomainWarning:
|
||||
'通行密鑰(PassKey)的可用性與 {domain} 緊密相關。在公網環境下,請務必在「基本設定」中配置正確的訪問域名。域名變更或配置錯誤將導致通行密鑰無法使用。',
|
||||
otpRequiredForPasskey:
|
||||
'為了安全起見,您必須先啟用 {otp} 驗證碼,然後才能註冊通行密鑰。這是為了防止在網域配置變動導致 PassKey 失效時,您仍能通過 OTP 碼登入帳戶。',
|
||||
accessDomain: '訪問域名',
|
||||
otpAuthenticator: 'OTP 身份驗證器',
|
||||
otpGenerateFailed: '獲取otp uri失敗:{message}!',
|
||||
otpDisableSuccess: '關閉登錄雙重驗證成功!',
|
||||
otpDisableFailed: '關閉otp失敗:{message}!',
|
||||
otpCodeRequired: '請填寫6位驗證碼',
|
||||
otpEnableSuccess: '開啟登錄雙重驗證成功!',
|
||||
otpEnableFailed: '開啟otp失敗:{message}!',
|
||||
authenticatorApp: '身份驗證器',
|
||||
otpDisableRestrictedByPasskey: '您已註冊通行密鑰,請先刪除所有通行密鑰再關閉 OTP 驗證。',
|
||||
confirmToDisableOtp: '為了安全起見,關閉雙重驗證需要驗證您的登錄密碼。',
|
||||
confirmToDeletePasskey: '為了安全起見,刪除通行密鑰需要驗證您的登錄密碼。',
|
||||
authenticatorAppDescription:
|
||||
'使用像Google Authenticator、Microsoft Authenticator、Authy或1Password這樣的身份驗證器應用程序,掃描二維碼。它將為您生成一個6位數的代碼,供您在下方輸入。',
|
||||
'使用 Google Authenticator、Microsoft Authenticator、Authy 或 1Password 等驗證器應用程式掃描 QR Code,取得 6 位數驗證碼。',
|
||||
secretKeyTip: '如果您在使用二維碼時遇到困難,請在您的應用程序中選擇手動輸入以上代碼。',
|
||||
enterVerificationCode: '輸入驗證碼以確認開啟雙重驗證',
|
||||
avatarFormatTip: '允許 JPG、PNG、GIF、WEBP 格式, 最大尺寸 800KB。',
|
||||
@@ -2626,6 +2757,11 @@ export default {
|
||||
hostRequired: '地址不能為空',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
passwordRequired: '密碼不能為空',
|
||||
pathMapping: '路徑映射',
|
||||
pathMappingRequired: '路徑不能為空',
|
||||
pathMappingError: '必須以 / 開頭',
|
||||
storagePath: '存儲路徑',
|
||||
downloadPath: '下載路徑',
|
||||
},
|
||||
filterRule: {
|
||||
title: '過濾規則',
|
||||
@@ -2909,6 +3045,26 @@ export default {
|
||||
unsupportedDownloaderType: '不支援的下載器類型: {type}',
|
||||
unsupportedMediaServerType: '不支援的媒體服務器類型: {type}',
|
||||
unsupportedNotificationType: '不支援的通知類型: {type}',
|
||||
storageTestFailed: '存儲目錄測試失敗',
|
||||
downloaderTestFailed: '下載器測試失敗',
|
||||
downloaderNotSelected: '未選擇下載器',
|
||||
mediaServerTestFailed: '媒體服務器測試失敗',
|
||||
mediaServerNotSelected: '未選擇媒體服務器',
|
||||
notificationTestFailed: '消息通知測試失敗',
|
||||
notificationNotSelected: '未選擇通知類型',
|
||||
saveStepFailed: '保存步驟設置失敗',
|
||||
basicSettingsSaved: '基礎設置保存成功',
|
||||
saveBasicSettingsFailed: '保存基礎設置失敗',
|
||||
storageSettingsSaved: '存儲設置保存成功',
|
||||
saveStorageSettingsFailed: '保存存儲設置失敗',
|
||||
downloaderSettingsSaved: '下載器設置保存成功',
|
||||
saveDownloaderSettingsFailed: '保存下載器設置失敗',
|
||||
mediaServerSettingsSaved: '媒體服務器設置保存成功',
|
||||
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
|
||||
notificationSettingsSaved: '通知設置保存成功',
|
||||
saveNotificationSettingsFailed: '保存通知設置失敗',
|
||||
preferenceSettingsSaved: '偏好設置保存成功',
|
||||
savePreferenceSettingsFailed: '保存偏好設置失敗',
|
||||
passwordUpdateSuccess: '密碼更新成功',
|
||||
userCreateSuccess: '使用者建立成功',
|
||||
passwordUpdateFailed: '密碼更新失敗',
|
||||
|
||||
@@ -23,7 +23,7 @@ const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
const allMenus = getNavMenus()
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ function initializeColors() {
|
||||
|
||||
// 初始化发现标签
|
||||
function initDiscoverTabs() {
|
||||
const tabs = getDiscoverTabs()
|
||||
const tabs = getDiscoverTabs(t)
|
||||
for (const tab of tabs) {
|
||||
discoverTabs.value.push({
|
||||
name: tab.name,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import { VForm } from 'vuetify/components/VForm'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { useAuthStore, useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
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'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -20,9 +20,11 @@ const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
//用户 Store
|
||||
const userStore = useUserStore()
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 获取有权限的菜单
|
||||
const navMenus = getNavMenus()
|
||||
const navMenus = computed(() => getNavMenus(t))
|
||||
|
||||
// 表单
|
||||
const form = ref({
|
||||
@@ -43,6 +45,12 @@ const errorMessage = ref('')
|
||||
// 是否开启双重验证
|
||||
const isOTP = ref(false)
|
||||
|
||||
// 二次验证对话框
|
||||
const mfaDialog = ref(false)
|
||||
|
||||
// MFA PassKey loading
|
||||
const mfaPasskeyLoading = ref(false)
|
||||
|
||||
// 用户名称输入框
|
||||
const usernameInput = ref()
|
||||
|
||||
@@ -66,6 +74,223 @@ const locales = Object.values(SUPPORTED_LOCALES)
|
||||
// 登录按钮 loading
|
||||
const loading = ref(false)
|
||||
|
||||
// PassKey 登录按钮 loading
|
||||
const passkeyLoading = ref(false)
|
||||
|
||||
// Conditional UI 的 AbortController
|
||||
let conditionalAbortController: AbortController | null = null
|
||||
|
||||
// 手动模式的 AbortController(用于防止重复点击)
|
||||
let manualAbortController: AbortController | null = null
|
||||
|
||||
// 标记当前是否有手动模式的 PassKey 请求正在进行
|
||||
let isManualPassKeyActive = false
|
||||
|
||||
// PassKey 认证核心函数 - 处理 WebAuthn 认证流程
|
||||
interface PassKeyAuthOptions {
|
||||
username?: string // 可选的用户名,用于 MFA 场景
|
||||
isConditional?: boolean // 是否为 Conditional UI 模式
|
||||
signal?: AbortSignal // AbortController 信号
|
||||
}
|
||||
|
||||
// PassKey API 响应类型
|
||||
interface PassKeyStartResponse {
|
||||
options: string // JSON 字符串
|
||||
challenge: string
|
||||
}
|
||||
|
||||
interface PassKeyFinishResponse {
|
||||
access_token: string
|
||||
super_user: boolean
|
||||
user_id: number
|
||||
user_name: string
|
||||
avatar: string
|
||||
level: number
|
||||
permissions: Record<string, boolean>
|
||||
wizard: boolean
|
||||
}
|
||||
|
||||
async function authenticateWithPassKey(options: PassKeyAuthOptions = {}): Promise<PassKeyFinishResponse> {
|
||||
const { username, isConditional = false, signal } = options
|
||||
|
||||
// 1. 开始认证流程
|
||||
const startResponse = (await api.post(
|
||||
'/mfa/passkey/authenticate/start',
|
||||
username ? { username } : {},
|
||||
)) as ApiResponse<PassKeyStartResponse>
|
||||
|
||||
if (!startResponse.success) {
|
||||
throw new Error(startResponse.message || 'PassKey start failed')
|
||||
}
|
||||
|
||||
const { options: optionsStr, challenge } = startResponse.data
|
||||
const publicKeyOptions = JSON.parse(optionsStr)
|
||||
|
||||
// 2. 调用WebAuthn API
|
||||
const credentialRequestOptions: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
...publicKeyOptions,
|
||||
challenge: base64UrlToUint8Array(publicKeyOptions.challenge),
|
||||
allowCredentials: publicKeyOptions.allowCredentials?.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64UrlToUint8Array(cred.id),
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
// 如果是 Conditional UI 模式,添加 mediation 和 signal
|
||||
if (isConditional) {
|
||||
credentialRequestOptions.mediation = 'conditional'
|
||||
if (signal) {
|
||||
credentialRequestOptions.signal = signal
|
||||
}
|
||||
}
|
||||
|
||||
const credential = await navigator.credentials.get(credentialRequestOptions)
|
||||
|
||||
// Conditional UI 模式下,用户选择通行密钥后才显示 loading
|
||||
if (isConditional) {
|
||||
passkeyLoading.value = true
|
||||
}
|
||||
|
||||
if (!credential) {
|
||||
throw new Error('No credential selected')
|
||||
}
|
||||
|
||||
// 3. 转换credential为可传输格式
|
||||
const publicKeyCredential = credential as PublicKeyCredential
|
||||
const assertionResponse = publicKeyCredential.response as AuthenticatorAssertionResponse
|
||||
const credentialJSON = {
|
||||
id: publicKeyCredential.id,
|
||||
rawId: bufferToBase64Url(publicKeyCredential.rawId),
|
||||
type: publicKeyCredential.type,
|
||||
response: {
|
||||
authenticatorData: bufferToBase64Url(assertionResponse.authenticatorData),
|
||||
clientDataJSON: bufferToBase64Url(assertionResponse.clientDataJSON),
|
||||
signature: bufferToBase64Url(assertionResponse.signature),
|
||||
userHandle: assertionResponse.userHandle ? bufferToBase64Url(assertionResponse.userHandle) : null,
|
||||
},
|
||||
}
|
||||
|
||||
// 4. 完成认证
|
||||
const finishResponse = (await api.post('/mfa/passkey/authenticate/finish', {
|
||||
credential: credentialJSON,
|
||||
challenge: challenge,
|
||||
})) as PassKeyFinishResponse
|
||||
|
||||
if (!finishResponse || !finishResponse.access_token) {
|
||||
throw new Error('PassKey finish failed: No access token')
|
||||
}
|
||||
|
||||
return finishResponse
|
||||
}
|
||||
|
||||
// 统一处理 PassKey 认证流程
|
||||
async function handlePassKeyAuth(
|
||||
authOptions: PassKeyAuthOptions,
|
||||
setLoading: (loading: boolean) => void,
|
||||
onSuccess: (response: PassKeyFinishResponse) => Promise<void>,
|
||||
) {
|
||||
const { isConditional = false } = authOptions
|
||||
errorMessage.value = ''
|
||||
|
||||
// 检查浏览器环境
|
||||
if (!window.PublicKeyCredential) {
|
||||
if (!isConditional) {
|
||||
if (!window.isSecureContext) {
|
||||
errorMessage.value = t('login.passkeySecureContextRequired')
|
||||
} else {
|
||||
errorMessage.value = t('login.passkeyNotSupported')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是手动触发(非 Conditional UI)
|
||||
if (!isConditional) {
|
||||
// 取消之前的 Conditional UI 请求
|
||||
if (conditionalAbortController) {
|
||||
conditionalAbortController.abort()
|
||||
conditionalAbortController = null
|
||||
}
|
||||
|
||||
// 取消之前的手动请求(防止重复点击)
|
||||
if (manualAbortController) {
|
||||
manualAbortController.abort()
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
manualAbortController = new AbortController()
|
||||
|
||||
// 标记手动请求为活跃状态,并立即设置 loading
|
||||
isManualPassKeyActive = true
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const finishResponse = await authenticateWithPassKey({
|
||||
...authOptions,
|
||||
signal:
|
||||
isConditional && conditionalAbortController
|
||||
? conditionalAbortController.signal
|
||||
: !isConditional && manualAbortController
|
||||
? manualAbortController.signal
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await onSuccess(finishResponse)
|
||||
} catch (error: any) {
|
||||
// Conditional UI 模式下:
|
||||
// 1. 如果 loading 为 false,说明错误发生在用户选择密钥之前(如初始化失败、用户取消等),此时应静默
|
||||
// 2. 如果是 AbortError,始终静默
|
||||
if (isConditional && (!passkeyLoading.value || error.name === 'AbortError')) {
|
||||
console.warn('[PassKey] Conditional UI silenced error:', error)
|
||||
return
|
||||
}
|
||||
|
||||
// 手动模式下的 AbortError 也应该静默(用户重复点击导致)
|
||||
if (!isConditional && error.name === 'AbortError') {
|
||||
console.warn('[PassKey] Manual request aborted (likely due to rapid clicking):', error)
|
||||
return
|
||||
}
|
||||
|
||||
// 设置错误信息
|
||||
if (error.name === 'NotAllowedError') {
|
||||
errorMessage.value = t('login.passkeyAuthCanceled')
|
||||
} else if (error.name === 'NotSupportedError') {
|
||||
errorMessage.value = t('login.passkeyNotSupported')
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
errorMessage.value = t('login.passkeyLoginStartFailed')
|
||||
} else {
|
||||
errorMessage.value = t('login.authFailure')
|
||||
}
|
||||
} finally {
|
||||
// 清除 loading 状态
|
||||
if (!isConditional) {
|
||||
// 手动模式:始终清除,并取消手动活跃标记
|
||||
isManualPassKeyActive = false
|
||||
setLoading(false)
|
||||
manualAbortController = null
|
||||
} else {
|
||||
// Conditional UI 模式:只有在没有手动请求活跃时才清除
|
||||
if (!isManualPassKeyActive && passkeyLoading.value) {
|
||||
passkeyLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用PassKey登录 (支持 Conditional UI)
|
||||
async function loginWithPassKey(isConditional = false) {
|
||||
await handlePassKeyAuth(
|
||||
{ isConditional },
|
||||
val => (passkeyLoading.value = val),
|
||||
async response => {
|
||||
await handleLoginSuccess(response)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 切换语言
|
||||
async function switchLanguage(locale: SupportedLocale) {
|
||||
await setI18nLanguage(locale)
|
||||
@@ -73,23 +298,6 @@ async function switchLanguage(locale: SupportedLocale) {
|
||||
langMenu.value = false
|
||||
}
|
||||
|
||||
// 查询是否开启双重验证
|
||||
const fetchOTP = debounce(async () => {
|
||||
const userid = usernameInput.value?.value
|
||||
if (!userid) {
|
||||
isOTP.value = false
|
||||
return
|
||||
}
|
||||
api
|
||||
.get(`/user/otp/${userid}`)
|
||||
.then((response: any) => {
|
||||
isOTP.value = response.success
|
||||
})
|
||||
.catch((error: any) => {
|
||||
console.log(error)
|
||||
})
|
||||
}, 500)
|
||||
|
||||
// 订阅推送通知
|
||||
async function subscribeForPushNotifications() {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
@@ -110,7 +318,7 @@ async function subscribeForPushNotifications() {
|
||||
try {
|
||||
await api.post('/message/webpush/subscribe', subscription)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,84 +340,128 @@ 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 globalSettingsStore.loadUserSettings()
|
||||
|
||||
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,
|
||||
}
|
||||
await handleLoginSuccess(response)
|
||||
} catch (error: any) {
|
||||
// 登录失败,显示错误提示
|
||||
if (!error.response) {
|
||||
errorMessage.value = t('login.networkError')
|
||||
return
|
||||
}
|
||||
|
||||
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
|
||||
// 如果用户没有任何可用菜单,拒绝登录
|
||||
if (filteredMenus.length === 0) {
|
||||
// 显示错误信息
|
||||
errorMessage.value = t('login.noPermission')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
switch (error.response.status) {
|
||||
case 401:
|
||||
// 401错误可能是需要MFA或者认证失败
|
||||
// 检查响应头是否有MFA要求标识
|
||||
if (error.response.headers?.['x-mfa-required'] === 'true' && !form.value.otp_password) {
|
||||
// 需要MFA验证,弹出对话框
|
||||
isOTP.value = true
|
||||
mfaDialog.value = true
|
||||
return
|
||||
}
|
||||
// 不需要MFA或已填写OTP但认证失败
|
||||
errorMessage.value = t('login.authFailure')
|
||||
// 认证失败后清空OTP密码,防止下次点击不弹出对话框
|
||||
form.value.otp_password = ''
|
||||
break
|
||||
case 403:
|
||||
errorMessage.value = t('login.permissionDenied')
|
||||
break
|
||||
case 500:
|
||||
errorMessage.value = t('login.serverError')
|
||||
break
|
||||
default:
|
||||
errorMessage.value = `${t('login.authFailure')} (Status: ${error.response.status})`
|
||||
}
|
||||
} 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)
|
||||
})
|
||||
.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
|
||||
})
|
||||
await handlePassKeyAuth(
|
||||
{ username: form.value.username },
|
||||
val => (mfaPasskeyLoading.value = val),
|
||||
async response => {
|
||||
// 关闭MFA对话框
|
||||
mfaDialog.value = false
|
||||
await handleLoginSuccess(response)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
@@ -221,6 +473,51 @@ onMounted(async () => {
|
||||
// 如果token存在,且保持登录状态为true,则跳转到首页
|
||||
if (token && remember) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化 Conditional UI 的 PassKey 自动填充
|
||||
await initConditionalPasskey()
|
||||
})
|
||||
|
||||
// 初始化 Conditional UI 的 PassKey 自动填充
|
||||
async function initConditionalPasskey() {
|
||||
// 检查浏览器是否支持 WebAuthn 和 Conditional UI
|
||||
if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const available = await PublicKeyCredential.isConditionalMediationAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
// 安全防御:如果已存在 controller,先 abort 掉旧的,防止重复调用产生幽灵请求
|
||||
if (conditionalAbortController) {
|
||||
conditionalAbortController.abort()
|
||||
conditionalAbortController = null
|
||||
}
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
conditionalAbortController = new AbortController()
|
||||
|
||||
// 启动 Conditional UI 模式的 PassKey 认证
|
||||
await loginWithPassKey(true)
|
||||
} catch (error) {
|
||||
console.error('[PassKey] Failed to initialize Conditional UI:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (conditionalAbortController) {
|
||||
conditionalAbortController.abort()
|
||||
conditionalAbortController = null
|
||||
}
|
||||
if (manualAbortController) {
|
||||
manualAbortController.abort()
|
||||
manualAbortController = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -229,7 +526,7 @@ onMounted(async () => {
|
||||
<!-- 登录页面容器 -->
|
||||
<div class="relative flex min-h-screen flex-col items-center justify-center">
|
||||
<!-- 登录表单 -->
|
||||
<div class="auth-wrapper d-flex align-center justify-center">
|
||||
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard
|
||||
class="auth-card px-7 py-3 w-full h-full"
|
||||
:class="{ 'glass-effect': !isTransparentTheme }"
|
||||
@@ -274,7 +571,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">
|
||||
@@ -284,9 +581,10 @@ onMounted(async () => {
|
||||
:label="t('login.username')"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
id="username"
|
||||
autocomplete="username webauthn"
|
||||
:rules="[requiredValidator]"
|
||||
@input="fetchOTP"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<!-- password -->
|
||||
@@ -295,15 +593,16 @@ onMounted(async () => {
|
||||
v-model="form.password"
|
||||
:label="t('login.password')"
|
||||
:type="isPasswordVisible ? 'text' : 'password'"
|
||||
name="current-password"
|
||||
name="password"
|
||||
id="password"
|
||||
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 +610,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 passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="passkeyLoading"
|
||||
@click="loginWithPassKey(false)"
|
||||
>
|
||||
{{ t('login.loginWithPasskey') }}
|
||||
</VBtn>
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
@@ -323,6 +634,64 @@ onMounted(async () => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- MFA二次验证对话框 -->
|
||||
<VDialog v-model="mfaDialog" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<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"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
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"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="mfaPasskeyLoading"
|
||||
@click="verifyWithPassKey"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<VAlert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="mfaDialog = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -348,4 +717,10 @@ onMounted(async () => {
|
||||
backdrop-filter: blur(10px) !important;
|
||||
background: rgba(var(--v-theme-surface), 0.7) !important;
|
||||
}
|
||||
|
||||
.v-theme--light {
|
||||
.passkey-btn.v-btn--variant-tonal {
|
||||
color: rgb(86, 170, 0) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -3,15 +3,29 @@ import { debounce } from 'lodash-es'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCardListView from '@/views/torrent/TorrentCardListView.vue'
|
||||
import TorrentRowListView from '@/views/torrent/TorrentRowListView.vue'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { useGlobalSettingsStore } from '@/stores/global'
|
||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { useProgressSSE } = useBackgroundOptimization()
|
||||
|
||||
// 提示框
|
||||
const toast = useToast()
|
||||
|
||||
// 全局设置 Store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 使用筛选 composable
|
||||
const torrentFilter = useTorrentFilter()
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
|
||||
@@ -39,11 +53,46 @@ const sites = route.query?.sites?.toString() ?? ''
|
||||
// 视图类型,从localStorage中读取
|
||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||
|
||||
// 视图切换中
|
||||
const isViewChanging = ref(false)
|
||||
// 智能推荐相关
|
||||
// 从全局设置中获取 AI_RECOMMEND_ENABLED 状态
|
||||
const aiRecommendEnabled = computed(() => {
|
||||
return globalSettingsStore.get('AI_RECOMMEND_ENABLED') === true
|
||||
})
|
||||
const isRecommending = ref(false)
|
||||
const isReRecommending = ref(false) // 是否正在重新推荐
|
||||
const aiRecommended = ref(false) // 是否已执行过智能推荐
|
||||
const showingAiResults = ref(false) // 是否正在显示智能推荐结果
|
||||
const originalDataList = ref<Array<Context>>([]) // 原始搜索结果
|
||||
const aiRecommendedList = ref<Array<Context>>([]) // 智能推荐结果
|
||||
const savedFilterState = ref<FilterState | null>(null) // 保存的筛选状态
|
||||
const aiStatusChecked = ref(false) // 是否已完成首次AI状态检查
|
||||
let aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状态检查定时器
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Array<Context>>([])
|
||||
// 是否有搜索标签
|
||||
const hasSearchTags = computed(() => {
|
||||
return !!(keyword || title || year || season)
|
||||
})
|
||||
|
||||
// 是否启用筛选栏动画
|
||||
const enableFilterAnimation = ref(true)
|
||||
|
||||
// 原始数据列表(未筛选)
|
||||
const rawDataList = ref<Array<Context>>([])
|
||||
|
||||
// 筛选后的数据列表(用于行视图)
|
||||
const filteredRowDataList = ref<Array<Context>>([])
|
||||
|
||||
// 筛选后的数据列表(用于卡片视图)
|
||||
interface SearchTorrent extends Context {
|
||||
more?: Array<Context>
|
||||
}
|
||||
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||
|
||||
// 使用无限滚动 composable(行视图)
|
||||
const rowScroll = useInfiniteScroll(filteredRowDataList)
|
||||
|
||||
// 使用无限滚动 composable(卡片视图)
|
||||
const cardScroll = useInfiniteScroll(filteredCardDataList)
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
@@ -66,6 +115,49 @@ const errorTitle = ref(t('resource.noData'))
|
||||
// 错误描述
|
||||
const errorDescription = ref(t('resource.noResourceFound'))
|
||||
|
||||
// 监听筛选条件变化,重新筛选数据
|
||||
watch(
|
||||
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||
() => {
|
||||
applyFilter()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 应用筛选
|
||||
function applyFilter() {
|
||||
if (viewType.value === 'row') {
|
||||
filteredRowDataList.value = torrentFilter.filterRowData(rawDataList.value)
|
||||
} else {
|
||||
filteredCardDataList.value = torrentFilter.filterCardData(rawDataList.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理筛选表单更新
|
||||
function handleFilterFormUpdate(key: string, values: string[]) {
|
||||
torrentFilter.filterForm[key] = values
|
||||
}
|
||||
|
||||
// 处理全选
|
||||
function handleSelectAll(key: string) {
|
||||
torrentFilter.selectAll(key)
|
||||
}
|
||||
|
||||
// 处理清除筛选
|
||||
function handleClearFilter(key: string) {
|
||||
torrentFilter.clearFilter(key)
|
||||
}
|
||||
|
||||
// 处理清除所有筛选
|
||||
function handleClearAllFilters() {
|
||||
torrentFilter.clearAllFilters()
|
||||
}
|
||||
|
||||
// 处理移除单个筛选
|
||||
function handleRemoveFilter(key: string, value: string) {
|
||||
torrentFilter.removeFilter(key, value)
|
||||
}
|
||||
|
||||
// 添加安全超时,确保进度条不会永远卡住
|
||||
const watchProgressValue = watch(
|
||||
progressValue,
|
||||
@@ -116,29 +208,30 @@ function stopLoadingProgress() {
|
||||
setTimeout(() => {
|
||||
progressValue.value = 0
|
||||
progressEnabled.value = false
|
||||
}, 1500) // 延长到1.5秒,让用户有足够时间看到完成状态
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 设置视图类型
|
||||
function changeViewType(newType: string) {
|
||||
if (viewType.value !== newType) {
|
||||
isViewChanging.value = true
|
||||
// 立即更新视图类型
|
||||
viewType.value = newType
|
||||
localStorage.setItem('MPTorrentsViewType', newType)
|
||||
|
||||
// 模拟视图切换的加载过程
|
||||
setTimeout(() => {
|
||||
isViewChanging.value = false
|
||||
}, 600)
|
||||
// 切换视图时重新应用筛选
|
||||
applyFilter()
|
||||
}
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
enableFilterAnimation.value = true
|
||||
if (!keyword) {
|
||||
// 查询上次搜索结果
|
||||
dataList.value = await api.get('search/last')
|
||||
const results = await api.get('search/last')
|
||||
rawDataList.value = (results as unknown as Context[]) || []
|
||||
originalDataList.value = (results as unknown as Context[]) || []
|
||||
} else {
|
||||
startLoadingProgress()
|
||||
let result: { [key: string]: any }
|
||||
@@ -164,7 +257,12 @@ async function fetchData() {
|
||||
})
|
||||
}
|
||||
if (result && result.success) {
|
||||
dataList.value = result.data || []
|
||||
rawDataList.value = result.data || []
|
||||
originalDataList.value = result.data || []
|
||||
// 重置智能推荐状态
|
||||
aiRecommended.value = false
|
||||
showingAiResults.value = false
|
||||
aiRecommendedList.value = []
|
||||
} else if (result && result.message) {
|
||||
errorDescription.value = result.message
|
||||
}
|
||||
@@ -172,6 +270,8 @@ async function fetchData() {
|
||||
// 从浏览器历史中删除当前搜索
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
// 应用筛选
|
||||
applyFilter()
|
||||
// 标记已刷新
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
@@ -182,14 +282,280 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到智能推荐结果(自动保存筛选条件)
|
||||
async function switchToAiResults() {
|
||||
if (showingAiResults.value) {
|
||||
console.log('已经在显示AI结果')
|
||||
return
|
||||
}
|
||||
|
||||
// 保存当前筛选状态
|
||||
savedFilterState.value = torrentFilter.getFilterState()
|
||||
|
||||
// 切换数据
|
||||
rawDataList.value = [...aiRecommendedList.value]
|
||||
showingAiResults.value = true
|
||||
console.log('已切换到智能推荐结果')
|
||||
|
||||
// 清空智能推荐筛选条件
|
||||
torrentFilter.clearAllFilters()
|
||||
|
||||
// 重新应用筛选
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 切换回原始结果(自动还原筛选条件)
|
||||
async function switchToOriginalResults() {
|
||||
if (!showingAiResults.value) {
|
||||
console.log('已经在显示原始结果')
|
||||
return
|
||||
}
|
||||
|
||||
// 切换数据
|
||||
rawDataList.value = [...originalDataList.value]
|
||||
showingAiResults.value = false
|
||||
console.log('已切换到原始结果')
|
||||
|
||||
// 恢复原始筛选条件
|
||||
if (savedFilterState.value) {
|
||||
torrentFilter.setFilterState(savedFilterState.value)
|
||||
}
|
||||
|
||||
// 重新应用筛选
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 智能推荐/切换结果
|
||||
async function toggleAiRecommend() {
|
||||
// 如果当前显示AI结果,则切换回原始结果
|
||||
if (showingAiResults.value) {
|
||||
await switchToOriginalResults()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经有智能推荐结果,直接切换
|
||||
if (aiRecommended.value && aiRecommendedList.value.length > 0) {
|
||||
await switchToAiResults()
|
||||
return
|
||||
}
|
||||
|
||||
// 否则启动智能推荐
|
||||
// 保存当前筛选状态,以便切换回原始结果时恢复
|
||||
savedFilterState.value = torrentFilter.getFilterState()
|
||||
console.log('首次智能推荐,已保存筛选状态:', savedFilterState.value)
|
||||
|
||||
startAiRecommend()
|
||||
}
|
||||
|
||||
// 启动智能推荐(开始轮询)
|
||||
async function startAiRecommend(force: boolean = false) {
|
||||
isRecommending.value = true
|
||||
console.log('启动智能推荐', force ? '(强制)' : '')
|
||||
|
||||
// 首次或强制时,先发送一个启动任务的请求
|
||||
await sendInitialRequest(force)
|
||||
|
||||
// 然后开始 check_only 轮询
|
||||
startAiRecommendPolling()
|
||||
}
|
||||
|
||||
// 发送初始请求以启动智能推荐任务
|
||||
async function sendInitialRequest(force: boolean = false) {
|
||||
try {
|
||||
const requestBody: any = {}
|
||||
|
||||
// 检查是否有筛选条件
|
||||
const hasFilters = torrentFilter.hasActiveFilters()
|
||||
if (hasFilters) {
|
||||
const indices = torrentFilter.getFilteredIndices()
|
||||
if (indices && indices.length > 0) {
|
||||
requestBody.filtered_indices = indices
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是强制模式,添加 force 标志
|
||||
if (force) {
|
||||
requestBody.force = true
|
||||
}
|
||||
|
||||
console.log('发送初始请求以启动任务', force ? '(force)' : '')
|
||||
await api.post('search/recommend', requestBody)
|
||||
} catch (error) {
|
||||
console.error('发送初始请求失败:', error)
|
||||
isRecommending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 开始轮询智能推荐(使用 check_only 模式)
|
||||
function startAiRecommendPolling() {
|
||||
// 停止可能存在的轮询
|
||||
stopAiRecommendPolling()
|
||||
|
||||
// 立即发送一次 check_only 请求
|
||||
pollAiRecommend()
|
||||
|
||||
// 然后每2秒轮询一次(check_only)
|
||||
aiStatusCheckInterval = setInterval(() => {
|
||||
pollAiRecommend()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 轮询智能推荐状态(始终使用 check_only 模式)
|
||||
async function pollAiRecommend() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('search/recommend', {
|
||||
check_only: true,
|
||||
})
|
||||
|
||||
const { success, data } = result
|
||||
const status = data?.status
|
||||
|
||||
// 正在运行,继续轮询
|
||||
if (success && status === 'running') {
|
||||
console.log('AI推理中...')
|
||||
return
|
||||
}
|
||||
|
||||
// 其他所有状态均停止轮询
|
||||
stopAiRecommendPolling()
|
||||
isRecommending.value = false
|
||||
|
||||
if (success && status === 'completed') {
|
||||
// 推荐完成
|
||||
if (data.results?.length > 0) {
|
||||
// 加载智能推荐结果
|
||||
loadAiRecommendedResults(data.results)
|
||||
|
||||
// 自动切换到智能推荐结果(会自动保存筛选条件)
|
||||
await switchToAiResults()
|
||||
}
|
||||
} else if (success && status === 'disabled') {
|
||||
// 功能停用
|
||||
console.error('AI功能未启用')
|
||||
} else {
|
||||
// 错误情况(status === 'error' 或 success 为 false)
|
||||
const errMsg = result.message || data?.error || data?.message || 'Unknown error'
|
||||
console.error('智能推荐错误:', errMsg)
|
||||
toast.error(`${t('resource.aiRecommendError')}: ${errMsg}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('智能推荐轮询失败:', error)
|
||||
stopAiRecommendPolling()
|
||||
isRecommending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 停止轮询智能推荐
|
||||
function stopAiRecommendPolling() {
|
||||
if (aiStatusCheckInterval) {
|
||||
clearInterval(aiStatusCheckInterval)
|
||||
aiStatusCheckInterval = null
|
||||
console.log('停止智能推荐轮询')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载智能推荐结果(从索引数组提取数据)
|
||||
function loadAiRecommendedResults(indices: number[]) {
|
||||
if (!indices || indices.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从原始数据中根据索引提取结果
|
||||
aiRecommendedList.value = indices.map((index: number) => originalDataList.value[index]).filter(Boolean)
|
||||
aiRecommended.value = true
|
||||
console.log(`加载智能推荐结果: ${aiRecommendedList.value.length} 条`)
|
||||
}
|
||||
|
||||
// 重新推荐
|
||||
async function reRecommend() {
|
||||
try {
|
||||
isReRecommending.value = true
|
||||
console.log('重新推荐:重置状态')
|
||||
|
||||
// 重置状态
|
||||
aiRecommended.value = false
|
||||
aiRecommendedList.value = []
|
||||
|
||||
// 切换回原始结果(会自动还原筛选条件)
|
||||
await switchToOriginalResults()
|
||||
|
||||
// 等待筛选数据还原完成(nextTick确保DOM更新完成)
|
||||
await nextTick()
|
||||
|
||||
// 再等待一个微任务,确保筛选逻辑完全执行
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// 重新启动智能推荐(带 force 标志)
|
||||
startAiRecommend(true)
|
||||
} catch (error) {
|
||||
console.error('重新推荐失败:', error)
|
||||
} finally {
|
||||
isReRecommending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查智能推荐状态(页面初始化时调用一次)
|
||||
async function checkAiRecommendStatus() {
|
||||
try {
|
||||
// 首次检查时使用 check_only 模式
|
||||
const result: { [key: string]: any } = await api.post('search/recommend', {
|
||||
check_only: true,
|
||||
})
|
||||
|
||||
const { success, data } = result
|
||||
const status = data?.status
|
||||
|
||||
// 只要有数据且状态不是disabled,就标记已检查(允许重试)
|
||||
if (data && status !== 'disabled') {
|
||||
aiStatusChecked.value = true
|
||||
}
|
||||
|
||||
if (success && data) {
|
||||
const { results } = data
|
||||
|
||||
// 如果有完成的结果,加载它
|
||||
if (status === 'completed' && results && results.length > 0) {
|
||||
loadAiRecommendedResults(results)
|
||||
}
|
||||
|
||||
// 如果正在运行,启动轮询
|
||||
if (status === 'running') {
|
||||
isRecommending.value = true
|
||||
startAiRecommendPolling()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查AI状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算当前显示的数据是否有数据
|
||||
const hasData = computed(() => {
|
||||
if (viewType.value === 'row') {
|
||||
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
} else {
|
||||
return filteredCardDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 AI_RECOMMEND_ENABLED 状态和数据加载状态
|
||||
// 使用 watchEffect 确保计算属性变化时立即响应
|
||||
watchEffect(() => {
|
||||
// 需要满足:AI 功能启用、数据已加载、尚未检查
|
||||
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
|
||||
checkAiRecommendStatus()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
fetchData()
|
||||
})
|
||||
|
||||
// 卸载时停止加载进度
|
||||
// 卸载时停止轮询
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
stopAiRecommendPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -215,9 +581,10 @@ onUnmounted(() => {
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
{{ t('resource.searchResults') }}
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
|
||||
</div>
|
||||
<div class="search-tags d-flex flex-wrap mt-1">
|
||||
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
|
||||
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="flat">
|
||||
{{ t('resource.keyword') }}: {{ keyword }}
|
||||
</VChip>
|
||||
@@ -232,10 +599,61 @@ onUnmounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- AI操作按钮组 -->
|
||||
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
|
||||
<div class="ai-toggle-buttons">
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="0"
|
||||
@click="toggleAiRecommend"
|
||||
:disabled="isRecommending || !aiStatusChecked"
|
||||
height="44"
|
||||
class="ps-4 pe-3 ai-recommend-btn"
|
||||
:class="{ 'ai-active': showingAiResults }"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" />
|
||||
</template>
|
||||
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }">
|
||||
{{ t('resource.aiRecommend') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
|
||||
<VExpandXTransition>
|
||||
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
|
||||
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="0"
|
||||
:disabled="isRecommending || !aiStatusChecked"
|
||||
@click="reRecommend"
|
||||
height="44"
|
||||
min-width="38"
|
||||
class="px-0"
|
||||
>
|
||||
<VIcon
|
||||
:icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
|
||||
size="18"
|
||||
class="ai-refresh-icon"
|
||||
/>
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('resource.reRecommend') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</VExpandXTransition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重新设计的视图切换按钮 -->
|
||||
<div class="view-toggle-container">
|
||||
<div class="view-toggle-buttons">
|
||||
<div class="active-indicator" :class="viewType"></div>
|
||||
<button class="view-toggle-btn" :class="{ active: viewType === 'card' }" @click="changeViewType('card')">
|
||||
<VIcon icon="mdi-view-grid-outline" :color="viewType === 'card' ? 'primary' : undefined" />
|
||||
</button>
|
||||
@@ -246,39 +664,91 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 视图切换加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="isRefreshed && isViewChanging" class="view-changing-container rounded-lg">
|
||||
<div class="view-changing-content">
|
||||
<div class="pulse-loader">
|
||||
<div class="pulse-circle"></div>
|
||||
<div class="pulse-circle"></div>
|
||||
<div class="pulse-circle"></div>
|
||||
</div>
|
||||
<div class="view-changing-text">{{ t('resource.switchingView') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div v-if="isRefreshed && dataList.length > 0 && !isViewChanging" class="search-results-container">
|
||||
<!-- 卡片视图模式 -->
|
||||
<VFadeTransition>
|
||||
<div>
|
||||
<TorrentCardListView v-if="viewType === 'card'" :items="dataList" />
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
<div v-if="isRefreshed && hasData" class="search-results-container">
|
||||
<!-- 筛选栏 -->
|
||||
<TorrentFilterBar
|
||||
:filter-form="torrentFilter.filterForm"
|
||||
:filter-options="torrentFilter.filterOptions"
|
||||
:sort-field="torrentFilter.sortField.value"
|
||||
:sort-type="torrentFilter.sortType.value"
|
||||
:total-filtered-count="torrentFilter.totalFilteredCount.value"
|
||||
:filter-titles="torrentFilter.filterTitles"
|
||||
:sort-titles="torrentFilter.sortTitles"
|
||||
:enable-animation="enableFilterAnimation"
|
||||
@update:sort-field="val => (torrentFilter.sortField.value = val)"
|
||||
@update:sort-type="val => (torrentFilter.sortType.value = val)"
|
||||
@update:filter-form="handleFilterFormUpdate"
|
||||
@select-all="handleSelectAll"
|
||||
@clear-filter="handleClearFilter"
|
||||
@clear-all-filters="handleClearAllFilters"
|
||||
@remove-filter="handleRemoveFilter"
|
||||
/>
|
||||
|
||||
<!-- 列表视图模式 -->
|
||||
<VFadeTransition>
|
||||
<div>
|
||||
<TorrentRowListView v-if="viewType === 'row'" :items="dataList" />
|
||||
<!-- 视图切换区域 -->
|
||||
<VFadeTransition mode="out-in">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'" key="card">
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="cardScroll.displayDataList.value"
|
||||
class="overflow-visible"
|
||||
@load="cardScroll.loadMore"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-torrent-card items-start">
|
||||
<TorrentCard
|
||||
v-for="item in cardScroll.displayDataList.value"
|
||||
:key="`${item.torrent_info.page_url}`"
|
||||
:torrent="item"
|
||||
:more="item.more"
|
||||
/>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="cardScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表视图模式 -->
|
||||
<div v-else-if="viewType === 'row'" key="row">
|
||||
<VCard class="resource-list-container">
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="rowScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-else
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="rowScroll.displayDataList.value"
|
||||
class="resource-list overflow-visible"
|
||||
@load="rowScroll.loadMore"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div
|
||||
v-for="(item, index) in rowScroll.displayDataList.value"
|
||||
:key="`${item.torrent_info?.enclosure || ''}-${index}`"
|
||||
>
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</VCard>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
</div>
|
||||
|
||||
<!-- 无数据显示 -->
|
||||
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
|
||||
<div v-else-if="isRefreshed" class="d-flex flex-column align-center justify-center py-8">
|
||||
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
||||
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
||||
{{ t('resource.backToHome') }}
|
||||
@@ -343,8 +813,8 @@ onUnmounted(() => {
|
||||
/* 精简标题栏样式 */
|
||||
.search-header {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
@@ -374,6 +844,25 @@ onUnmounted(() => {
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
position: relative;
|
||||
isolation: isolate; /* Create new stacking context */
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 40px;
|
||||
height: 36px;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.active-indicator.row {
|
||||
transform: translateX(40px);
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
@@ -381,79 +870,87 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
block-size: 36px;
|
||||
cursor: pointer;
|
||||
inline-size: 40px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.view-toggle-btn.active {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 10%);
|
||||
z-index: 2; /* Sit on top of indicator */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view-toggle-btn:hover:not(.active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 视图切换加载状态 */
|
||||
.view-changing-container {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
/* AI按钮组样式 */
|
||||
.ai-toggle-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
overflow: hidden;
|
||||
height: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
|
||||
}
|
||||
|
||||
.view-changing-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.ai-recommend-btn {
|
||||
transition: all 0.3s ease;
|
||||
margin: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pulse-loader {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
/* 仅为激活的按钮添加背景 */
|
||||
.ai-recommend-btn.ai-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pulse-circle {
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 12px;
|
||||
inline-size: 12px;
|
||||
/* 图标基础样式 */
|
||||
.ai-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.pulse-circle:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.pulse-circle:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.view-changing-text {
|
||||
/* 激活状态图标:变色 + 辉光 */
|
||||
.ai-icon-active {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));
|
||||
}
|
||||
|
||||
/* 文字基础样式 */
|
||||
.ai-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-weight: 600; /* 保持一致的字重防止位移 */
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.3s ease;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* 激活状态文字 */
|
||||
.ai-text-active {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
/* 刷新图标样式 */
|
||||
.ai-refresh-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.ai-divider {
|
||||
width: 0; /* 宽度设为0,不占用空间 */
|
||||
height: 20px;
|
||||
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.search-results-container {
|
||||
@@ -461,27 +958,52 @@ onUnmounted(() => {
|
||||
min-block-size: 50vh;
|
||||
}
|
||||
|
||||
/* 卡片网格布局 */
|
||||
.grid-torrent-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
/* 列表视图样式 */
|
||||
.resource-list-container {
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 无结果提示 */
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 300px;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.search-header {
|
||||
padding-block: 8px;
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.search-title {
|
||||
font-size: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-info-container {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.search-tags {
|
||||
flex-wrap: nowrap;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-end: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -498,9 +1020,43 @@ onUnmounted(() => {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.active-indicator.row {
|
||||
transform: translateX(36px);
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
block-size: 32px;
|
||||
inline-size: 36px;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.ai-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ai-recommend-btn,
|
||||
.ai-toggle-buttons .v-btn {
|
||||
height: 36px !important;
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
||||
.ai-recommend-btn {
|
||||
padding-inline-start: 12px !important;
|
||||
padding-inline-end: 8px !important;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons .v-btn:last-child {
|
||||
min-width: 32px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,10 +12,11 @@ import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const settingTabs = computed(() => getSettingTabs())
|
||||
const settingTabs = computed(() => getSettingTabs(t))
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -22,9 +22,9 @@ const shareViewKey = ref(0)
|
||||
// 获取标签页
|
||||
const subscribeTabs = computed(() => {
|
||||
if (subType === '电影') {
|
||||
return getSubscribeMovieTabs()
|
||||
return getSubscribeMovieTabs(t)
|
||||
} else {
|
||||
return getSubscribeTvTabs()
|
||||
return getSubscribeTvTabs(t)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ const listViewKey = ref(0)
|
||||
|
||||
// 获取标签页
|
||||
const workflowTabs = computed(() => {
|
||||
return getWorkflowTabs()
|
||||
return getWorkflowTabs(t)
|
||||
})
|
||||
|
||||
// 新增工作流对话框
|
||||
|
||||
@@ -1,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',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { Composer } from 'vue-i18n'
|
||||
|
||||
// 构建路由菜单,每次调用时使用当前的语言环境
|
||||
export function getNavMenus() {
|
||||
const { t } = useI18n()
|
||||
export function getNavMenus(t: Composer['t']) {
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 检查是否为高级模式
|
||||
@@ -148,9 +147,7 @@ export function getNavMenus() {
|
||||
}
|
||||
|
||||
// 获取设置标签页
|
||||
export function getSettingTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSettingTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('settingTabs.system.title'),
|
||||
@@ -204,9 +201,7 @@ export function getSettingTabs() {
|
||||
}
|
||||
|
||||
// 获取电影订阅标签页
|
||||
export function getSubscribeMovieTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSubscribeMovieTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('subscribeTabs.movie.mysub'),
|
||||
@@ -222,9 +217,7 @@ export function getSubscribeMovieTabs() {
|
||||
}
|
||||
|
||||
// 获取电视剧订阅标签页
|
||||
export function getSubscribeTvTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getSubscribeTvTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('subscribeTabs.tv.mysub'),
|
||||
@@ -245,9 +238,7 @@ export function getSubscribeTvTabs() {
|
||||
}
|
||||
|
||||
// 获取插件标签页
|
||||
export function getPluginTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getPluginTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('pluginTabs.installed'),
|
||||
@@ -263,9 +254,7 @@ export function getPluginTabs() {
|
||||
}
|
||||
|
||||
// 获取发现标签页
|
||||
export function getDiscoverTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getDiscoverTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
name: t('discoverTabs.themoviedb'),
|
||||
@@ -286,9 +275,7 @@ export function getDiscoverTabs() {
|
||||
}
|
||||
|
||||
// 获取工作流标签页
|
||||
export function getWorkflowTabs() {
|
||||
const { t } = useI18n()
|
||||
|
||||
export function getWorkflowTabs(t: Composer['t']) {
|
||||
return [
|
||||
{
|
||||
title: t('workflowTabs.list'),
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
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)
|
||||
|
||||
// 监听安装事件
|
||||
self.addEventListener('install', () => {
|
||||
// 强制等待中的 Service Worker 立即激活
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// 监听激活事件
|
||||
self.addEventListener('activate', event => {
|
||||
// 让 Service Worker 立即接管页面
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim()
|
||||
// 清理旧版本的运行时缓存
|
||||
await cleanupRuntimeCaches(true)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
// 通知选项
|
||||
const options = {
|
||||
@@ -38,100 +51,229 @@ 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/system/progress/') && // SSE实时进度流
|
||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||
!url.pathname.includes('/api/v1/message/') && // 用户消息接口
|
||||
!url.pathname.includes('/api/v1/system/global') && // 系统配置接口
|
||||
!url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口
|
||||
!url.pathname.includes('/api/v1/dashboard/'), // Dashboard实时监控数据
|
||||
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 +281,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 +292,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 +385,7 @@ self.addEventListener('push', function (event) {
|
||||
title: event.data?.text(),
|
||||
}
|
||||
}
|
||||
// 根据推送消息生成桌面通知并展现出来
|
||||
|
||||
try {
|
||||
const content = {
|
||||
body: payload.body || '',
|
||||
@@ -515,7 +395,6 @@ self.addEventListener('push', function (event) {
|
||||
actions: options.actions,
|
||||
}
|
||||
|
||||
// 增加未读消息计数并持久化存储
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
@@ -525,11 +404,11 @@ self.addEventListener('push', function (event) {
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
// 静默处理错误
|
||||
// 忽略错误
|
||||
}
|
||||
})
|
||||
|
||||
// 监听通知点击事件
|
||||
// 监听通知点击
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
const info = event.notification
|
||||
if (event.action === 'close') {
|
||||
@@ -539,10 +418,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 +429,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 +439,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 +467,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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { globalSettingsState } from '@/stores/types'
|
||||
import { fetchGlobalSettings } from '@/utils/globalSetting'
|
||||
import { useVersionChecker } from '@/composables/useVersionChecker'
|
||||
import api from '@/api'
|
||||
|
||||
export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||
state: (): globalSettingsState => ({
|
||||
@@ -18,6 +20,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 {
|
||||
@@ -25,6 +33,19 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||
}
|
||||
},
|
||||
|
||||
// 登录后加载用户相关设置
|
||||
async loadUserSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/global/user')
|
||||
if (result.success && result.data) {
|
||||
// 合并用户设置到现有数据
|
||||
this.data = { ...this.data, ...result.data }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user settings', error)
|
||||
}
|
||||
},
|
||||
|
||||
setData(data: { [key: string]: any }) {
|
||||
this.data = data
|
||||
this.initialized = true
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 网格布局样式
|
||||
|
||||
3
src/types/global.d.ts
vendored
3
src/types/global.d.ts
vendored
@@ -1,5 +1,8 @@
|
||||
// PWA Badge API 类型定义
|
||||
declare global {
|
||||
const __APP_VERSION__: string
|
||||
const __BUILD_TIME__: string
|
||||
|
||||
interface Navigator {
|
||||
/**
|
||||
* 设置应用徽章数量
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -150,7 +150,8 @@ async function loadSeasonEpisodes(season: number) {
|
||||
// 加载季集信息
|
||||
if (seasonEpisodesInfo.value[season]) return
|
||||
try {
|
||||
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`)
|
||||
const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined
|
||||
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
|
||||
seasonEpisodesInfo.value[season] = result || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -32,7 +32,7 @@ const { appMode } = usePWA()
|
||||
const activeTab = ref('installed')
|
||||
|
||||
// 获取插件标签页
|
||||
const pluginTabs = computed(() => getPluginTabs())
|
||||
const pluginTabs = computed(() => getPluginTabs(t))
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -32,40 +32,13 @@ const endpoints = {
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
|
||||
// 查询存储
|
||||
async function loadStorages() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
|
||||
storages.value = result.data?.value ?? []
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
const storageTypes = computed(() => storages.value.map(s => s.type))
|
||||
|
||||
// 当前文件项
|
||||
const operItem = ref<FileItem>({
|
||||
storage: 'local',
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
fileid: 'root',
|
||||
})
|
||||
const operItem = ref<FileItem | undefined>(undefined)
|
||||
|
||||
// fileid的堆栈
|
||||
const itemstack = ref<FileItem[]>([
|
||||
{
|
||||
storage: 'local',
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
fileid: 'root',
|
||||
},
|
||||
])
|
||||
|
||||
// 下载目录列表
|
||||
const downloadDirectories = ref<TransferDirectoryConf[]>([])
|
||||
const itemstack = ref<FileItem[]>([])
|
||||
|
||||
// 计算公共路径
|
||||
function findCommonPath(paths: string[]): string {
|
||||
@@ -101,29 +74,97 @@ function findCommonPath(paths: string[]): string {
|
||||
return commonPath
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'fileBrowserView.activeStorage'
|
||||
|
||||
interface BrowserInitialParams {
|
||||
storage: string;
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
// determine which entry to select initially
|
||||
function determineBrowserInitialParams(downloadDirectories: TransferDirectoryConf[]): BrowserInitialParams {
|
||||
const isAvailable = (storage: string) => storageTypes.value.includes(storage);
|
||||
const buckets = downloadDirectories.reduce<Map<string, string[]>>((dict, item) => {
|
||||
// filter out directories whose storage is not available
|
||||
if (!isAvailable(item.storage)) {
|
||||
return dict
|
||||
}
|
||||
if (item.download_path == undefined) {
|
||||
return dict
|
||||
}
|
||||
if (!dict.has(item.storage)) {
|
||||
dict.set(item.storage, [item.download_path])
|
||||
} else {
|
||||
dict.get(item.storage)!.push(item.download_path)
|
||||
}
|
||||
return dict
|
||||
}, new Map());
|
||||
|
||||
const cachedStorage = localStorage.getItem(STORAGE_KEY) || '';
|
||||
// if no download directories are configured, fall back to cached storage or first available storage
|
||||
if (buckets.size === 0) {
|
||||
return {
|
||||
storage: isAvailable(cachedStorage)
|
||||
? cachedStorage
|
||||
: (storageTypes.value[0] || 'local'),
|
||||
path: '/',
|
||||
name: '/',
|
||||
}
|
||||
}
|
||||
let selectedEntry: [string, string[]];
|
||||
if (cachedStorage && buckets.has(cachedStorage)) {
|
||||
selectedEntry = [cachedStorage, buckets.get(cachedStorage)!];
|
||||
} else {
|
||||
// if no storage selected previously, use the most populous one
|
||||
selectedEntry = Array.from(buckets.entries()).reduce((prev, curr) => {
|
||||
return curr[1].length > prev[1].length ? curr : prev;
|
||||
});
|
||||
}
|
||||
|
||||
const path = findCommonPath(selectedEntry[1]);
|
||||
return {
|
||||
storage: selectedEntry[0],
|
||||
path,
|
||||
name: path.split('/').filter(Boolean).pop() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
// 查询下载目录
|
||||
async function loadDownloadDirectories() {
|
||||
try {
|
||||
// fetch available storages
|
||||
const storageResult: { [key: string]: any } = await api.get('system/setting/Storages')
|
||||
storages.value = storageResult.data?.value ?? []
|
||||
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
||||
if (result.success && result.data?.value) {
|
||||
downloadDirectories.value = result.data.value
|
||||
const path = findCommonPath(downloadDirectories.value.map(item => item.download_path) as string[])
|
||||
const name = path.split('/').filter(Boolean).pop() ?? ''
|
||||
const { storage, path, name } = determineBrowserInitialParams(result.data.value);
|
||||
// operItem初始化
|
||||
operItem.value = {
|
||||
storage: 'local',
|
||||
type: 'dir',
|
||||
storage,
|
||||
name: name,
|
||||
path: path,
|
||||
}
|
||||
// itemstack初始化
|
||||
itemstack.value = [
|
||||
{
|
||||
storage: storage,
|
||||
type: 'dir',
|
||||
name: '/',
|
||||
path: '/',
|
||||
fileid: 'root',
|
||||
}
|
||||
];
|
||||
// 将初始数据拆分到堆栈中
|
||||
const paths = path.split('/').filter(Boolean)
|
||||
paths.map((name, index) => {
|
||||
const path = '/' + paths.slice(0, index + 1).join('/') + '/'
|
||||
itemstack.value.push({
|
||||
storage: 'local',
|
||||
storage,
|
||||
type: 'dir',
|
||||
name: name,
|
||||
path: path,
|
||||
name,
|
||||
path,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -134,6 +175,11 @@ async function loadDownloadDirectories() {
|
||||
|
||||
// 目录变化
|
||||
function pathChanged(item: FileItem) {
|
||||
// save storage to localStorage
|
||||
if (item.storage !== operItem.value?.storage) {
|
||||
localStorage.setItem(STORAGE_KEY, item.storage)
|
||||
}
|
||||
|
||||
operItem.value = item
|
||||
if (item.path == '/') {
|
||||
itemstack.value = [
|
||||
@@ -156,16 +202,13 @@ function pathChanged(item: FileItem) {
|
||||
}
|
||||
|
||||
// 加载初始目录
|
||||
onBeforeMount(loadDownloadDirectories)
|
||||
|
||||
onMounted(() => {
|
||||
loadStorages()
|
||||
})
|
||||
onMounted(loadDownloadDirectories)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-browser-view">
|
||||
<FileBrowser
|
||||
v-if="operItem"
|
||||
:storages="storages"
|
||||
:tree="false"
|
||||
:itemstack="itemstack"
|
||||
|
||||
@@ -46,12 +46,37 @@ const redoTargetStorage = ref<string>()
|
||||
// 已选中的数据
|
||||
const selected = ref<TransferHistory[]>([])
|
||||
|
||||
const getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0);
|
||||
|
||||
function sortByTitle(a: TransferHistory, b: TransferHistory) {
|
||||
if (a.type !== b.type) {
|
||||
return (a.type ?? '').localeCompare(b.type ?? '');
|
||||
}
|
||||
if (a.title !== b.title) {
|
||||
return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase());
|
||||
}
|
||||
if (a.type === '电视剧') {
|
||||
if (a.seasons !== b.seasons) {
|
||||
return getNum(a.seasons) - getNum(b.seasons);
|
||||
}
|
||||
if (a.episodes !== b.episodes) {
|
||||
return getNum(a.episodes) - getNum(b.episodes);
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function sortBySourceSize(a: TransferHistory, b: TransferHistory) {
|
||||
return (a.src_fileitem?.size ?? 0) - (b.src_fileitem?.size ?? 0)
|
||||
}
|
||||
|
||||
// 表头
|
||||
const headers = [
|
||||
{
|
||||
title: t('transferHistory.titleColumn'),
|
||||
key: 'title',
|
||||
sortable: true,
|
||||
sortRaw: sortByTitle,
|
||||
},
|
||||
{
|
||||
title: t('transferHistory.pathColumn'),
|
||||
@@ -67,6 +92,7 @@ const headers = [
|
||||
title: t('transferHistory.sizeColumn'),
|
||||
key: 'size',
|
||||
sortable: true,
|
||||
sortRaw: sortBySourceSize,
|
||||
},
|
||||
{
|
||||
title: t('transferHistory.dateColumn'),
|
||||
@@ -91,6 +117,7 @@ const groupHeaders = [
|
||||
title: t('transferHistory.seasonEpisode'),
|
||||
key: 'title',
|
||||
sortable: true,
|
||||
sortRaw: sortByTitle,
|
||||
},
|
||||
{
|
||||
title: t('transferHistory.pathColumn'),
|
||||
@@ -106,6 +133,7 @@ const groupHeaders = [
|
||||
title: t('transferHistory.sizeColumn'),
|
||||
key: 'size',
|
||||
sortable: true,
|
||||
sortRaw: sortBySourceSize,
|
||||
},
|
||||
{
|
||||
title: t('transferHistory.dateColumn'),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TransferDirectoryConf, StorageConf } from '@/api/types'
|
||||
import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||
import StorageCard from '@/components/cards/StorageCard.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
|
||||
@@ -28,6 +29,9 @@ const $toast = useToast()
|
||||
// 进度框
|
||||
const progressDialog = ref(false)
|
||||
|
||||
// 分类编辑对话框
|
||||
const categoryDialog = ref(false)
|
||||
|
||||
// 数据源
|
||||
const sourceItems = [
|
||||
{ 'title': 'TheMovieDb', 'value': 'themoviedb' },
|
||||
@@ -292,7 +296,12 @@ onMounted(() => {
|
||||
:directory="element"
|
||||
:categories="mediaCategories"
|
||||
:storages="storages"
|
||||
@update:modelValue="(value: any) => {element.download_path = value?.download; element.library_path = value?.library}"
|
||||
@update:modelValue="
|
||||
(value: any) => {
|
||||
element.download_path = value?.download
|
||||
element.library_path = value?.library
|
||||
}
|
||||
"
|
||||
@close="removeDirectory(element)"
|
||||
/>
|
||||
</template>
|
||||
@@ -304,9 +313,13 @@ onMounted(() => {
|
||||
<VBtn type="submit" @click="saveDirectories" prepend-icon="mdi-content-save">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="addDirectory">
|
||||
<VBtn color="success" variant="tonal" @click="addDirectory" class="me-2">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="info" variant="tonal" prepend-icon="mdi-shape-plus" @click="categoryDialog = true">
|
||||
{{ t('setting.category.title') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -370,4 +383,12 @@ onMounted(() => {
|
||||
</VRow>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('setting.system.reloading')" />
|
||||
<!-- 分类对话框 -->
|
||||
<CategoryEditDialog
|
||||
v-if="categoryDialog"
|
||||
v-model="categoryDialog"
|
||||
:categories="mediaCategories"
|
||||
@close="categoryDialog = false"
|
||||
@done="loadMediaCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,10 +32,15 @@ const SystemSettings = ref<any>({
|
||||
OCR_HOST: null,
|
||||
CUSTOMIZE_WALLPAPER_API_URL: null,
|
||||
AI_AGENT_ENABLE: false,
|
||||
AI_AGENT_GLOBAL: false,
|
||||
LLM_PROVIDER: 'deepseek',
|
||||
LLM_MODEL: 'deepseek-chat',
|
||||
LLM_API_KEY: null,
|
||||
LLM_BASE_URL: 'https://api.deepseek.com',
|
||||
AI_RECOMMEND_ENABLED: false,
|
||||
AI_RECOMMEND_USER_PREFERENCE: null,
|
||||
AI_RECOMMEND_MAX_ITEMS: 50,
|
||||
LLM_MAX_CONTEXT_TOKENS: 64,
|
||||
},
|
||||
// 高级系统设置
|
||||
Advanced: {
|
||||
@@ -75,6 +80,7 @@ const SystemSettings = ref<any>({
|
||||
// 实验室
|
||||
PLUGIN_AUTO_RELOAD: false,
|
||||
ENCODING_DETECTION_PERFORMANCE_MODE: true,
|
||||
TRANSFER_THREADS: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -119,6 +125,10 @@ const progressDialog = ref(false)
|
||||
// 高级设置对话框
|
||||
const advancedDialog = ref(false)
|
||||
|
||||
// LLM 模型列表
|
||||
const llmModels = ref<string[]>([])
|
||||
const loadingModels = ref(false)
|
||||
|
||||
const activeTab = ref('system')
|
||||
|
||||
// 元数据语言
|
||||
@@ -154,6 +164,30 @@ const logLevelItems = [
|
||||
// 安全域名添加变量
|
||||
const newSecurityDomain = ref('')
|
||||
|
||||
// 加载LLM模型列表
|
||||
async function loadLlmModels() {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
||||
params: {
|
||||
provider: SystemSettings.value.Basic.LLM_PROVIDER,
|
||||
api_key: SystemSettings.value.Basic.LLM_API_KEY,
|
||||
base_url: SystemSettings.value.Basic.LLM_BASE_URL,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
llmModels.value = result.data
|
||||
if (llmModels.value.length > 0) SystemSettings.value.Basic.LLM_MODEL = llmModels.value[0]
|
||||
} else {
|
||||
$toast.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
loadingModels.value = false
|
||||
}
|
||||
|
||||
// 添加安全域名
|
||||
function addSecurityDomain() {
|
||||
if (
|
||||
@@ -614,7 +648,7 @@ onDeactivated(() => {
|
||||
</VRow>
|
||||
<VDivider class="my-4" />
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
|
||||
:label="t('setting.system.aiAgentEnable')"
|
||||
@@ -622,6 +656,14 @@ onDeactivated(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
:hint="t('setting.system.aiAgentGlobalHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.LLM_PROVIDER"
|
||||
@@ -638,12 +680,12 @@ onDeactivated(() => {
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_MODEL"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
placeholder="gpt-3.5-turbo"
|
||||
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
placeholder="https://api.deepseek.com"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-brain"
|
||||
prepend-inner-icon="mdi-link"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
@@ -658,13 +700,72 @@ onDeactivated(() => {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="SystemSettings.Basic.LLM_BASE_URL"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
placeholder="https://api.deepseek.com"
|
||||
<VCombobox
|
||||
v-model="SystemSettings.Basic.LLM_MODEL"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:placeholder="t('setting.system.llmModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link"
|
||||
:items="llmModels"
|
||||
:loading="loadingModels"
|
||||
prepend-inner-icon="mdi-brain"
|
||||
>
|
||||
<template #append-inner>
|
||||
<VBtn
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
@click="loadLlmModels"
|
||||
:disabled="!SystemSettings.Basic.LLM_API_KEY"
|
||||
/>
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
|
||||
:label="t('setting.system.llmMaxContextTokens')"
|
||||
:hint="t('setting.system.llmMaxContextTokensHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-counter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
|
||||
<VSwitch
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
:label="t('setting.system.aiRecommendEnabled')"
|
||||
:hint="t('setting.system.aiRecommendEnabledHint')"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextarea
|
||||
v-model="SystemSettings.Basic.AI_RECOMMEND_USER_PREFERENCE"
|
||||
:label="t('setting.system.aiRecommendUserPreference')"
|
||||
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
|
||||
persistent-hint
|
||||
rows="1"
|
||||
auto-grow
|
||||
prepend-inner-icon="mdi-account-heart"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="SystemSettings.Basic.AI_AGENT_ENABLE && SystemSettings.Basic.AI_RECOMMEND_ENABLED"
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.AI_RECOMMEND_MAX_ITEMS"
|
||||
:label="t('setting.system.aiRecommendMaxItems')"
|
||||
:hint="t('setting.system.aiRecommendMaxItemsHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-format-list-numbered"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
@@ -1328,7 +1429,10 @@ onDeactivated(() => {
|
||||
min="1"
|
||||
type="number"
|
||||
:suffix="t('setting.system.mb')"
|
||||
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'), (v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin')]"
|
||||
:rules="[
|
||||
(v: any) => v === 0 || !!v || t('setting.system.logMaxFileSizeRequired'),
|
||||
(v: any) => v >= 1 || t('setting.system.logMaxFileSizeMin'),
|
||||
]"
|
||||
prepend-inner-icon="mdi-file-document"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -1340,7 +1444,10 @@ onDeactivated(() => {
|
||||
persistent-hint
|
||||
min="1"
|
||||
type="number"
|
||||
:rules="[(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'), (v: any) => v >= 1 || t('setting.system.logBackupCountMin')]"
|
||||
:rules="[
|
||||
(v: any) => v === 0 || !!v || t('setting.system.logBackupCountRequired'),
|
||||
(v: any) => v >= 1 || t('setting.system.logBackupCountMin'),
|
||||
]"
|
||||
prepend-inner-icon="mdi-backup-restore"
|
||||
/>
|
||||
</VCol>
|
||||
@@ -1375,6 +1482,17 @@ onDeactivated(() => {
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Advanced.TRANSFER_THREADS"
|
||||
:label="t('setting.system.transferThreads')"
|
||||
:hint="t('setting.system.transferThreadsHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
min="1"
|
||||
prepend-inner-icon="mdi-swap-horizontal"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
|
||||
@@ -71,7 +71,8 @@ async function eventsHander(subscribe: Subscribe) {
|
||||
}
|
||||
} else {
|
||||
// 调用API查询集信息
|
||||
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`)
|
||||
const params = subscribe.episode_group ? { episode_group: subscribe.episode_group } : undefined
|
||||
const episodes: TmdbEpisode[] = await api.get(`tmdb/${subscribe.tmdbid}/${subscribe.season}`, params ? { params } : undefined)
|
||||
|
||||
interface EpisodeInfo {
|
||||
title: string
|
||||
|
||||
@@ -1,918 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeepWith } from 'lodash-es'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
interface SearchTorrent extends Context {
|
||||
more?: Array<Context>
|
||||
}
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
// 数据列表
|
||||
items: Array as PropType<SearchTorrent[]>,
|
||||
})
|
||||
|
||||
// 过滤表单
|
||||
const filterForm: Record<string, string[]> = reactive({
|
||||
// 站点
|
||||
site: [] as string[],
|
||||
// 季
|
||||
season: [] as string[],
|
||||
// 制作组
|
||||
releaseGroup: [] as string[],
|
||||
// 视频编码
|
||||
videoCode: [] as string[],
|
||||
// 促销状态
|
||||
freeState: [] as string[],
|
||||
// 质量
|
||||
edition: [] as string[],
|
||||
// 分辨率
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 排序选项
|
||||
const sortField = ref('default')
|
||||
// 降序
|
||||
const sortType = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
const sortTitles: Record<string, string> = {
|
||||
default: t('torrent.sortDefault'),
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 过滤项映射
|
||||
const filterTitles: Record<string, string> = {
|
||||
site: t('torrent.filterSite'),
|
||||
season: t('torrent.filterSeason'),
|
||||
freeState: t('torrent.filterFreeState'),
|
||||
videoCode: t('torrent.filterVideoCode'),
|
||||
edition: t('torrent.filterEdition'),
|
||||
resolution: t('torrent.filterResolution'),
|
||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
||||
}
|
||||
|
||||
// 统一存储过滤选项
|
||||
const filterOptions: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
})
|
||||
|
||||
// 完整的数据列表
|
||||
let dataList: SearchTorrent[]
|
||||
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<Array<SearchTorrent>>([])
|
||||
|
||||
// 分组后的数据列表
|
||||
const groupedDataList = ref<Map<string, Context[]>>()
|
||||
|
||||
// 过滤菜单相关
|
||||
const filterMenuOpen = ref(false)
|
||||
const currentFilter = ref('site')
|
||||
|
||||
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
|
||||
const currentFilterOptions = computed(() => {
|
||||
return filterOptions[currentFilter.value]
|
||||
})
|
||||
|
||||
// 添加全部筛选菜单相关
|
||||
const allFilterMenuOpen = ref(false)
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(data: Context) {
|
||||
const { torrent_info, meta_info } = data
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
if (value && !options.includes(value)) {
|
||||
options.push(value)
|
||||
// 如果是season选项,立即进行排序
|
||||
if (options === filterOptions.season) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
||||
optionValue(filterOptions.edition, meta_info?.edition)
|
||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 直接对季集选项进行排序的函数
|
||||
function sortSeasonOptions() {
|
||||
if (filterOptions.season.length <= 1) {
|
||||
return // 不需要排序
|
||||
}
|
||||
|
||||
// 预解析所有选项
|
||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
||||
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
|
||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
||||
|
||||
if (!match) {
|
||||
// 格式不符合规范的放到最后
|
||||
return {
|
||||
original: option,
|
||||
seasonNum: 0,
|
||||
episodeNum: 0,
|
||||
maxEpisodeNum: 0,
|
||||
isWholeSeason: false,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
const seasonNum = parseInt(match[1], 10)
|
||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
||||
const isWholeSeason = !match[3] // 没有E部分表示整季
|
||||
|
||||
return {
|
||||
original: option,
|
||||
seasonNum,
|
||||
episodeNum,
|
||||
maxEpisodeNum,
|
||||
isWholeSeason,
|
||||
index,
|
||||
}
|
||||
})
|
||||
|
||||
// 先对所有项进行分类
|
||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
||||
|
||||
// 对整季按季号降序排序
|
||||
wholeSeasons.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum // 季号降序
|
||||
}
|
||||
return a.index - b.index // 相同季号按原始索引
|
||||
})
|
||||
|
||||
// 对单集先按季号降序排序,季号相同时按集号降序排序
|
||||
episodes.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum // 季号降序
|
||||
}
|
||||
// 使用最大集号进行排序 (对于范围如 E01-E06)
|
||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
||||
if (aMaxEp !== bMaxEp) {
|
||||
return bMaxEp - aMaxEp // 集号降序
|
||||
}
|
||||
// 如果最大集号相同,再比较起始集号
|
||||
if (a.episodeNum !== b.episodeNum) {
|
||||
return b.episodeNum - a.episodeNum
|
||||
}
|
||||
return a.index - b.index // 都相同时按原始索引
|
||||
})
|
||||
|
||||
// 合并结果:整季在前,单集在后
|
||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
||||
|
||||
// 直接更新 filterOptions.season
|
||||
filterOptions.season = sortedOptions
|
||||
}
|
||||
|
||||
// 计算分组后的列表
|
||||
onMounted(() => {
|
||||
// 数据分组
|
||||
const groupMap = new Map<string, Context[]>()
|
||||
// 遍历数据
|
||||
props.items?.forEach(item => {
|
||||
const { torrent_info, meta_info } = item
|
||||
// init options
|
||||
initOptions(item)
|
||||
// group data
|
||||
const key = `${meta_info.name}_${meta_info.resource_pix}_${meta_info.edition}_${meta_info.resource_team}_${meta_info.season_episode}_${torrent_info.size}`
|
||||
if (groupMap.has(key)) {
|
||||
// 已入库相同标题和大小的分组,将当前上下文信息添加到分组中
|
||||
const group = groupMap.get(key)
|
||||
group?.push(item)
|
||||
} else {
|
||||
// 创建新的分组,并将当前上下文信息添加到分组中
|
||||
groupMap.set(key, [item])
|
||||
}
|
||||
})
|
||||
groupedDataList.value = groupMap
|
||||
|
||||
// 确保季集选项排序
|
||||
if (filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
})
|
||||
|
||||
// 修改watch监听,同时监听排序字段的变化
|
||||
watch([filterForm, groupedDataList, sortField, sortType], filterData)
|
||||
|
||||
function filterData() {
|
||||
// 清空列表
|
||||
dataList = []
|
||||
displayDataList.value = []
|
||||
// 匹配过滤函数,filter中有任一值包含value则返回true
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
// 筛选数据
|
||||
const filteredData: SearchTorrent[] = []
|
||||
|
||||
groupedDataList.value?.forEach(value => {
|
||||
if (value.length > 0) {
|
||||
const matchData = value.filter(data => {
|
||||
const { meta_info, torrent_info } = data
|
||||
// 季、制作组、视频编码
|
||||
return (
|
||||
// 站点过滤
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
// 促销状态过滤
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
// 季过滤
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
// 制作组过滤
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
// 视频编码过滤
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
// 分辨率过滤
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
// 质量过滤
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
)
|
||||
})
|
||||
if (matchData.length > 0) {
|
||||
const firstData = cloneDeepWith(matchData[0]) as SearchTorrent
|
||||
if (matchData.length > 1) firstData.more = matchData.slice(1)
|
||||
filteredData.push(firstData)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 排序数据
|
||||
if (sortField.value !== 'default') {
|
||||
filteredData.sort((a, b) => {
|
||||
if (sortType.value === 'desc') {
|
||||
if (sortField.value === 'site') {
|
||||
// 按站点名称排序
|
||||
return (a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || '')
|
||||
} else if (sortField.value === 'size') {
|
||||
// 按文件大小排序(降序)
|
||||
return (Number(b.torrent_info.size) || 0) - (Number(a.torrent_info.size) || 0)
|
||||
} else if (sortField.value === 'seeder') {
|
||||
// 按做种数排序(降序)
|
||||
return (Number(b.torrent_info.seeders) || 0) - (Number(a.torrent_info.seeders) || 0)
|
||||
} else if (sortField.value === 'publishTime') {
|
||||
// 按发布时间排序(降序,最新的在前)
|
||||
return new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime()
|
||||
}
|
||||
} else {
|
||||
if (sortField.value === 'site') {
|
||||
// 按站点名称排序
|
||||
return (b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || '')
|
||||
} else if (sortField.value === 'size') {
|
||||
// 按文件大小排序(降序)
|
||||
return (Number(a.torrent_info.size) || 0) - (Number(b.torrent_info.size) || 0)
|
||||
} else if (sortField.value === 'seeder') {
|
||||
// 按做种数排序(降序)
|
||||
return (Number(a.torrent_info.seeders) || 0) - (Number(b.torrent_info.seeders) || 0)
|
||||
} else if (sortField.value === 'publishTime') {
|
||||
// 按发布时间排序(升序,最旧的在前)
|
||||
return new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime()
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// 显示前20个
|
||||
displayDataList.value = filteredData.slice(0, 20)
|
||||
// 保存剩余数据
|
||||
dataList = filteredData.slice(20)
|
||||
}
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 开关筛选菜单
|
||||
function toggleFilterMenu(key: string) {
|
||||
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||
filterMenuOpen.value = false
|
||||
} else {
|
||||
currentFilter.value = key
|
||||
filterMenuOpen.value = true
|
||||
|
||||
// 如果是季集选项,确保已排序
|
||||
if (key === 'season' && filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开关全部筛选菜单
|
||||
function toggleAllFilterMenu() {
|
||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
// 不再需要特殊处理季集选项
|
||||
filterForm[key] = [...filterOptions[key]]
|
||||
}
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in filterForm) {
|
||||
count += filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key].length > 0) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
const index = filterForm[key].indexOf(value)
|
||||
if (index !== -1) {
|
||||
filterForm[key].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore({ done }: { done: any }) {
|
||||
// 从 dataList 中获取最前面的 20 个元素
|
||||
const itemsToMove = dataList.splice(0, 20)
|
||||
displayDataList.value.push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 处理图标点击
|
||||
const handleSortIconClick = () => {
|
||||
// 切换排序方向
|
||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-header d-none d-sm-flex mb-3">
|
||||
<!-- 页面头部和筛选栏 -->
|
||||
<VCard class="view-header rounded-xl">
|
||||
<div class="d-flex align-center flex-wrap pa-3">
|
||||
<VChip color="primary" variant="elevated" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
|
||||
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
<!-- 排序选择 -->
|
||||
<div class="sort-container me-4">
|
||||
<VSelect
|
||||
v-model="sortField"
|
||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="sort-select"
|
||||
variant="plain"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<!-- 添加排序点击事件 -->
|
||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
||||
</VIcon>
|
||||
</template>
|
||||
</VSelect>
|
||||
</div>
|
||||
|
||||
<!-- 筛选按钮组 -->
|
||||
<div class="filter-bar">
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
>
|
||||
{{ title }}
|
||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ filterForm[key].length }}
|
||||
</VChip>
|
||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
||||
<VCard max-width="25rem">
|
||||
<VCardText class="filter-menu-content">
|
||||
<div class="flex justify-between">
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="filter-btn ms-2"
|
||||
prepend-icon="mdi-filter-variant"
|
||||
rounded="pill"
|
||||
@click="toggleAllFilterMenu"
|
||||
>
|
||||
{{ t('torrent.allFilters') }}
|
||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ getFilterCount }}
|
||||
</VChip>
|
||||
</VBtn>
|
||||
|
||||
<!-- 清除全部筛选按钮 -->
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
class="filter-btn"
|
||||
prepend-icon="mdi-close-circle-outline"
|
||||
rounded="pill"
|
||||
>
|
||||
{{ t('torrent.clearFilters') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选择的过滤项显示 -->
|
||||
<div v-if="getFilterCount > 0" class="selected-filters pa-3 pt-0">
|
||||
<div class="d-flex flex-wrap align-center">
|
||||
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||
<VChip
|
||||
v-for="(value, index) in values"
|
||||
:key="`${key}-${index}`"
|
||||
color="primary"
|
||||
size="small"
|
||||
closable
|
||||
variant="elevated"
|
||||
class="me-1 mt-2 filter-tag"
|
||||
@click:close="removeFilter(key, value)"
|
||||
>
|
||||
<VIcon size="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
||||
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
||||
</VChip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- 移动端头部和筛选区域 -->
|
||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
||||
<!-- 移动端头部 -->
|
||||
<div class="view-header">
|
||||
<div class="d-flex align-center flex-wrap pa-2">
|
||||
<div class="d-flex align-center w-100 mb-2">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="search-count me-auto"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<!-- 排序选择 -->
|
||||
<VSelect
|
||||
v-model="sortField"
|
||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mobile-sort-select"
|
||||
variant="plain"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<!-- 添加排序点击事件 -->
|
||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
||||
</VIcon>
|
||||
</template>
|
||||
</VSelect>
|
||||
</div>
|
||||
|
||||
<!-- 筛选图标按钮区域 -->
|
||||
<div class="filter-buttons-grid w-100 mt-2">
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="getFilterCount > 0"
|
||||
:content="getFilterCount"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
>
|
||||
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ title }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="filterForm[key].length > 0"
|
||||
:content="filterForm[key].length"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 全部筛选弹窗 -->
|
||||
<VDialog
|
||||
v-model="allFilterMenuOpen"
|
||||
max-width="50rem"
|
||||
location="center"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||
<span>{{ t('torrent.allFilters') }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
class="me-10"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
{{ t('torrent.clearAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="all-filters-grid">
|
||||
<VCard
|
||||
v-for="(title, key) in filterTitles"
|
||||
variant="tonal"
|
||||
:key="key"
|
||||
class="filter-section"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ title }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<VDialog v-model="filterMenuOpen" max-width="25rem" location="center" max-height="85vh" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||
<span>{{ currentFilterTitle }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="filterForm[currentFilter].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(currentFilter)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in currentFilterOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="displayDataList" class="overflow-visible" @load="loadMore">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-torrent-card items-start">
|
||||
<TorrentCard
|
||||
v-for="item in displayDataList"
|
||||
:key="`${item.torrent_info.page_url}`"
|
||||
:torrent="item"
|
||||
:more="item.more"
|
||||
/>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="displayDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-header {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
border-inline-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 12px 12px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
}
|
||||
|
||||
.filter-menu-content {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid-torrent-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.filter-btn {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.sort-container {
|
||||
border-inline-end: none;
|
||||
inline-size: 100%;
|
||||
margin-block-end: 8px;
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
inline-size: 100%;
|
||||
margin-block-start: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-sort-select {
|
||||
max-inline-size: 130px;
|
||||
min-inline-size: 80px;
|
||||
}
|
||||
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
font-size: 18px;
|
||||
margin-block-end: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.search-header-mobile {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgba(var(--v-theme-background), 0.95);
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.all-filters-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,910 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入参数
|
||||
const props = defineProps({
|
||||
items: Array as PropType<Context[]>,
|
||||
})
|
||||
|
||||
// 过滤表单
|
||||
const filterForm: Record<string, string[]> = reactive({
|
||||
// 站点
|
||||
site: [] as string[],
|
||||
// 季
|
||||
season: [] as string[],
|
||||
// 制作组
|
||||
releaseGroup: [] as string[],
|
||||
// 视频编码
|
||||
videoCode: [] as string[],
|
||||
// 促销状态
|
||||
freeState: [] as string[],
|
||||
// 质量
|
||||
edition: [] as string[],
|
||||
// 分辨率
|
||||
resolution: [] as string[],
|
||||
})
|
||||
|
||||
// 过滤项映射(保持中文标题)
|
||||
const filterTitles: Record<string, string> = {
|
||||
site: t('torrent.filterSite'),
|
||||
season: t('torrent.filterSeason'),
|
||||
freeState: t('torrent.filterFreeState'),
|
||||
videoCode: t('torrent.filterVideoCode'),
|
||||
edition: t('torrent.filterEdition'),
|
||||
resolution: t('torrent.filterResolution'),
|
||||
releaseGroup: t('torrent.filterReleaseGroup'),
|
||||
}
|
||||
|
||||
// 排序中文名
|
||||
const sortTitles: Record<string, string> = {
|
||||
default: t('torrent.sortDefault'),
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 统一存储过滤选项
|
||||
const filterOptions: Record<string, string[]> = reactive({
|
||||
site: [] as string[],
|
||||
season: [] as string[],
|
||||
freeState: [] as string[],
|
||||
edition: [] as string[],
|
||||
resolution: [] as string[],
|
||||
videoCode: [] as string[],
|
||||
releaseGroup: [] as string[],
|
||||
})
|
||||
|
||||
// 排序字段
|
||||
const sortField = ref('default')
|
||||
// 降序
|
||||
const sortType = ref<'asc' | 'desc'>('desc')
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<Array<Context>>([])
|
||||
|
||||
// 显示用的数据列表
|
||||
const displayDataList = ref<Array<Context>>([])
|
||||
|
||||
// 计算已选择的过滤条件数量
|
||||
const getFilterCount = computed(() => {
|
||||
let count = 0
|
||||
for (const key in filterForm) {
|
||||
count += filterForm[key].length
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// 计算已选择的过滤条件
|
||||
const getSelectedFilters = computed(() => {
|
||||
const filters: Record<string, string[]> = {}
|
||||
for (const key in filterForm) {
|
||||
if (filterForm[key].length > 0) {
|
||||
filters[key] = [...filterForm[key]]
|
||||
}
|
||||
}
|
||||
return filters
|
||||
})
|
||||
|
||||
// 移除单个过滤条件
|
||||
function removeFilter(key: string, value: string) {
|
||||
const index = filterForm[key].indexOf(value)
|
||||
if (index !== -1) {
|
||||
filterForm[key].splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有过滤条件
|
||||
function clearAllFilters() {
|
||||
for (const key in filterForm) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化过滤选项
|
||||
function initOptions(data: Context) {
|
||||
const { torrent_info, meta_info } = data
|
||||
const optionValue = (options: Array<string>, value: string | undefined) => {
|
||||
if (value && !options.includes(value)) {
|
||||
options.push(value)
|
||||
// 如果是season选项,立即触发重新计算
|
||||
if (options === filterOptions.season) {
|
||||
// 季集选项排序
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
optionValue(filterOptions.site, torrent_info?.site_name)
|
||||
optionValue(filterOptions.season, meta_info?.season_episode)
|
||||
optionValue(filterOptions.releaseGroup, meta_info?.resource_team)
|
||||
optionValue(filterOptions.videoCode, meta_info?.video_encode)
|
||||
optionValue(filterOptions.freeState, torrent_info?.volume_factor)
|
||||
optionValue(filterOptions.edition, meta_info?.edition)
|
||||
optionValue(filterOptions.resolution, meta_info?.resource_pix)
|
||||
}
|
||||
|
||||
// 直接在组件中添加季集排序函数,而不是用计算属性
|
||||
function sortSeasonOptions() {
|
||||
if (filterOptions.season.length <= 1) {
|
||||
return // 不需要排序
|
||||
}
|
||||
|
||||
// 预解析所有选项
|
||||
const parsedOptions = filterOptions.season.map((option, index) => {
|
||||
// 修改正则表达式以适配 "S01 E07" 格式(注意季号和集号之间的空格)
|
||||
const match = option.match(/^S(\d+)(?:-S(\d+))?\s*(?:E(\d+)(?:-E(\d+))?)?$/)
|
||||
|
||||
if (!match) {
|
||||
// 格式不符合规范的放到最后
|
||||
return {
|
||||
original: option,
|
||||
seasonNum: 0,
|
||||
episodeNum: 0,
|
||||
maxEpisodeNum: 0,
|
||||
isWholeSeason: false,
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
const seasonNum = parseInt(match[1], 10)
|
||||
const episodeNum = match[3] ? parseInt(match[3], 10) : 0
|
||||
const maxEpisodeNum = match[4] ? parseInt(match[4], 10) : episodeNum
|
||||
const isWholeSeason = !match[3] // 没有E部分表示整季
|
||||
|
||||
return {
|
||||
original: option,
|
||||
seasonNum,
|
||||
episodeNum,
|
||||
maxEpisodeNum,
|
||||
isWholeSeason,
|
||||
index,
|
||||
}
|
||||
})
|
||||
|
||||
// 先对所有项进行分类
|
||||
const wholeSeasons = parsedOptions.filter(item => item.isWholeSeason)
|
||||
const episodes = parsedOptions.filter(item => !item.isWholeSeason)
|
||||
|
||||
// 对整季按季号降序排序
|
||||
wholeSeasons.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum // 季号降序
|
||||
}
|
||||
return a.index - b.index // 相同季号按原始索引
|
||||
})
|
||||
|
||||
// 对单集先按季号降序排序,季号相同时按集号降序排序
|
||||
episodes.sort((a, b) => {
|
||||
if (a.seasonNum !== b.seasonNum) {
|
||||
return b.seasonNum - a.seasonNum // 季号降序
|
||||
}
|
||||
// 使用最大集号进行排序 (对于范围如 E01-E06)
|
||||
const aMaxEp = a.maxEpisodeNum || a.episodeNum
|
||||
const bMaxEp = b.maxEpisodeNum || b.episodeNum
|
||||
if (aMaxEp !== bMaxEp) {
|
||||
return bMaxEp - aMaxEp // 集号降序
|
||||
}
|
||||
// 如果最大集号相同,再比较起始集号
|
||||
if (a.episodeNum !== b.episodeNum) {
|
||||
return b.episodeNum - a.episodeNum
|
||||
}
|
||||
return a.index - b.index // 都相同时按原始索引
|
||||
})
|
||||
|
||||
// 合并结果:整季在前,单集在后
|
||||
const sortedOptions = [...wholeSeasons, ...episodes].map(item => item.original)
|
||||
|
||||
// 直接更新 filterOptions.season
|
||||
filterOptions.season = sortedOptions
|
||||
}
|
||||
|
||||
// 修改watch监听,同时监听排序字段的变化
|
||||
watch([filterForm, sortField, sortType], filterData)
|
||||
|
||||
// 计算过滤后的列表
|
||||
function filterData() {
|
||||
// 清空列表
|
||||
dataList.value = []
|
||||
displayDataList.value = []
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: string | undefined) =>
|
||||
filter.length === 0 || (value && filter.includes(value))
|
||||
|
||||
// 先收集所有过滤选项,再过滤数据
|
||||
if (props.items?.length) {
|
||||
// 首先收集所有过滤选项
|
||||
props.items.forEach(data => {
|
||||
initOptions(data)
|
||||
})
|
||||
|
||||
// 筛选数据
|
||||
let filteredData: Context[] = []
|
||||
|
||||
// 然后根据过滤条件筛选数据
|
||||
props.items.forEach(data => {
|
||||
const { meta_info, torrent_info } = data
|
||||
if (
|
||||
// 站点过滤
|
||||
match(filterForm.site, torrent_info.site_name) &&
|
||||
// 促销状态过滤
|
||||
match(filterForm.freeState, torrent_info.volume_factor) &&
|
||||
// 季过滤
|
||||
match(filterForm.season, meta_info.season_episode) &&
|
||||
// 制作组过滤
|
||||
match(filterForm.releaseGroup, meta_info.resource_team) &&
|
||||
// 视频编码过滤
|
||||
match(filterForm.videoCode, meta_info.video_encode) &&
|
||||
// 分辨率过滤
|
||||
match(filterForm.resolution, meta_info.resource_pix) &&
|
||||
// 质量过滤
|
||||
match(filterForm.edition, meta_info.edition)
|
||||
) {
|
||||
filteredData.push(data)
|
||||
}
|
||||
})
|
||||
|
||||
// 排序
|
||||
if (sortType.value === 'desc') {
|
||||
if (sortField.value === 'default') {
|
||||
filteredData = filteredData.sort((a, b) => b.torrent_info.pri_order - a.torrent_info.pri_order)
|
||||
} else if (sortField.value === 'site') {
|
||||
filteredData = filteredData.sort((a, b) =>
|
||||
(a.torrent_info.site_name || '').localeCompare(b.torrent_info.site_name || ''),
|
||||
)
|
||||
} else if (sortField.value === 'size') {
|
||||
filteredData = filteredData.sort((a, b) => b.torrent_info.size - a.torrent_info.size)
|
||||
} else if (sortField.value === 'seeder') {
|
||||
filteredData = filteredData.sort((a, b) => b.torrent_info.seeders - a.torrent_info.seeders)
|
||||
} else if (sortField.value === 'publishTime') {
|
||||
// 按发布时间排序(降序,最新的在前)
|
||||
filteredData = filteredData.sort(
|
||||
(a, b) => new Date(b.torrent_info.pubdate || 0).getTime() - new Date(a.torrent_info.pubdate || 0).getTime(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (sortField.value === 'default') {
|
||||
filteredData = filteredData.sort((a, b) => a.torrent_info.pri_order - b.torrent_info.pri_order)
|
||||
} else if (sortField.value === 'site') {
|
||||
filteredData = filteredData.sort((a, b) =>
|
||||
(b.torrent_info.site_name || '').localeCompare(a.torrent_info.site_name || ''),
|
||||
)
|
||||
} else if (sortField.value === 'size') {
|
||||
filteredData = filteredData.sort((a, b) => a.torrent_info.size - b.torrent_info.size)
|
||||
} else if (sortField.value === 'seeder') {
|
||||
filteredData = filteredData.sort((a, b) => a.torrent_info.seeders - b.torrent_info.seeders)
|
||||
} else if (sortField.value === 'publishTime') {
|
||||
// 按发布时间排序(升序,最旧的在前)
|
||||
filteredData = filteredData.sort(
|
||||
(a, b) => new Date(a.torrent_info.pubdate || 0).getTime() - new Date(b.torrent_info.pubdate || 0).getTime(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示前20个
|
||||
displayDataList.value = filteredData.slice(0, 20)
|
||||
// 保存剩余数据
|
||||
dataList.value = filteredData.slice(20)
|
||||
}
|
||||
|
||||
// 确保在数据筛选完成后重新排序季集选项
|
||||
if (filterOptions.season.length > 0) {
|
||||
// 直接排序,不再使用延时
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤菜单相关
|
||||
const filterMenuOpen = ref(false)
|
||||
const currentFilter = ref('site')
|
||||
const currentFilterTitle = computed(() => filterTitles[currentFilter.value])
|
||||
const currentFilterOptions = computed(() => {
|
||||
return filterOptions[currentFilter.value]
|
||||
})
|
||||
|
||||
// 添加全部筛选菜单相关
|
||||
const allFilterMenuOpen = ref(false)
|
||||
|
||||
// 开关全部筛选菜单
|
||||
function toggleAllFilterMenu() {
|
||||
allFilterMenuOpen.value = !allFilterMenuOpen.value
|
||||
}
|
||||
|
||||
// 给定过滤类型返回不同图标
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 全选某个过滤项
|
||||
function selectAll(key: string) {
|
||||
if (key === 'season') {
|
||||
filterForm[key] = [...filterOptions[key]]
|
||||
} else {
|
||||
filterForm[key] = [...filterOptions[key]]
|
||||
}
|
||||
}
|
||||
|
||||
// 清除某个过滤项
|
||||
function clearFilter(key: string) {
|
||||
filterForm[key] = []
|
||||
}
|
||||
|
||||
// 添加toggleFilterMenu函数
|
||||
function toggleFilterMenu(key: string) {
|
||||
if (currentFilter.value === key && filterMenuOpen.value) {
|
||||
filterMenuOpen.value = false
|
||||
} else {
|
||||
currentFilter.value = key
|
||||
filterMenuOpen.value = true
|
||||
|
||||
// 如果是季集选项,确保已排序
|
||||
if (key === 'season' && filterOptions.season.length > 0) {
|
||||
sortSeasonOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore({ done }: { done: any }) {
|
||||
// 从 dataList 中获取最前面的 20 个元素
|
||||
const itemsToMove = dataList.value.splice(0, 20)
|
||||
displayDataList.value.push(...itemsToMove)
|
||||
done('ok')
|
||||
}
|
||||
|
||||
// 处理图标点击
|
||||
const handleSortIconClick = () => {
|
||||
// 切换排序方向
|
||||
sortType.value = sortType.value === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
filterData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="torrent-view">
|
||||
<!-- 搜索头部容器 - 新增,用于固定在顶部 -->
|
||||
<div class="search-header d-none d-sm-block">
|
||||
<!-- PC端页面头部和筛选栏 -->
|
||||
<VCard class="view-header mb-3">
|
||||
<div class="d-flex align-center flex-wrap pa-3">
|
||||
<VChip color="primary" variant="flat" size="small" class="search-count me-3" prepend-icon="mdi-magnify">
|
||||
{{ dataList.length }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
<div class="filter-bar">
|
||||
<!-- 排序选择 -->
|
||||
<VSelect
|
||||
v-model="sortField"
|
||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="sort-select"
|
||||
variant="plain"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<!-- 添加排序点击事件 -->
|
||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
||||
</VIcon>
|
||||
</template>
|
||||
</VSelect>
|
||||
<div class="filter-divider"></div>
|
||||
|
||||
<!-- 筛选按钮 -->
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
>
|
||||
{{ title }}
|
||||
<VChip v-if="filterForm[key].length > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ filterForm[key].length }}
|
||||
</VChip>
|
||||
<VMenu activator="parent" :close-on-content-click="false" scrim>
|
||||
<VCard max-width="20rem">
|
||||
<VCardText class="filter-menu-content">
|
||||
<div class="flex justify-between">
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="filter-btn me-2"
|
||||
prepend-icon="mdi-filter-variant"
|
||||
rounded="pill"
|
||||
@click="toggleAllFilterMenu"
|
||||
>
|
||||
{{ t('torrent.allFilters') }}
|
||||
<VChip v-if="getFilterCount > 0" size="small" color="primary" class="ms-1" variant="elevated">
|
||||
{{ getFilterCount }}
|
||||
</VChip>
|
||||
</VBtn>
|
||||
|
||||
<!-- 清除全部筛选按钮 -->
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
class="filter-btn"
|
||||
prepend-icon="mdi-close-circle-outline"
|
||||
>
|
||||
{{ t('torrent.clearFilters') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选择的过滤项显示 -->
|
||||
<div v-if="getFilterCount > 0" class="selected-filters">
|
||||
<div class="d-flex flex-wrap align-center">
|
||||
<template v-for="(values, key) in getSelectedFilters" :key="key">
|
||||
<VChip
|
||||
v-for="(value, index) in values"
|
||||
:key="`${key}-${index}`"
|
||||
color="primary"
|
||||
size="small"
|
||||
closable
|
||||
variant="elevated"
|
||||
class="me-1 mb-1 mt-1 filter-tag"
|
||||
@click:close="removeFilter(key, value)"
|
||||
>
|
||||
<VIcon size="small" :icon="getFilterIcon(key)" class="me-1"></VIcon>
|
||||
<strong>{{ filterTitles[key] }}:</strong> {{ value }}
|
||||
</VChip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<!-- 移动端头部和筛选区域 -->
|
||||
<VCard class="d-block d-sm-none search-header-mobile mb-3">
|
||||
<!-- 移动端头部 -->
|
||||
<div class="view-header">
|
||||
<div class="d-flex align-center flex-wrap pa-2">
|
||||
<div class="d-flex align-center w-100">
|
||||
<VChip
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="search-count me-auto"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
{{ props.items?.length || 0 }} {{ t('torrent.resources') }}
|
||||
</VChip>
|
||||
|
||||
<!-- 排序选择 -->
|
||||
<VSelect
|
||||
v-model="sortField"
|
||||
:items="Object.entries(sortTitles).map(([key, title]) => ({ title, value: key }))"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mobile-sort-select"
|
||||
variant="plain"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<!-- 添加排序点击事件 -->
|
||||
<VIcon @mousedown.stop.prevent="handleSortIconClick">
|
||||
{{ sortType === 'asc' ? 'mdi-sort-ascending' : 'mdi-sort-descending' }}
|
||||
</VIcon>
|
||||
</template>
|
||||
</VSelect>
|
||||
</div>
|
||||
|
||||
<!-- 筛选图标按钮区域 -->
|
||||
<div class="filter-buttons-grid w-100 mt-2">
|
||||
<!-- 全部筛选按钮 -->
|
||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="getFilterCount > 0"
|
||||
:content="getFilterCount"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
>
|
||||
<VIcon :icon="getFilterIcon(key)" class="filter-icon me-1"></VIcon>
|
||||
<span class="filter-label">
|
||||
{{ title }}
|
||||
</span>
|
||||
<VBadge
|
||||
v-if="filterForm[key].length > 0"
|
||||
:content="filterForm[key].length"
|
||||
color="primary"
|
||||
location="top end"
|
||||
offset-x="-10"
|
||||
offset-y="-10"
|
||||
></VBadge>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 全部筛选弹窗 -->
|
||||
<VDialog
|
||||
v-model="allFilterMenuOpen"
|
||||
max-width="50rem"
|
||||
location="center"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="allFilterMenuOpen = false" />
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||
<span>{{ t('torrent.allFilters') }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="getFilterCount > 0"
|
||||
class="me-10"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearAllFilters"
|
||||
>
|
||||
{{ t('torrent.clearAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="all-filters-grid">
|
||||
<VCard
|
||||
v-for="(title, key) in filterTitles"
|
||||
variant="tonal"
|
||||
:key="key"
|
||||
class="filter-section"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getFilterIcon(key)" class="me-2"></VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ title }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(key)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(key)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VChipGroup v-model="filterForm[key]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 筛选弹窗 -->
|
||||
<VDialog v-model="filterMenuOpen" max-width="25rem" max-height="85vh" location="center" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon :icon="getFilterIcon(currentFilter)" class="me-2"></VIcon>
|
||||
<span>{{ currentFilterTitle }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="filterForm[currentFilter].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="clearFilter(currentFilter)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" size="small" color="primary" @click="selectAll(currentFilter)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VChipGroup v-model="filterForm[currentFilter]" column multiple class="filter-options">
|
||||
<VChip
|
||||
v-for="option in currentFilterOptions"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="filterMenuOpen = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
<!-- 资源列表容器 -->
|
||||
<VCard class="resource-list-container">
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="displayDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-else
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="displayDataList"
|
||||
class="resource-list overflow-visible"
|
||||
@load="loadMore"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-for="(item, index) in displayDataList" :key="`${item.torrent_info?.enclosure || ''}-${index}`">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < displayDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.torrent-view {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.search-header-mobile {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 24px;
|
||||
inline-size: 1px;
|
||||
margin-block: 0;
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-menu-content {
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
margin: 4px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.filter-chip.v-chip--selected {
|
||||
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||
color: rgb(var(--v-theme-on-primary)) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.selected-filters {
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.resource-list-container {
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 300px;
|
||||
}
|
||||
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface), 0.5);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
font-size: 18px;
|
||||
margin-block-end: 2px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-sort-select {
|
||||
max-inline-size: 130px;
|
||||
min-inline-size: 80px;
|
||||
}
|
||||
|
||||
.all-filters-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { VForm } from 'vuetify/lib/components/index.mjs'
|
||||
import api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import type { User, PassKey } from '@/api/types'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import OTPAuthDialog from '@/components/dialog/OTPAuthDialog.vue'
|
||||
import PasskeyDialog from '@/components/dialog/PasskeyDialog.vue'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -34,15 +35,6 @@ const isSaving = ref(false)
|
||||
// 开启双重验证窗口
|
||||
const otpDialog = ref(false)
|
||||
|
||||
// otp uri
|
||||
const otpUri = ref('')
|
||||
|
||||
// otp secret
|
||||
const secret = ref('')
|
||||
|
||||
// 确认双重验证密码
|
||||
const otpPassword = ref('')
|
||||
|
||||
// 当前头像缓存
|
||||
const currentAvatar = ref(avatar1)
|
||||
|
||||
@@ -64,8 +56,34 @@ const accountInfo = ref<User>({
|
||||
nickname: '',
|
||||
})
|
||||
|
||||
// 二维码信息
|
||||
const qrCode = ref('')
|
||||
// PassKey列表
|
||||
const passkeyList = ref<PassKey[]>([])
|
||||
|
||||
// PassKey对话框
|
||||
const passkeyDialog = ref(false)
|
||||
|
||||
// 双重验证菜单
|
||||
const mfaMenu = ref(false)
|
||||
|
||||
// 密码验证对话框
|
||||
const verifyPasswordDialog = ref(false)
|
||||
|
||||
// 验证密码
|
||||
const verifyPassword = ref('')
|
||||
|
||||
// 验证后的回调
|
||||
const verifyCallback = ref<((password: string) => 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) {
|
||||
@@ -114,15 +132,18 @@ async function fetchUserInfo() {
|
||||
if (result) {
|
||||
accountInfo.value = result
|
||||
accountInfo.value.avatar = accountInfo.value.avatar ? accountInfo.value.avatar : avatar1
|
||||
accountInfo.value.nickname = accountInfo.value.settings?.nickname ?? ''
|
||||
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'))
|
||||
@@ -141,12 +162,10 @@ async function saveAccountInfo() {
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (accountInfo.value.nickname) {
|
||||
if (!accountInfo.value.settings) {
|
||||
accountInfo.value.settings = {}
|
||||
}
|
||||
accountInfo.value.settings.nickname = accountInfo.value.nickname
|
||||
if (!accountInfo.value.settings) {
|
||||
accountInfo.value.settings = {}
|
||||
}
|
||||
accountInfo.value.settings.nickname = accountInfo.value.nickname ?? ''
|
||||
|
||||
const oldUserName = accountInfo.value.name
|
||||
const oldAvatar = accountInfo.value.avatar
|
||||
@@ -195,56 +214,45 @@ async function saveAccountInfo() {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
// 为当前用户获取Otp Uri
|
||||
async function getOtpUri() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/generate')
|
||||
if (result.success) {
|
||||
otpUri.value = result.data.uri
|
||||
secret.value = result.data.secret
|
||||
qrCode.value = result.data.uri
|
||||
otpDialog.value = true
|
||||
} else {
|
||||
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
// 验证密码载荷接口
|
||||
interface VerifyPasswordPayload {
|
||||
title: string
|
||||
text: string
|
||||
callback: (password: string) => void
|
||||
}
|
||||
|
||||
// 关闭当前用户的双重验证
|
||||
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)
|
||||
}
|
||||
// 密码验证并执行回调
|
||||
function withPasswordVerification(title: string, text: string, callback: (password: string) => void) {
|
||||
verifyTitle.value = title
|
||||
verifyText.value = text
|
||||
verifyCallback.value = callback
|
||||
verifyPassword.value = ''
|
||||
verifyPasswordDialog.value = true
|
||||
}
|
||||
|
||||
// 启用Otp
|
||||
async function judgeOtpPassword() {
|
||||
if (!otpPassword.value) {
|
||||
$toast.error(t('profile.otpCodeRequired'))
|
||||
// 弹窗请求密码验证
|
||||
function onVerifyPassword({ title, text, callback }: VerifyPasswordPayload) {
|
||||
withPasswordVerification(title, text, callback)
|
||||
}
|
||||
|
||||
// 确认密码验证
|
||||
async function confirmVerifyPassword() {
|
||||
if (!verifyPassword.value) {
|
||||
$toast.error(t('user.passwordHint'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('user/otp/judge', {
|
||||
uri: otpUri.value,
|
||||
otpPassword: otpPassword.value,
|
||||
})
|
||||
if (verifyCallback.value) {
|
||||
verifyCallback.value(verifyPassword.value)
|
||||
}
|
||||
verifyPasswordDialog.value = false
|
||||
}
|
||||
|
||||
// 获取PassKey列表
|
||||
async function fetchPassKeyList() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mfa/passkey/list')
|
||||
if (result.success) {
|
||||
$toast.success(t('profile.otpEnableSuccess'))
|
||||
otpDialog.value = false
|
||||
accountInfo.value.is_otp = true
|
||||
} else {
|
||||
$toast.error(t('profile.otpEnableFailed', { message: result.message }))
|
||||
passkeyList.value = result.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
@@ -301,16 +309,52 @@ 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="
|
||||
() => {
|
||||
otpDialog = true
|
||||
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="
|
||||
() => {
|
||||
passkeyDialog = true
|
||||
mfaMenu = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="material-symbols:passkey" />
|
||||
</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>
|
||||
@@ -410,6 +454,15 @@ watch(
|
||||
prepend-inner-icon="mdi-slack"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.discord_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('profile.discordUser')"
|
||||
prepend-inner-icon="mdi-discord"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.vocechat_userid"
|
||||
@@ -454,38 +507,43 @@ watch(
|
||||
</VRow>
|
||||
|
||||
<!-- 双重验证弹窗 -->
|
||||
<VDialog v-if="otpDialog" v-model="otpDialog" max-width="45rem" scrollable>
|
||||
<!-- 开启双重验证弹窗内容 -->
|
||||
<OTPAuthDialog
|
||||
v-model="otpDialog"
|
||||
v-model:is-otp="accountInfo.is_otp"
|
||||
:passkey-list="passkeyList"
|
||||
@verify-password="onVerifyPassword"
|
||||
/>
|
||||
|
||||
<!-- PassKey管理对话框 -->
|
||||
<PasskeyDialog
|
||||
v-model="passkeyDialog"
|
||||
:is-otp="accountInfo.is_otp"
|
||||
v-model:passkey-list="passkeyList"
|
||||
@verify-password="onVerifyPassword"
|
||||
/>
|
||||
|
||||
<!-- 密码验证对话框 -->
|
||||
<VDialog v-model="verifyPasswordDialog" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="otpDialog = false" />
|
||||
<VCardTitle class="text-h5 text-center mt-4">{{ verifyTitle }}</VCardTitle>
|
||||
<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>
|
||||
<p class="mb-4">{{ verifyText }}</p>
|
||||
<VForm @submit.prevent="confirmVerifyPassword">
|
||||
<VTextField
|
||||
v-model="otpPassword"
|
||||
type="text"
|
||||
:label="t('profile.enterVerificationCode')"
|
||||
autocomplete=""
|
||||
class="mb-8"
|
||||
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-shield-key"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
autocomplete="current-password"
|
||||
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
|
||||
/>
|
||||
<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">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
<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>
|
||||
|
||||
114
vite.config.ts
114
vite.config.ts
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
205
yarn.lock
205
yarn.lock
@@ -1145,6 +1145,27 @@
|
||||
resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz"
|
||||
integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==
|
||||
|
||||
"@iconify-json/line-md@^1.2.13":
|
||||
version "1.2.13"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/line-md/-/line-md-1.2.13.tgz#19714b8471ebac5871e20036512eaffa869a04b7"
|
||||
integrity sha512-XFXThXsEQ2Wzzn+ze2T1d+JHkkFvI1AxiVKnOox4qFbdR9EVikckZlUK+/DUsV4zSy6pMQAgXpIk+1xG8qFYPQ==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/lucide@^1.2.85":
|
||||
version "1.2.85"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/lucide/-/lucide-1.2.85.tgz#0074b64f50798da4b89f9f74e4db5a4e56c640b1"
|
||||
integrity sha512-VXUWT6KRDiVK4Ty/7Ypu+U0KnSbHzDAOOiSgLLPhU8u3ES5IusP1X7ahZb1iwiVKGWRG6gkKywaRUIZLgYWXyA==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/material-symbols@^1.2.51":
|
||||
version "1.2.51"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/material-symbols/-/material-symbols-1.2.51.tgz#270862a21bb65a8632de4943146096b5a58863ae"
|
||||
integrity sha512-GkxlK8ocHi3NVVozaW62jm3qR9fNY3xX2penFtIRvoe1OtNhJ2KD4KRzv8x34pugMOAZYK8sALMcU30gDgCi1A==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/mdi@^1.1.52":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@iconify-json/mdi/-/mdi-1.2.3.tgz"
|
||||
@@ -1976,6 +1997,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
|
||||
integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
|
||||
|
||||
"@types/linkify-it@^5":
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
||||
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
|
||||
|
||||
"@types/lodash-es@^4.17.12":
|
||||
version "4.17.12"
|
||||
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
|
||||
@@ -1988,6 +2014,26 @@
|
||||
resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz"
|
||||
integrity sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==
|
||||
|
||||
"@types/markdown-it-link-attributes@^3.0.5":
|
||||
version "3.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/markdown-it-link-attributes/-/markdown-it-link-attributes-3.0.5.tgz#521179990cd2ced55761d9b8c93e502b679df329"
|
||||
integrity sha512-VZ2BGN3ywUg7mBD8W6PwR8ChpOxaQSBDbLqPgvNI+uIra3zY2af1eG/3XzWTKjEraTWskMKnZqZd6m1fDF67Bg==
|
||||
dependencies:
|
||||
"@types/markdown-it" "*"
|
||||
|
||||
"@types/markdown-it@*", "@types/markdown-it@^14.1.2":
|
||||
version "14.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
|
||||
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
|
||||
dependencies:
|
||||
"@types/linkify-it" "^5"
|
||||
"@types/mdurl" "^2"
|
||||
|
||||
"@types/mdurl@^2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
|
||||
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
||||
|
||||
"@types/mousetrap@^1.6.15":
|
||||
version "1.6.15"
|
||||
resolved "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.15.tgz"
|
||||
@@ -2020,6 +2066,13 @@
|
||||
resolved "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz"
|
||||
integrity sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==
|
||||
|
||||
"@types/qrcode@^1.5.6":
|
||||
version "1.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42"
|
||||
integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/resolve@1.20.2":
|
||||
version "1.20.2"
|
||||
resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz"
|
||||
@@ -3016,10 +3069,15 @@ camelcase-css@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
camelcase@^5.0.0:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
|
||||
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
|
||||
|
||||
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"
|
||||
@@ -3104,6 +3162,15 @@ clean-regexp@^1.0.0:
|
||||
dependencies:
|
||||
escape-string-regexp "^1.0.5"
|
||||
|
||||
cliui@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
|
||||
integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
|
||||
dependencies:
|
||||
string-width "^4.2.0"
|
||||
strip-ansi "^6.0.0"
|
||||
wrap-ansi "^6.2.0"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
|
||||
@@ -3460,6 +3527,11 @@ debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
decamelize@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
||||
|
||||
deep-is@^0.1.3:
|
||||
version "0.1.4"
|
||||
resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"
|
||||
@@ -3543,6 +3615,11 @@ didyoumean@^1.2.2:
|
||||
resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz"
|
||||
integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==
|
||||
|
||||
dijkstrajs@^1.0.1:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
|
||||
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
|
||||
|
||||
dir-glob@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
|
||||
@@ -4452,6 +4529,11 @@ gensync@^1.0.0-beta.2:
|
||||
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
|
||||
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
|
||||
|
||||
get-caller-file@^2.0.1:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
|
||||
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
|
||||
|
||||
get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz"
|
||||
@@ -5313,6 +5395,13 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
linkify-it@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
|
||||
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
|
||||
dependencies:
|
||||
uc.micro "^2.0.0"
|
||||
|
||||
local-pkg@^0.5.1:
|
||||
version "0.5.1"
|
||||
resolved "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz"
|
||||
@@ -5414,6 +5503,23 @@ magic-string@^0.30.11, magic-string@^0.30.17:
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.5.0"
|
||||
|
||||
markdown-it-link-attributes@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it-link-attributes/-/markdown-it-link-attributes-4.0.1.tgz#25751f2cf74fd91f0a35ba7b3247fa45f2056d88"
|
||||
integrity sha512-pg5OK0jPLg62H4k7M9mRJLT61gUp9nvG0XveKYHMOOluASo9OEF13WlXrpAp2aj35LbedAy3QOCgQCw0tkLKAQ==
|
||||
|
||||
markdown-it@^14.1.0:
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
|
||||
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
entities "^4.4.0"
|
||||
linkify-it "^5.0.0"
|
||||
mdurl "^2.0.0"
|
||||
punycode.js "^2.3.1"
|
||||
uc.micro "^2.1.0"
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz"
|
||||
@@ -5444,6 +5550,11 @@ mdn-data@^2.15.0:
|
||||
resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.21.0.tgz"
|
||||
integrity sha512-+ZKPQezM5vYJIkCxaC+4DTnRrVZR1CgsKLu5zsQERQx6Tea8Y+wMx5A24rq8A8NepCeatIQufVAekKNgiBMsGQ==
|
||||
|
||||
mdurl@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
||||
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
|
||||
|
||||
media-typer@0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
|
||||
@@ -6045,6 +6156,11 @@ pluralize@^8.0.0:
|
||||
resolved "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz"
|
||||
integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
|
||||
|
||||
pngjs@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
|
||||
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
|
||||
|
||||
possible-typed-array-names@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz"
|
||||
@@ -6197,15 +6313,24 @@ pump@^3.0.0:
|
||||
end-of-stream "^1.1.0"
|
||||
once "^1.3.1"
|
||||
|
||||
punycode.js@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
||||
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
||||
|
||||
punycode@^2.1.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qrcode.vue@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.npmjs.org/qrcode.vue/-/qrcode.vue-3.6.0.tgz"
|
||||
integrity sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==
|
||||
qrcode@^1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88"
|
||||
integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==
|
||||
dependencies:
|
||||
dijkstrajs "^1.0.1"
|
||||
pngjs "^5.0.0"
|
||||
yargs "^15.3.1"
|
||||
|
||||
qs@6.13.0:
|
||||
version "6.13.0"
|
||||
@@ -6411,11 +6536,21 @@ regjsparser@^0.12.0:
|
||||
dependencies:
|
||||
jsesc "~3.0.2"
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
|
||||
|
||||
require-from-string@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"
|
||||
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
|
||||
|
||||
require-main-filename@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
|
||||
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
|
||||
|
||||
requires-port@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
|
||||
@@ -6617,6 +6752,11 @@ serve-static@1.16.2:
|
||||
parseurl "~1.3.3"
|
||||
send "0.19.0"
|
||||
|
||||
set-blocking@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
|
||||
integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==
|
||||
|
||||
set-function-length@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz"
|
||||
@@ -6855,7 +6995,7 @@ std-env@^3.9.0:
|
||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -7406,6 +7546,11 @@ typescript@^5, typescript@^5.0.4:
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz"
|
||||
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
||||
|
||||
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
||||
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
|
||||
|
||||
ufo@^1.5.4, ufo@^1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz"
|
||||
@@ -7915,6 +8060,11 @@ which-collection@^1.0.2:
|
||||
is-weakmap "^2.0.2"
|
||||
is-weakset "^2.0.3"
|
||||
|
||||
which-module@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409"
|
||||
integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==
|
||||
|
||||
which-typed-array@^1.1.16, which-typed-array@^1.1.18:
|
||||
version "1.1.19"
|
||||
resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz"
|
||||
@@ -8114,6 +8264,15 @@ workbox-window@7.3.0, workbox-window@^7.3.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^6.2.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
|
||||
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"
|
||||
@@ -8141,6 +8300,11 @@ xml-name-validator@^4.0.0:
|
||||
resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz"
|
||||
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
|
||||
|
||||
y18n@^4.0.0:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
|
||||
integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
|
||||
|
||||
yallist@^3.0.2:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"
|
||||
@@ -8164,6 +8328,31 @@ yaml@^2.0.0, yaml@^2.3.4, yaml@^2.7.0:
|
||||
resolved "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz"
|
||||
integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==
|
||||
|
||||
yargs-parser@^18.1.2:
|
||||
version "18.1.3"
|
||||
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
|
||||
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
|
||||
dependencies:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
|
||||
yargs@^15.3.1:
|
||||
version "15.4.1"
|
||||
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
|
||||
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
|
||||
dependencies:
|
||||
cliui "^6.0.0"
|
||||
decamelize "^1.2.0"
|
||||
find-up "^4.1.0"
|
||||
get-caller-file "^2.0.1"
|
||||
require-directory "^2.1.1"
|
||||
require-main-filename "^2.0.0"
|
||||
set-blocking "^2.0.0"
|
||||
string-width "^4.2.0"
|
||||
which-module "^2.0.0"
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^18.1.2"
|
||||
|
||||
yauzl@^2.10.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user