mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 06:32:45 +08:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d22cb84bf | ||
|
|
f01c61e09f | ||
|
|
d50e67f3bc | ||
|
|
3726c472fc | ||
|
|
dc174e81cf | ||
|
|
c9867bc453 | ||
|
|
8e282fb216 | ||
|
|
e9c0792cb3 | ||
|
|
e7e1b4c43f | ||
|
|
dc56c177b7 | ||
|
|
c0ee998874 | ||
|
|
e1ff50e1e3 | ||
|
|
0e440955c8 | ||
|
|
a16dd497c4 | ||
|
|
5aa4e9339d | ||
|
|
723fa96519 | ||
|
|
75252fded6 | ||
|
|
51fbcdfa56 | ||
|
|
61c9b97d70 | ||
|
|
23b09d09ce | ||
|
|
a00f6ab8ff | ||
|
|
bb59095bad | ||
|
|
da57124d5e | ||
|
|
a00800a128 | ||
|
|
a98db1699d | ||
|
|
e3d9e736ad | ||
|
|
28f38d8b80 | ||
|
|
3b7c34258f | ||
|
|
9dde646695 | ||
|
|
4bdee63f28 | ||
|
|
20dced021d | ||
|
|
17cf640e23 | ||
|
|
24369daea0 | ||
|
|
873bf905ab | ||
|
|
da0756adf0 | ||
|
|
09942ec946 | ||
|
|
2650bc6068 | ||
|
|
6bd7274c9c | ||
|
|
129ccf9e39 | ||
|
|
e2b789cfbc | ||
|
|
bb70e91277 | ||
|
|
f6c07a29ce | ||
|
|
4347983fc7 | ||
|
|
12b463d9e8 | ||
|
|
edc0949bed | ||
|
|
85780917c2 | ||
|
|
e45919cac1 | ||
|
|
c61821ef4e | ||
|
|
011902598b | ||
|
|
3186c6ca0e | ||
|
|
3a680a132f | ||
|
|
455dda54e8 | ||
|
|
5ea5ab07d9 | ||
|
|
35c8025b00 | ||
|
|
615c162663 | ||
|
|
c4bd15e5a0 | ||
|
|
edc92905f7 | ||
|
|
bf5bbd3689 | ||
|
|
eb70ca233b | ||
|
|
8718816fce | ||
|
|
7d36330b4b | ||
|
|
1fa0474fef | ||
|
|
4070b27148 | ||
|
|
3892b0ed05 | ||
|
|
a06cf69d7a | ||
|
|
61dc2568e8 | ||
|
|
ac6362e698 | ||
|
|
94afdf5495 | ||
|
|
d96f8acdbc | ||
|
|
d39c795f92 | ||
|
|
8e12e0562b | ||
|
|
7a1babb418 | ||
|
|
8d65f0c2a8 | ||
|
|
b8dff560f0 | ||
|
|
b48c26ee73 | ||
|
|
8328e51ae0 | ||
|
|
7070eb8a7d | ||
|
|
d0aa26441c | ||
|
|
1bba7103c8 | ||
|
|
7f8dd744f2 | ||
|
|
2f4a707498 | ||
|
|
569bc3c8ec | ||
|
|
b01421aa94 | ||
|
|
30d933bd85 | ||
|
|
377998335b | ||
|
|
21d21aa438 | ||
|
|
18cf1ea3d7 | ||
|
|
60ea884fe2 | ||
|
|
999fa9d9a6 | ||
|
|
e80034e7f8 | ||
|
|
b16f99941a | ||
|
|
3503e7d5b1 | ||
|
|
d1d80acef8 | ||
|
|
16fe916b07 | ||
|
|
d754c3dae3 | ||
|
|
1b32a3e8cd | ||
|
|
15a6f215b4 | ||
|
|
38014ba342 | ||
|
|
7dcc293a09 | ||
|
|
35ce244490 | ||
|
|
3bade2060a | ||
|
|
f8307f25c9 |
468
index.html
468
index.html
@@ -1,273 +1,239 @@
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
style="
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: calc(100% + env(safe-area-inset-top) + env(safe-area-inset-bottom));
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
"
|
||||
>
|
||||
<head>
|
||||
<meta http-equiv="pragma" content="no-cache" />
|
||||
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="expires" content="0" />
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width, user-scalable=no" />
|
||||
<title>MoviePilot</title>
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="origin" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<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-startup-image" href="/splash/apple-splash.jpg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
<meta name="description" content="MoviePilot" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="referrer" content="never" />
|
||||
<meta name="msapplication-TileColor" content="#7D34FD" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
<script>
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
</script>
|
||||
</head>
|
||||
">
|
||||
|
||||
<body style="margin: 0">
|
||||
<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"
|
||||
>
|
||||
<style>
|
||||
/* 添加SVG内部的动画样式 */
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
<head>
|
||||
<title>MoviePilot</title>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||
|
||||
@keyframes glow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 3px rgba(141, 81, 249, 0.3));
|
||||
}
|
||||
<!-- 基础信息 -->
|
||||
<meta name="description" content="MoviePilot - 智能影视媒体库管理工具" />
|
||||
<meta name="author" content="MoviePilot" />
|
||||
<meta name="keywords" content="MoviePilot,影视,媒体库,管理" />
|
||||
|
||||
50% {
|
||||
filter: drop-shadow(0 0 6px rgba(141, 81, 249, 0.6));
|
||||
}
|
||||
}
|
||||
<!-- 安全和隐私 -->
|
||||
<meta name="Robots" content="noindex,nofollow,noarchive" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
/* 为各个元素添加动画 */
|
||||
#a2-c {
|
||||
filter: drop-shadow(0 0 5px rgba(141, 81, 249, 0.3));
|
||||
animation: glow 3s ease-in-out infinite;
|
||||
}
|
||||
<!-- PWA - 基础图标 -->
|
||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/logo.png" sizes="any" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
|
||||
path {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
<!-- 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-startup-image" href="/splash/apple-splash.png" />
|
||||
|
||||
/* 错开不同元素的动画开始时间 */
|
||||
g:nth-child(2) path {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
<!-- iOS Safari 全屏模式 -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="MoviePilot" />
|
||||
|
||||
g:nth-child(3) path {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
<!-- iOS Safari 防止自动识别 -->
|
||||
<meta name="apple-mobile-web-app-orientations" content="portrait" />
|
||||
|
||||
g:nth-child(4) path {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
<!-- Android Chrome PWA 优化 -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-title" content="MoviePilot" />
|
||||
|
||||
g:nth-child(5) path {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
</style>
|
||||
<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)">
|
||||
<!-- Microsoft Windows PWA -->
|
||||
<meta name="msapplication-TileColor" content="#0E1116" />
|
||||
<meta name="msapplication-TileImage" content="/android-chrome-192x192.png" />
|
||||
<meta name="msapplication-config" content="none" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
<meta name="msapplication-navbutton-color" content="#0E1116" />
|
||||
|
||||
<!-- 主题色彩 - 适配深色和浅色模式 -->
|
||||
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F4F5FA" media="(prefers-color-scheme: light)" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
|
||||
<!-- 屏幕方向锁定 -->
|
||||
<meta name="screen-orientation" content="portrait" />
|
||||
<meta name="x5-orientation" content="portrait" />
|
||||
<meta name="x5-fullscreen" content="true" />
|
||||
<meta name="x5-page-mode" content="app" />
|
||||
|
||||
<!-- UC浏览器优化 -->
|
||||
<meta name="browsermode" content="application" />
|
||||
<meta name="wap-font-scale" content="no" />
|
||||
|
||||
<!-- 360浏览器优化 -->
|
||||
<meta name="renderer" content="webkit" />
|
||||
|
||||
<!-- 触摸优化 -->
|
||||
<meta name="HandheldFriendly" content="True" />
|
||||
<meta name="MobileOptimized" content="320" />
|
||||
|
||||
<!-- 缓存控制 -->
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
|
||||
<!-- DNS预解析 -->
|
||||
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
|
||||
|
||||
<!-- 预加载关键资源 -->
|
||||
<link rel="preload" href="/loader.css" as="style" />
|
||||
|
||||
<!-- 加载样式 -->
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
|
||||
<!-- 初始化脚本 -->
|
||||
<script>
|
||||
// 主题色彩初始化
|
||||
const loaderColor = localStorage.getItem('materio-initial-loader-bg') || '#FFFFFF'
|
||||
if (loaderColor) document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
const primaryColor = localStorage.getItem('materio-initial-loader-color') || '#9155FD'
|
||||
if (primaryColor) document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
document.documentElement.style.setProperty('--status-bar-height', '20px')
|
||||
}
|
||||
|
||||
// 安全区域适配
|
||||
function updateSafeArea() {
|
||||
const safeAreaTop = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-top)')
|
||||
const safeAreaBottom = getComputedStyle(document.documentElement).getPropertyValue('env(safe-area-inset-bottom)')
|
||||
|
||||
if (safeAreaTop) document.documentElement.style.setProperty('--safe-area-top', safeAreaTop)
|
||||
if (safeAreaBottom) document.documentElement.style.setProperty('--safe-area-bottom', safeAreaBottom)
|
||||
}
|
||||
|
||||
updateSafeArea()
|
||||
window.addEventListener('resize', updateSafeArea)
|
||||
window.addEventListener('orientationchange', updateSafeArea)
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch;">
|
||||
<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="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">
|
||||
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="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>
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.5.8",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -45,6 +45,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^4.21.2",
|
||||
"express-http-proxy": "^2.1.1",
|
||||
"http-proxy-middleware": "^3.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -112,4 +113,4 @@
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.18"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
@@ -94,4 +94,4 @@
|
||||
opacity: 1;
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require('node:path')
|
||||
const express = require('express')
|
||||
const proxy = require('express-http-proxy')
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware')
|
||||
|
||||
const app = express()
|
||||
const port = process.env.NGINX_PORT || 3000
|
||||
@@ -14,16 +15,141 @@ const proxyConfig = {
|
||||
// 静态文件服务目录
|
||||
app.use(express.static(__dirname))
|
||||
|
||||
// 配置代理中间件将请求转发给后端API
|
||||
app.use(
|
||||
'/api',
|
||||
proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
|
||||
// 路径加上 /api 前缀
|
||||
proxyReqPathResolver: (req) => {
|
||||
return `/api${req.url}`
|
||||
// 创建专门的SSE代理中间件
|
||||
const sseProxyMiddleware = createProxyMiddleware({
|
||||
target: `http://${proxyConfig.URL}:${proxyConfig.PORT}`,
|
||||
changeOrigin: true,
|
||||
ws: false,
|
||||
timeout: 0, // 无超时
|
||||
proxyTimeout: 0, // 无超时
|
||||
headers: {
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
// 检测SSE响应
|
||||
const isSSE = proxyRes.headers['content-type'] &&
|
||||
proxyRes.headers['content-type'].includes('text/event-stream');
|
||||
|
||||
if (isSSE) {
|
||||
// 设置SSE响应头
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Cache-Control, Content-Type, Authorization'
|
||||
});
|
||||
|
||||
// 直接将代理响应流式传输到客户端
|
||||
proxyRes.pipe(res);
|
||||
|
||||
// 处理客户端断开连接
|
||||
req.on('close', () => {
|
||||
console.log('Client disconnected from SSE stream');
|
||||
if (proxyRes.destroy) {
|
||||
proxyRes.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 处理代理响应结束
|
||||
proxyRes.on('end', () => {
|
||||
console.log('SSE stream ended');
|
||||
if (!res.headersSent) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
// 处理代理响应错误
|
||||
proxyRes.on('error', (err) => {
|
||||
console.error('SSE proxy response error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
onError: (err, req, res) => {
|
||||
console.error('SSE proxy error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Proxy error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 创建普通API代理中间件
|
||||
const apiProxyMiddleware = proxy(`${proxyConfig.URL}:${proxyConfig.PORT}`, {
|
||||
// 路径加上 /api 前缀
|
||||
proxyReqPathResolver: (req) => {
|
||||
return `/api${req.url}`
|
||||
},
|
||||
proxyReqOptDecorator: (proxyReqOpts, srcReq) => {
|
||||
proxyReqOpts.headers = proxyReqOpts.headers || {};
|
||||
|
||||
// 检测是否为SSE请求
|
||||
const isSSE = srcReq.headers.accept && srcReq.headers.accept.includes('text/event-stream');
|
||||
|
||||
if (!isSSE) {
|
||||
// 普通请求设置超时
|
||||
proxyReqOpts.timeout = 600000; // 600秒超时
|
||||
}
|
||||
|
||||
return proxyReqOpts;
|
||||
},
|
||||
userResDecorator: (proxyRes, proxyResData, userReq, userRes) => {
|
||||
// 只处理非SSE响应
|
||||
const isSSEResponse = proxyRes.headers['content-type'] &&
|
||||
proxyRes.headers['content-type'].includes('text/event-stream');
|
||||
|
||||
if (!isSSEResponse) {
|
||||
// 普通响应:正常处理
|
||||
return proxyResData;
|
||||
}
|
||||
|
||||
// SSE响应不在这里处理,已经由专门的中间件处理
|
||||
return proxyResData;
|
||||
},
|
||||
// 错误处理
|
||||
proxyErrorHandler: (err, res, next) => {
|
||||
// 客户端断开连接的正常情况
|
||||
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
|
||||
console.log('Client disconnected:', err.code);
|
||||
if (!res.headersSent) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 超时错误处理
|
||||
if (err.code === 'ETIMEDOUT') {
|
||||
console.log('Proxy request timed out:', err.code);
|
||||
if (!res.headersSent) {
|
||||
res.status(504).send('Gateway Timeout');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
console.error('Proxy error:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 配置API代理路由
|
||||
app.use('/api', (req, res, next) => {
|
||||
// 检测是否为SSE请求
|
||||
const isSSE = req.headers.accept && req.headers.accept.includes('text/event-stream');
|
||||
|
||||
if (isSSE) {
|
||||
// 使用专门的SSE代理中间件
|
||||
sseProxyMiddleware(req, res, next);
|
||||
} else {
|
||||
// 使用普通API代理中间件
|
||||
apiProxyMiddleware(req, res, next);
|
||||
}
|
||||
});
|
||||
|
||||
// 配置代理中间件将CookieCloud请求转发给后端API
|
||||
app.use(
|
||||
|
||||
@@ -5,7 +5,7 @@ defineProps({
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="title" class="my-3 md:flex md:items-center md:justify-between">
|
||||
<div v-if="title" class="my-3 mx-3 md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1 mx-0">
|
||||
<h2
|
||||
class="ms-1 truncate text-2xl font-bold leading-7 text-gray-100 sm:overflow-visible sm:text-3xl sm:leading-9 md:mb-0"
|
||||
|
||||
@@ -16,14 +16,14 @@ $header: ".layout-navbar";
|
||||
@if variables.$vertical-nav-navbar-style == "elevated" {
|
||||
// Add transition
|
||||
#{$header} {
|
||||
transition: padding 0.2s ease, background-color 0.18s ease;
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
|
||||
// If navbar is contained => Add border radius to header
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
#{$header} {
|
||||
border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
|
||||
}
|
||||
// #{$header} {
|
||||
// border-radius: 0 0 variables.$default-layout-with-vertical-nav-navbar-footer-roundness variables.$default-layout-with-vertical-nav-navbar-footer-roundness;
|
||||
// }
|
||||
}
|
||||
|
||||
// Scrolled styles for sticky navbar
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
%blurry-bg {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
|
||||
// 磨砂渐变效果
|
||||
backdrop-filter: blur(20px);
|
||||
block-size: calc(env(safe-area-inset-top, 0px) + 5rem);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
|
||||
// 使用遮罩实现渐变效果
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 100%) 0%,
|
||||
rgba(0, 0, 0, 90%) calc(env(safe-area-inset-top, 0px) + 1rem),
|
||||
rgba(0, 0, 0, 70%) calc(env(safe-area-inset-top, 0px) + 2rem),
|
||||
rgba(0, 0, 0, 50%) calc(env(safe-area-inset-top, 0px) + 3rem),
|
||||
rgba(0, 0, 0, 20%) calc(env(safe-area-inset-top, 0px) + 4rem),
|
||||
rgba(0, 0, 0, 0%) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
transition: all 0.5s ease-in-out;
|
||||
|
||||
.v-theme--light & {
|
||||
background: rgba(var(--v-theme-surface), 0.8);
|
||||
}
|
||||
|
||||
.v-theme--dark & {
|
||||
background: rgba(var(--v-theme-background), 0.6);
|
||||
}
|
||||
|
||||
.v-theme--purple & {
|
||||
background: rgba(var(--v-theme-background), 0.6);
|
||||
}
|
||||
background: rgba(var(--v-theme-background), 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
|
||||
|
||||
@media (width > 768px) {
|
||||
.v-theme--transparent & {
|
||||
background: rgba(var(--v-theme-background), 0.3);
|
||||
backdrop-filter: blur(5px);
|
||||
background: rgba(var(--v-theme-background), 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (width <= 768px) {
|
||||
background: transparent;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
backdrop-filter: blur(24px);
|
||||
block-size: calc(env(safe-area-inset-top, 0px) + var(--navbar-tab-height) + 4rem);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
.v-theme--light & {
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
}
|
||||
|
||||
.v-theme--dark & {
|
||||
background: rgba(var(--v-theme-background), 0.5);
|
||||
}
|
||||
|
||||
.v-theme--purple & {
|
||||
background: rgba(var(--v-theme-background), 0.5);
|
||||
}
|
||||
|
||||
.v-theme--transparent & {
|
||||
background: rgba(var(--v-theme-background), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import ColorThief from 'colorthief'
|
||||
|
||||
// 将 RGB 转换为十六进制
|
||||
function rgbStringToHex(rgbArray: number[]): string {
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
|
||||
throw new Error('Invalid RGB string format')
|
||||
if (rgbArray.length !== 3 || rgbArray.some(isNaN)) throw new Error('Invalid RGB string format')
|
||||
|
||||
const [r, g, b] = rgbArray
|
||||
|
||||
@@ -21,3 +20,27 @@ export async function getDominantColor(image: HTMLImageElement): Promise<string>
|
||||
const dominantColor = colorThief.getColor(image)
|
||||
return rgbStringToHex(dominantColor)
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,15 +38,25 @@ export default defineComponent({
|
||||
)
|
||||
|
||||
// 👉 Navbar
|
||||
const navbar = h('header', { class: ['layout-navbar navbar-blur'] }, [
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
),
|
||||
])
|
||||
const navbar = h(
|
||||
'header',
|
||||
{ class: ['layout-navbar navbar-blur'] },
|
||||
[
|
||||
h(
|
||||
'div',
|
||||
{ class: 'navbar-content-container' },
|
||||
[
|
||||
slots.navbar?.({
|
||||
toggleVerticalOverlayNavActive: toggleIsOverlayNavActive,
|
||||
}),
|
||||
// 👉 Dynamic Header Tab in NavBar
|
||||
slots['dynamic-header-tab']?.()
|
||||
? h('div', { class: 'layout-dynamic-header-tab' }, slots['dynamic-header-tab']?.())
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
),
|
||||
].filter(Boolean),
|
||||
)
|
||||
|
||||
const main = h(
|
||||
'main',
|
||||
@@ -127,7 +137,9 @@ export default defineComponent({
|
||||
inset-block-start: 0;
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height);
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
@@ -135,10 +147,6 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
@if variables.$layout-vertical-nav-navbar-is-contained {
|
||||
@include mixins.boxed-content;
|
||||
} @else {
|
||||
.navbar-content-container {
|
||||
// @include mixins.boxed-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
269
src/App.vue
269
src/App.vue
@@ -3,10 +3,12 @@ import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
@@ -18,13 +20,13 @@ globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
|
||||
// 显示状态
|
||||
const show = ref(false)
|
||||
|
||||
// 检查是否登录
|
||||
const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 生成背景图片key
|
||||
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
|
||||
|
||||
@@ -34,6 +36,8 @@ const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
let backgroundRotationTimer: NodeJS.Timeout | null = null
|
||||
|
||||
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -41,43 +45,60 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
if (window.Apex) {
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
// 配置 ApexCharts 全局选项
|
||||
function configureApexCharts() {
|
||||
if (typeof window !== 'undefined' && window.Apex) {
|
||||
try {
|
||||
// 获取当前主题
|
||||
const currentTheme = globalTheme.name.value
|
||||
const isDark = currentTheme === 'dark' || currentTheme === 'transparent'
|
||||
|
||||
// 数据标签
|
||||
window.Apex.dataLabels = {
|
||||
formatter: function (_: number, { seriesIndex, w }: { seriesIndex: number; w: any }) {
|
||||
// 如果有小数点,保留两位小数,否则保留整数
|
||||
const data = w.config.series[seriesIndex]
|
||||
return data.toFixed(data % 1 === 0 ? 0 : 1)
|
||||
},
|
||||
}
|
||||
// 图例
|
||||
window.Apex.legend = {
|
||||
labels: {
|
||||
useSeriesColors: true,
|
||||
},
|
||||
}
|
||||
// 标题
|
||||
window.Apex.title = {
|
||||
style: {
|
||||
color: 'rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))',
|
||||
},
|
||||
}
|
||||
// 鼠标悬浮提示
|
||||
window.Apex.tooltip = {
|
||||
theme: isDark ? 'dark' : 'light',
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('ApexCharts 全局配置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
// 确保body元素也有相同的主题属性,以便更好地选择弹出窗口
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`)
|
||||
const controller = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +106,8 @@ async function fetchBackgroundImages() {
|
||||
function startBackgroundRotation() {
|
||||
// 清除轮换定时器
|
||||
if (backgroundRotationTimer) clearInterval(backgroundRotationTimer)
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 每10秒切换一次
|
||||
backgroundRotationTimer = setInterval(() => {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
@@ -97,121 +118,108 @@ function startBackgroundRotation() {
|
||||
activeImageIndex.value = nextIndex
|
||||
}
|
||||
})
|
||||
}, 10000) // 每10秒切换一次
|
||||
}, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加logo动画效果并延迟移除加载界面
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
// 先添加完成动画类
|
||||
loadingBg.classList.add('loading-complete')
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
}
|
||||
}
|
||||
|
||||
// 等待动画完成后再移除元素
|
||||
setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 500) // 与CSS动画持续时间匹配
|
||||
// 检查PWA状态并移除加载界面
|
||||
async function removeLoadingWithStateCheck() {
|
||||
try {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
globalLoadingStateManager.setLoadingState('global-settings', true)
|
||||
globalLoadingStateManager.setLoadingState('background-images', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
const pwaController = (window as any).pwaStateController
|
||||
if (pwaController) {
|
||||
await pwaController.waitForStateRestore()
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// 并行加载关键资源
|
||||
await Promise.all([
|
||||
globalSettingsStore.initialize().then(() => {
|
||||
globalLoadingStateManager.setLoadingState('global-settings', false)
|
||||
}),
|
||||
new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
globalLoadingStateManager.setLoadingState('background-images', false)
|
||||
resolve(void 0)
|
||||
}, 50)
|
||||
})
|
||||
])
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
|
||||
// 移除加载界面
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
} catch (error) {
|
||||
// 即使出错也要移除加载界面
|
||||
globalLoadingStateManager.reset()
|
||||
animateAndRemoveLoader()
|
||||
}
|
||||
}
|
||||
|
||||
// 加载背景图片
|
||||
async function loadBackgroundImages() {
|
||||
await fetchBackgroundImages()
|
||||
.then(() => {
|
||||
startBackgroundRotation()
|
||||
})
|
||||
.catch(() => {
|
||||
// 3秒后重试
|
||||
async function loadBackgroundImages(retryCount = 0) {
|
||||
const maxRetries = 3
|
||||
try {
|
||||
await fetchBackgroundImages()
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
setTimeout(() => {
|
||||
loadBackgroundImages()
|
||||
}, 3000)
|
||||
})
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
}, retryDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 默认隐藏页面
|
||||
show.value = false
|
||||
// 监听主题变化
|
||||
watch(
|
||||
() => globalTheme.name.value,
|
||||
newTheme => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
},
|
||||
)
|
||||
|
||||
// 加载背景图片
|
||||
await loadBackgroundImages()
|
||||
loadBackgroundImages()
|
||||
|
||||
// 移除加载动画
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
// 移除加载动画,显示页面
|
||||
animateAndRemoveLoader()
|
||||
|
||||
// 页面完全显示后,检查未读消息
|
||||
setTimeout(() => {
|
||||
checkAndEmitUnreadMessages()
|
||||
}, 1000)
|
||||
}, 1500)
|
||||
})
|
||||
})
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadBackgroundImages()
|
||||
// 页面恢复可见时检查未读消息
|
||||
setTimeout(() => {
|
||||
checkAndEmitUnreadMessages()
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加PWA的页面恢复事件监听
|
||||
window.addEventListener('pageshow', event => {
|
||||
// persisted属性为true表示页面是从bfcache中恢复的
|
||||
if (event.persisted) {
|
||||
loadBackgroundImages()
|
||||
// PWA恢复时检查未读消息
|
||||
setTimeout(() => {
|
||||
checkAndEmitUnreadMessages()
|
||||
}, 500)
|
||||
}
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除页面可见性监听
|
||||
document.removeEventListener('visibilitychange', () => {})
|
||||
// 移除PWA的页面恢复事件监听
|
||||
window.removeEventListener('pageshow', () => {})
|
||||
|
||||
// 清除轮换定时器
|
||||
if (backgroundRotationTimer) {
|
||||
clearInterval(backgroundRotationTimer)
|
||||
@@ -230,12 +238,12 @@ onUnmounted(() => {
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
></div>
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp v-show="show" :class="{ 'transparent-app': isTransparentTheme }">
|
||||
<VApp :class="{ 'transparent-app': isTransparentTheme }">
|
||||
<RouterView />
|
||||
</VApp>
|
||||
</div>
|
||||
@@ -297,4 +305,29 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 优化加载完成动画 */
|
||||
.loading-complete {
|
||||
animation: fadeOutScale 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeOutScale {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0px);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.05);
|
||||
filter: blur(2px);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
filter: blur(5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,11 @@ export const storageAttributes = [
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'smb',
|
||||
icon: 'mdi-folder-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const storageIconDict = storageAttributes.reduce((dict, item) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
@@ -17,6 +19,9 @@ declare global {
|
||||
// 将 API 实例暴露到全局,供插件使用
|
||||
window.MoviePilotAPI = api
|
||||
|
||||
// 初始化请求优化器(必须在其他拦截器之前)
|
||||
initializeRequestOptimizer(api)
|
||||
|
||||
// 添加请求拦截器
|
||||
api.interceptors.request.use(config => {
|
||||
// 认证 Store
|
||||
@@ -28,15 +33,45 @@ api.interceptors.request.use(config => {
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
if (!error.response) {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error(error))
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
globalOfflineStatus.setAppOffline(true, reason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
// 请求被中止(路由切换等)
|
||||
return Promise.reject(new Error('Request cancelled'))
|
||||
}
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -769,6 +769,8 @@ export interface MetaInfo {
|
||||
audio_term: string
|
||||
// 资源类型+特效
|
||||
edition: string
|
||||
// 流媒体平台
|
||||
web_source: string
|
||||
// 应用的自定义识别词
|
||||
apply_words: string[]
|
||||
}
|
||||
|
||||
10
src/assets/images/misc/openlist.svg
Normal file
10
src/assets/images/misc/openlist.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/misc/smb.png
Normal file
BIN
src/assets/images/misc/smb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
1
src/assets/images/pages/404.svg
Normal file
1
src/assets/images/pages/404.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 41 KiB |
@@ -5,6 +5,7 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -33,7 +34,8 @@ const emit = defineEmits(['pathchanged'])
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -241,14 +243,14 @@ function stopDrag() {
|
||||
|
||||
// 外层DIV大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 7rem)'
|
||||
: 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
// 文件列表大小限制
|
||||
const fileListStyle = computed(() => {
|
||||
return appMode
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 14rem - env(safe-area-inset-bottom) - 7rem)'
|
||||
: 'height: calc(100vh - 14rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import page404 from '@images/pages/404.svg'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -19,16 +20,7 @@ interface Props {
|
||||
<div class="no-data-container">
|
||||
<!-- 图标容器 -->
|
||||
<div class="icon-wrapper">
|
||||
<div class="icon-glow"></div>
|
||||
<div class="icon-container">
|
||||
<VIcon
|
||||
:icon="props.icon || 'mdi-file-search-outline'"
|
||||
:color="props.iconColor || 'white'"
|
||||
size="48"
|
||||
class="main-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="pulse-ring"></div>
|
||||
<img :src="page404" alt="404" />
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
@@ -57,8 +49,7 @@ interface Props {
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
min-block-size: 300px;
|
||||
padding-block: 3rem;
|
||||
padding-inline: 1rem;
|
||||
padding-block-start: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -68,109 +59,17 @@ interface Props {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
margin-block: 0 2rem;
|
||||
inline-size: 15rem;
|
||||
margin-block: 0 1rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
animation: pulse 3s infinite ease-in-out;
|
||||
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.8) 0%, rgba(var(--v-theme-primary), 0) 70%);
|
||||
block-size: 80px;
|
||||
filter: blur(15px);
|
||||
inline-size: 80px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.9), rgba(var(--v-theme-secondary), 0.8));
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.main-icon {
|
||||
animation: slight-bounce 3s infinite ease-in-out;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 30%));
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.5);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite ease-out;
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.pulse-ring::before {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 50%;
|
||||
animation: ripple 2s infinite 0.5s ease-out;
|
||||
block-size: 85px;
|
||||
content: '';
|
||||
inline-size: 85px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slight-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.error-title {
|
||||
position: relative;
|
||||
color: rgba(var(--v-theme-on-surface), 0.95);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin-block-end: 0.75rem;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 5%);
|
||||
}
|
||||
@@ -181,69 +80,15 @@ interface Props {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-primary), 0.8), rgba(var(--v-theme-primary), 0.2));
|
||||
block-size: 3px;
|
||||
content: '';
|
||||
inline-size: 40px;
|
||||
margin-block: 0.5rem 0;
|
||||
inline-size: 60px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
color: rgba(var(--v-theme-on-surface), 0.75);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 1.5rem;
|
||||
font-size: 1rem;
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 80%;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-block-start: 1.5rem;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn) {
|
||||
transform: translateY(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.actions-container :deep(.v-btn:hover) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (width <= 600px) {
|
||||
.no-data-container {
|
||||
padding-block: 2rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block-end: 1.5rem;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.icon-glow {
|
||||
block-size: 70px;
|
||||
inline-size: 70px;
|
||||
}
|
||||
|
||||
.pulse-ring,
|
||||
.pulse-ring::before {
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 0.95rem;
|
||||
max-inline-size: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -187,7 +187,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import router, { registerAbortController } from '@/router'
|
||||
import { useUserStore } from '@/stores'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
@@ -28,7 +28,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
@@ -232,9 +234,6 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
@@ -243,7 +242,6 @@ async function handleCheckExists() {
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
@@ -255,16 +253,13 @@ async function handleCheckExists() {
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
|
||||
@@ -87,6 +87,9 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
{{ context?.media_info?.tmdb_id }}
|
||||
</VChip>
|
||||
<!-- meta_info -->
|
||||
<VChip v-if="context?.meta_info?.web_source" variant="elevated" class="me-1 mb-1 text-white bg-purple-500">
|
||||
{{ context?.meta_info?.web_source }}
|
||||
</VChip>
|
||||
<VChip v-if="context?.meta_info?.edition" variant="elevated" class="me-1 mb-1 text-white bg-red-500">
|
||||
{{ context?.meta_info?.edition }}
|
||||
</VChip>
|
||||
|
||||
@@ -200,7 +200,7 @@ onMounted(() => {
|
||||
<span class="me-2 mb-1">自定义媒体服务器</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-3" max-width="3rem" min-width="3rem" />
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import personIcon from '@images/misc/person-icon.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
const personProps = defineProps({
|
||||
person: Object as PropType<Person>,
|
||||
@@ -10,7 +11,9 @@ const personProps = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前人物
|
||||
const personInfo = ref(personProps.person)
|
||||
|
||||
@@ -106,7 +106,7 @@ const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
|
||||
@@ -170,7 +170,7 @@ const iconPath: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(props.plugin?.plugin_icon)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
})
|
||||
@@ -180,7 +180,7 @@ const authorPath: Ref<string> = computed(() => {
|
||||
// 网络图片则使用代理后返回
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.author_url + '.png',
|
||||
)}`
|
||||
)}&cache=true`
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
|
||||
@@ -24,10 +24,11 @@ const { t } = useI18n()
|
||||
const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update', 'remove'])
|
||||
const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
@@ -56,9 +57,6 @@ const resourceDialog = ref(false)
|
||||
// 用户数据弹窗
|
||||
const siteUserDataDialog = ref(false)
|
||||
|
||||
// 站点使用统计
|
||||
const siteStats = ref<SiteStatistic>({})
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
try {
|
||||
@@ -84,16 +82,8 @@ async function testSite() {
|
||||
testButtonText.value = t('site.testConnectivity')
|
||||
testButtonDisable.value = false
|
||||
|
||||
getSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询站点使用统计
|
||||
async function getSiteStats() {
|
||||
try {
|
||||
siteStats.value = await api.get(`site/statistic/${cardProps.site?.domain}`)
|
||||
// 测试完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -140,16 +130,17 @@ async function deleteSiteInfo() {
|
||||
|
||||
// 根据站点状态显示不同的状态图标
|
||||
const statColor = computed(() => {
|
||||
if (isNullOrEmptyObject(siteStats.value)) {
|
||||
if (!cardProps.stats || isNullOrEmptyObject(cardProps.stats)) {
|
||||
return 'secondary'
|
||||
}
|
||||
if (siteStats.value?.lst_state == 1) {
|
||||
if (cardProps.stats?.lst_state === 1) {
|
||||
return 'error'
|
||||
} else if (siteStats.value?.lst_state == 0) {
|
||||
if (!siteStats.value?.seconds) return 'secondary'
|
||||
if (siteStats.value?.seconds >= 5) return 'warning'
|
||||
} else if (cardProps.stats?.lst_state === 0) {
|
||||
if (!cardProps.stats?.seconds) return 'secondary'
|
||||
if (cardProps.stats?.seconds >= 5) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
return 'secondary'
|
||||
})
|
||||
|
||||
// 数据百分比计算
|
||||
@@ -185,19 +176,20 @@ function saveSite() {
|
||||
// 更新站点Cookie UA后的回调
|
||||
function onSiteCookieUpdated() {
|
||||
siteCookieDialog.value = false
|
||||
getSiteStats()
|
||||
// Cookie更新后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
|
||||
// 资源浏览弹窗关闭后的回调
|
||||
function onSiteResourceDone() {
|
||||
resourceDialog.value = false
|
||||
getSiteStats()
|
||||
// 资源操作完成后刷新统计数据
|
||||
emit('refresh-stats', cardProps.site?.domain)
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
getSiteStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@ import storage_png from '@images/misc/storage.png'
|
||||
import alipan_png from '@images/misc/alipan.webp'
|
||||
import u115_png from '@images/misc/u115.png'
|
||||
import rclone_png from '@images/misc/rclone.png'
|
||||
import alist_png from '@images/misc/alist.svg'
|
||||
import alist_png from '@images/misc/openlist.svg'
|
||||
import custom_png from '@images/misc/database.png'
|
||||
import smb_png from '@images/misc/smb.png'
|
||||
import api from '@/api'
|
||||
import AliyunAuthDialog from '../dialog/AliyunAuthDialog.vue'
|
||||
import U115AuthDialog from '../dialog/U115AuthDialog.vue'
|
||||
import RcloneConfigDialog from '../dialog/RcloneConfigDialog.vue'
|
||||
import AlistConfigDialog from '../dialog/AlistConfigDialog.vue'
|
||||
import SmbConfigDialog from '../dialog/SmbConfigDialog.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -66,6 +67,8 @@ const u115AuthDialog = ref(false)
|
||||
const rcloneConfigDialog = ref(false)
|
||||
// AList配置对话框
|
||||
const aListConfigDialog = ref(false)
|
||||
// SMB配置对话框
|
||||
const smbConfigDialog = ref(false)
|
||||
// 自定义存储配置对话框
|
||||
const customConfigDialog = ref(false)
|
||||
|
||||
@@ -84,6 +87,9 @@ function openStorageDialog() {
|
||||
case 'alist':
|
||||
aListConfigDialog.value = true
|
||||
break
|
||||
case 'smb':
|
||||
smbConfigDialog.value = true
|
||||
break
|
||||
case 'local':
|
||||
$toast.info(t('storage.noConfigNeeded'))
|
||||
break
|
||||
@@ -106,6 +112,8 @@ const getIcon = computed(() => {
|
||||
return rclone_png
|
||||
case 'alist':
|
||||
return alist_png
|
||||
case 'smb':
|
||||
return smb_png
|
||||
default:
|
||||
return custom_png
|
||||
}
|
||||
@@ -144,6 +152,7 @@ function handleDone() {
|
||||
u115AuthDialog.value = false
|
||||
rcloneConfigDialog.value = false
|
||||
aListConfigDialog.value = false
|
||||
smbConfigDialog.value = false
|
||||
customConfigDialog.value = false
|
||||
// 更新存储
|
||||
storage_ref.value.name = customName.value
|
||||
@@ -163,14 +172,14 @@ function onClose() {
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openStorageDialog">
|
||||
<VDialogCloseBtn v-if="!storageIconDict[storage.type]" @click="onClose" />
|
||||
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
|
||||
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
|
||||
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7" max-width="3rem" min-width="3rem" />
|
||||
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />
|
||||
@@ -204,6 +213,13 @@ function onClose() {
|
||||
@close="aListConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<SmbConfigDialog
|
||||
v-if="smbConfigDialog"
|
||||
v-model="smbConfigDialog"
|
||||
:conf="props.storage.config || {}"
|
||||
@close="smbConfigDialog = false"
|
||||
@done="handleDone"
|
||||
/>
|
||||
<VDialog
|
||||
v-if="customConfigDialog"
|
||||
v-model="customConfigDialog"
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -23,7 +24,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save'])
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import ForkSubscribeDialog from '../dialog/ForkSubscribeDialog.vue'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -14,7 +15,9 @@ const props = defineProps({
|
||||
const emit = defineEmits(['delete'])
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
@@ -196,8 +196,19 @@ onMounted(() => {
|
||||
{{ meta?.subtitle || torrent?.description }}
|
||||
</div>
|
||||
|
||||
<!-- 发布时间 -->
|
||||
<div v-if="torrent?.pubdate" class="d-flex align-center justify-start mb-2">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
<span class="text-sm text-medium-emphasis">{{ formatDateDifference(torrent.pubdate) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 资源标签区 -->
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<!-- 流媒体平台 -->
|
||||
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
|
||||
{{ meta?.web_source }}
|
||||
</VChip>
|
||||
|
||||
<!-- 版本标签 -->
|
||||
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
|
||||
{{ meta?.edition }}
|
||||
@@ -406,6 +417,11 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-web-source {
|
||||
background-color: #8000FF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-edition {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
@@ -154,7 +154,18 @@ onMounted(() => {
|
||||
{{ meta?.subtitle || torrent?.description || '暂无描述' }}
|
||||
</div>
|
||||
|
||||
<!-- 发布时间 -->
|
||||
<div v-if="torrent?.pubdate" class="d-flex align-center mb-2">
|
||||
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||
<span class="text-sm text-medium-emphasis">{{ formatDateDifference(torrent.pubdate) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||
<!-- 流媒体平台 -->
|
||||
<VChip v-if="meta?.web_source" class="chip-web-source rounded-sm" size="x-small" variant="elevated">
|
||||
{{ meta?.web_source }}
|
||||
</VChip>
|
||||
|
||||
<!-- 版本标签 -->
|
||||
<VChip v-if="meta?.edition" class="chip-edition rounded-sm" size="x-small" variant="elevated">
|
||||
{{ meta?.edition }}
|
||||
@@ -254,6 +265,11 @@ onMounted(() => {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-web-source {
|
||||
background-color: #8000ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chip-edition {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { VBtn } from 'vuetify/lib/components/index.mjs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -19,7 +20,9 @@ const props = defineProps({
|
||||
const emit = defineEmits(['fork', 'delete', 'close'])
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
@@ -4,12 +4,17 @@ import type { Plugin } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
},
|
||||
show_switch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -18,7 +23,8 @@ const emit = defineEmits(['close', 'save', 'switch'])
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
@@ -130,6 +136,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
<VFab
|
||||
v-if="show_switch"
|
||||
icon="mdi-cog"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
@@ -146,6 +153,7 @@ onMounted(() => {
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:api="api"
|
||||
:show_switch="show_switch"
|
||||
@action="handleAction"
|
||||
@switch="emit('switch')"
|
||||
@close="emit('close')"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -24,10 +25,12 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 当前识别类型
|
||||
const mediaSource = ref(globalSettings.data?.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
@@ -188,6 +191,11 @@ async function handleTransferLog(logid: number, background: boolean = false) {
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
// 在创建新连接之前,先确保任何可能存在的旧连接都被关闭了,防止因快速重复点击而产生孤儿连接。
|
||||
if (progressEventSource.value) {
|
||||
progressEventSource.value.close()
|
||||
}
|
||||
|
||||
progressText.value = t('dialog.reorganize.processing')
|
||||
progressEventSource.value = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`)
|
||||
progressEventSource.value.onmessage = event => {
|
||||
@@ -197,6 +205,13 @@ function startLoadingProgress() {
|
||||
progressValue.value = progress.value
|
||||
}
|
||||
}
|
||||
|
||||
// 发生错误时,也确保连接被关闭,避免重试等意外行为
|
||||
progressEventSource.value.onerror = () => {
|
||||
if (progressEventSource.value) {
|
||||
progressEventSource.value.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
|
||||
@@ -253,6 +253,8 @@ async function fetchSiteUserData() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
|
||||
if (result.success) {
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
|
||||
(a.updated_day || '').localeCompare(b.updated_day || ''),
|
||||
)
|
||||
@@ -276,8 +278,11 @@ async function refreshSiteData() {
|
||||
progressDialog.value = false
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await fetchSiteUserData()
|
||||
onBeforeMount(() => {
|
||||
// 延迟加载,确保组件完全挂载
|
||||
nextTick(() => {
|
||||
fetchSiteUserData()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
131
src/components/dialog/SmbConfigDialog.vue
Normal file
131
src/components/dialog/SmbConfigDialog.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
await saveSmbConfig()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/smb')
|
||||
if (result.success) {
|
||||
// 重置成功
|
||||
handleDone()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 SMB 设置
|
||||
async function saveSmbConfig() {
|
||||
try {
|
||||
await api.post(`storage/save/smb`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-network-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('dialog.smbConfig.title') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="props.conf.host"
|
||||
:hint="t('dialog.smbConfig.hostHint')"
|
||||
:label="t('dialog.smbConfig.host')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server"
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="props.conf.share"
|
||||
:hint="t('dialog.smbConfig.shareHint')"
|
||||
:label="t('dialog.smbConfig.share')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-folder-network"
|
||||
placeholder="shared_folder"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="props.conf.username"
|
||||
:hint="t('dialog.smbConfig.usernameHint')"
|
||||
:label="t('dialog.smbConfig.username')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="props.conf.password"
|
||||
:hint="t('dialog.smbConfig.passwordHint')"
|
||||
:label="t('dialog.smbConfig.password')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
placeholder="your_password"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="props.conf.domain"
|
||||
:hint="t('dialog.smbConfig.domainHint')"
|
||||
:label="t('dialog.smbConfig.domain')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
placeholder="WORKGROUP"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
{{ t('dialog.smbConfig.reset') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
{{ t('dialog.smbConfig.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -283,6 +283,7 @@ onMounted(() => {
|
||||
<VDialog scrollable max-width="45rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
|
||||
</template>
|
||||
@@ -300,7 +301,6 @@ onMounted(() => {
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VTabs v-model="activeTab" show-arrows>
|
||||
<VTab value="basic">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -17,7 +18,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 季详情
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
@@ -207,7 +207,6 @@ const isMacOS = computed(() => {
|
||||
</VBtn>
|
||||
</VToolbarItems>
|
||||
<VToolbarTitle> {{ t('dialog.workflowActions.title') }} - {{ workflow?.name }} </VToolbarTitle>
|
||||
<VSpacer></VSpacer>
|
||||
<VToolbarItems>
|
||||
<VBtn icon variant="text" @click="importCodeDialog = true" class="ms-2">
|
||||
<VIcon size="24" color="white" icon="mdi-import" />
|
||||
|
||||
@@ -24,7 +24,7 @@ const inProps = defineProps({
|
||||
storage: String,
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Object as PropType<any>,
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
refreshpending: Boolean,
|
||||
@@ -554,196 +554,202 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
|
||||
<div v-if="!loading" class="flex">
|
||||
<IconBtn v-if="display.mdAndUp.value">
|
||||
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
|
||||
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
|
||||
</IconBtn>
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="plain"
|
||||
:placeholder="t('common.search')"
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="mx-2"
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="list_files">
|
||||
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||
</IconBtn>
|
||||
<!-- 批量操作按钮 -->
|
||||
<span v-if="selected.length > 0">
|
||||
<IconBtn @click.stop="batchScrape">
|
||||
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||
<div>
|
||||
<VCard class="d-flex flex-column w-full h-full rounded-t-0" :class="{ 'rounded-s-0': showTree }">
|
||||
<div v-if="!loading" class="flex">
|
||||
<IconBtn v-if="display.mdAndUp.value">
|
||||
<VIcon v-if="showTree" icon="mdi-file-tree" @click="switchFileTree(false)" />
|
||||
<VIcon v-else icon="mdi-file-tree-outline" @click="switchFileTree(true)" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showBatchTransfer">
|
||||
<VIcon color="primary" icon="mdi-folder-arrow-right" />
|
||||
<VTextField
|
||||
v-if="!isFile"
|
||||
v-model="filter"
|
||||
hide-details
|
||||
flat
|
||||
density="compact"
|
||||
variant="plain"
|
||||
:placeholder="t('common.search')"
|
||||
prepend-inner-icon="mdi-filter-outline"
|
||||
class="mx-2"
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" v-if="selectMode"> mdi-selection-remove </VIcon>
|
||||
<VIcon color="primary" v-else>mdi-select</VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="batchDelete">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
</span>
|
||||
</div>
|
||||
<LoadingBanner v-if="loading" />
|
||||
<!-- 文件详情 -->
|
||||
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
|
||||
<div v-if="items[0]?.thumbnail" class="flex justify-center">
|
||||
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="object-cover w-full h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="list_files">
|
||||
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||
</IconBtn>
|
||||
<!-- 批量操作按钮 -->
|
||||
<span v-if="selected.length > 0">
|
||||
<IconBtn @click.stop="batchScrape">
|
||||
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showBatchTransfer">
|
||||
<VIcon color="primary" icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="batchDelete">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
|
||||
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
|
||||
{{ t('file.size') }}:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
{{ t('file.modifyTime') }}:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
</p>
|
||||
</VCardText>
|
||||
<!-- 图片 -->
|
||||
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList class="text-high-emphasis">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
<template #prepend>
|
||||
<VListItemAction v-if="selectMode">
|
||||
<VCheckbox v-model="selected" :value="item" />
|
||||
</VListItemAction>
|
||||
<template v-else>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
|
||||
<VIcon v-else icon="mdi-file-outline" />
|
||||
</template>
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn v-if="display.smAndDown.value && !selectMode">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="menu.show" :base-color="menu.props.color" @click="menu.props.click(item)">
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||
<IconBtn @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="scrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
{{ t('file.noFiles') }}
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
{{ t('file.emptyDirectory') }}
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="renamePopper = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="newName"
|
||||
:label="t('file.newName')"
|
||||
:loading="renameLoading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
|
||||
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<LoadingBanner v-if="loading" />
|
||||
<!-- 文件详情 -->
|
||||
<VCardText v-else-if="isFile && !isImage && items.length > 0" class="text-center break-all">
|
||||
<div v-if="items[0]?.thumbnail" class="flex justify-center">
|
||||
<VImg max-width="15rem" cover :src="items[0]?.thumbnail" class="rounded border">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="object-cover w-full h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="text-xl text-high-emphasis mt-3">{{ items[0]?.name }}</div>
|
||||
<p class="mt-2" v-if="items[0]?.size && items[0].modify_time">
|
||||
{{ t('file.size') }}:{{ formatBytes(items[0]?.size || 0) }}<br />
|
||||
{{ t('file.modifyTime') }}:{{ formatTime(items[0]?.modify_time || 0) }}
|
||||
</p>
|
||||
</VCardText>
|
||||
<!-- 图片 -->
|
||||
<VCardText v-else-if="isFile && isImage && items.length > 0" class="grow d-flex justify-center align-center">
|
||||
<VImg :src="currentImgLink" max-width="100%" max-height="100%" />
|
||||
</VCardText>
|
||||
<!-- 目录和文件列表 -->
|
||||
<VCardText v-else-if="dirs.length || files.length" class="p-0">
|
||||
<VList class="text-high-emphasis">
|
||||
<VVirtualScroll :items="[...dirs, ...files]" :style="listStyle">
|
||||
<template #default="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
<template #prepend>
|
||||
<VListItemAction v-if="selectMode">
|
||||
<VCheckbox v-model="selected" :value="item" />
|
||||
</VListItemAction>
|
||||
<template v-else>
|
||||
<VIcon
|
||||
v-if="inProps.icons && item.extension"
|
||||
:icon="inProps.icons[item.extension.toLowerCase()] || inProps.icons?.other"
|
||||
/>
|
||||
<VIcon v-else-if="item.type == 'dir'" icon="mdi-folder" />
|
||||
<VIcon v-else icon="mdi-file-outline" />
|
||||
</template>
|
||||
</template>
|
||||
<VListItemTitle v-text="item.name" />
|
||||
<VListItemSubtitle v-if="item.size">
|
||||
{{ formatBytes(item.size) }}
|
||||
</VListItemSubtitle>
|
||||
<template #append>
|
||||
<IconBtn v-if="display.smAndDown.value && !selectMode">
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(menu, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="menu.show"
|
||||
:base-color="menu.props.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<span v-if="hover.isHovering && display.mdAndUp.value && !selectMode" class="flex">
|
||||
<IconBtn @click.stop="recognize(item.path)">
|
||||
<VIcon icon="mdi-text-recognition" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="scrape(item)">
|
||||
<VIcon icon="mdi-auto-fix" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showRenmae(item)">
|
||||
<VIcon icon="mdi-rename" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="showTransfer(item)">
|
||||
<VIcon icon="mdi-folder-arrow-right" />
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="deleteItem(item)">
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
{{ t('file.noFiles') }}
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!loading" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
{{ t('file.emptyDirectory') }}
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- 识别结果对话框 -->
|
||||
<MediaInfoDialog
|
||||
v-if="nameTestDialog"
|
||||
v-model="nameTestDialog"
|
||||
:context="nameTestResult"
|
||||
@close="nameTestDialog = false"
|
||||
/>
|
||||
<!-- 重命名弹窗 -->
|
||||
<VDialog v-if="renamePopper" v-model="renamePopper" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="renamePopper = false" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="newName"
|
||||
:label="t('file.newName')"
|
||||
:loading="renameLoading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" v-if="currentItem && currentItem.type == 'dir'">
|
||||
<VSwitch v-model="renameAll" :label="t('file.includeSubfolders')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" @click="get_recommend_name" prepend-icon="mdi-magic" class="px-5 me-3">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!newName" @click="rename" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 文件整理弹窗 -->
|
||||
<ReorganizeDialog
|
||||
v-if="transferPopper"
|
||||
v-model="transferPopper"
|
||||
:items="transferItems"
|
||||
:target_storage="inProps.storage"
|
||||
@done="transferDone"
|
||||
@close="transferPopper = false"
|
||||
/>
|
||||
<!-- 进度框 -->
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
|
||||
<!-- 识别结果对话框 -->
|
||||
<MediaInfoDialog
|
||||
v-if="nameTestDialog"
|
||||
v-model="nameTestDialog"
|
||||
:context="nameTestResult"
|
||||
@close="nameTestDialog = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,7 +27,7 @@ const props = defineProps({
|
||||
},
|
||||
endpoints: Object,
|
||||
axios: {
|
||||
type: Object as PropType<any>,
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ const inProps = defineProps({
|
||||
},
|
||||
endpoints: Object as PropType<EndPoints>,
|
||||
axios: {
|
||||
type: Object as PropType<any>,
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import AnalyticsStorage from '@/views/dashboard/AnalyticsStorage.vue'
|
||||
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
|
||||
import AnalyticsCpu from '@/views/dashboard/AnalyticsCpu.vue'
|
||||
import AnalyticsMemory from '@/views/dashboard/AnalyticsMemory.vue'
|
||||
import AnalyticsNetwork from '@/views/dashboard/AnalyticsNetwork.vue'
|
||||
import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
||||
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||
@@ -81,6 +82,7 @@ onUnmounted(() => {
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsMemory v-else-if="config?.id === 'memory'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsNetwork v-else-if="config?.id === 'network'" :allowRefresh="props.allowRefresh" />
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||
|
||||
152
src/composables/useDynamicHeaderTab.ts
Normal file
152
src/composables/useDynamicHeaderTab.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
// 动态标签页相关类型
|
||||
interface DynamicHeaderTabButton {
|
||||
icon: string
|
||||
color?: string | ComputedRef<string>
|
||||
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||
size?: string
|
||||
class?: string
|
||||
action?: () => void
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
}
|
||||
|
||||
interface DynamicHeaderTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
}
|
||||
|
||||
interface DynamicHeaderTabConfig {
|
||||
items: DynamicHeaderTabItem[]
|
||||
modelValue: string
|
||||
appendButtons?: DynamicHeaderTabButton[]
|
||||
routePath?: string
|
||||
onUpdateModelValue?: (value: string) => void
|
||||
}
|
||||
|
||||
export function useDynamicHeaderTab() {
|
||||
const route = useRoute()
|
||||
|
||||
// 尝试从inject获取
|
||||
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
|
||||
|
||||
// 注册动态标签页
|
||||
const registerHeaderTab = (config: {
|
||||
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
|
||||
modelValue: Ref<string>
|
||||
appendButtons?: DynamicHeaderTabButton[]
|
||||
}) => {
|
||||
const tabConfig: DynamicHeaderTabConfig = {
|
||||
items: Array.isArray(config.items) ? config.items : config.items.value,
|
||||
modelValue: config.modelValue.value,
|
||||
appendButtons: config.appendButtons,
|
||||
routePath: route.path,
|
||||
onUpdateModelValue: (value: string) => {
|
||||
config.modelValue.value = value
|
||||
},
|
||||
}
|
||||
|
||||
// 监听modelValue变化并更新配置
|
||||
watch(config.modelValue, newValue => {
|
||||
tabConfig.modelValue = newValue
|
||||
// 重新注册以更新值
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 如果items是computed或ref,也需要监听其变化
|
||||
if (!Array.isArray(config.items)) {
|
||||
watch(
|
||||
config.items,
|
||||
newItems => {
|
||||
tabConfig.items = newItems
|
||||
// 重新注册以更新items
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
}
|
||||
|
||||
// 注册函数
|
||||
const doRegister = () => {
|
||||
// 确保路由路径是最新的
|
||||
tabConfig.routePath = route.path
|
||||
// 确保items是最新的
|
||||
tabConfig.items = Array.isArray(config.items) ? config.items : config.items.value
|
||||
// 确保modelValue是最新的
|
||||
tabConfig.modelValue = config.modelValue.value
|
||||
|
||||
if (registerDynamicHeaderTab) {
|
||||
registerDynamicHeaderTab(tabConfig)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
// 使用全局方法作为备用
|
||||
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
if (globalRegister) {
|
||||
globalRegister(tabConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 取消注册函数
|
||||
const doUnregister = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始注册(延迟到下个tick,确保路由已经完全切换)
|
||||
nextTick(() => {
|
||||
doRegister()
|
||||
})
|
||||
|
||||
// 处理页面激活时重新注册(支持keep-alive缓存的页面)
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
doRegister()
|
||||
})
|
||||
})
|
||||
|
||||
// 处理页面失活时取消注册(支持keep-alive缓存的页面)
|
||||
onDeactivated(() => {
|
||||
doUnregister()
|
||||
})
|
||||
|
||||
// 在组件卸载时取消注册
|
||||
onUnmounted(() => {
|
||||
doUnregister()
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
const unregisterHeaderTab = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registerHeaderTab,
|
||||
unregisterHeaderTab,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型以供其他地方使用
|
||||
export type { DynamicHeaderTabButton, DynamicHeaderTabItem, DynamicHeaderTabConfig }
|
||||
61
src/composables/useOfflineStatus.ts
Normal file
61
src/composables/useOfflineStatus.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
// 全局状态
|
||||
const isAppOffline = ref(false)
|
||||
const appOfflineReason = ref('')
|
||||
|
||||
// 全局离线状态管理
|
||||
export function useGlobalOfflineStatus() {
|
||||
const isOnline = useOnline()
|
||||
|
||||
// 综合离线状态(网络离线 或 应用离线)
|
||||
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
|
||||
|
||||
// 是否可以执行网络操作
|
||||
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
|
||||
|
||||
// 设置应用离线状态
|
||||
const setAppOffline = (offline: boolean, reason?: string) => {
|
||||
isAppOffline.value = offline
|
||||
appOfflineReason.value = reason || ''
|
||||
}
|
||||
|
||||
// 获取离线消息
|
||||
const getOfflineMessage = () => {
|
||||
if (!isOnline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
if (isAppOffline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
setAppOffline,
|
||||
getOfflineMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// 单个组件的离线状态
|
||||
export function useOfflineStatus(initialMessage?: string) {
|
||||
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
const message = computed(() => {
|
||||
if (initialMessage) {
|
||||
return initialMessage
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
message,
|
||||
}
|
||||
}
|
||||
57
src/composables/usePWA.ts
Normal file
57
src/composables/usePWA.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { isPWA } from '@/@core/utils/navigator'
|
||||
|
||||
// 全局PWA状态,确保只初始化一次
|
||||
const globalPwaMode = ref<boolean | null>(null)
|
||||
const globalLoading = ref(false)
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
// 全局初始化函数
|
||||
async function initializePWAGlobally() {
|
||||
if (initPromise) return initPromise
|
||||
|
||||
if (globalPwaMode.value !== null || globalLoading.value) return Promise.resolve()
|
||||
|
||||
initPromise = new Promise(async (resolve, reject) => {
|
||||
globalLoading.value = true
|
||||
try {
|
||||
globalPwaMode.value = await isPWA()
|
||||
resolve()
|
||||
} catch (error) {
|
||||
console.error('Failed to detect PWA mode', error)
|
||||
globalPwaMode.value = false
|
||||
reject(error)
|
||||
} finally {
|
||||
globalLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function usePWA() {
|
||||
const display = useDisplay()
|
||||
|
||||
const appMode = computed(() => {
|
||||
return globalPwaMode.value && display.mdAndDown.value
|
||||
})
|
||||
|
||||
// 自动初始化PWA检测
|
||||
onMounted(() => {
|
||||
initializePWAGlobally().catch(console.error)
|
||||
})
|
||||
|
||||
// 如果是在服务端或首次调用,立即开始初始化
|
||||
if (typeof window !== 'undefined' && globalPwaMode.value === null && !globalLoading.value) {
|
||||
initializePWAGlobally().catch(console.error)
|
||||
}
|
||||
|
||||
return {
|
||||
pwaMode: globalPwaMode,
|
||||
appMode,
|
||||
loading: globalLoading,
|
||||
// 保留手动初始化方法以防需要
|
||||
initializePWA: initializePWAGlobally,
|
||||
}
|
||||
}
|
||||
122
src/composables/usePWAState.ts
Normal file
122
src/composables/usePWAState.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import type { PWAState } from '@/utils/pwaStateManager'
|
||||
|
||||
export function usePWAState() {
|
||||
const isStateRestored = ref(false)
|
||||
const stateRestoreCount = ref(0)
|
||||
const lastRestoredState = ref<PWAState | null>(null)
|
||||
const isPWAMode = ref(false)
|
||||
|
||||
// 检查PWA模式
|
||||
const checkPWAMode = () => {
|
||||
isPWAMode.value = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
const saveCurrentState = async () => {
|
||||
if (window.pwaStateController) {
|
||||
await window.pwaStateController.saveCurrentState()
|
||||
}
|
||||
}
|
||||
|
||||
// 手动触发状态恢复检查
|
||||
const checkStateRestore = async () => {
|
||||
if (window.pwaStateController) {
|
||||
// 静默检查
|
||||
}
|
||||
}
|
||||
|
||||
// 监听状态恢复事件
|
||||
const handleStateRestored = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ state: PWAState }>
|
||||
isStateRestored.value = true
|
||||
stateRestoreCount.value++
|
||||
lastRestoredState.value = customEvent.detail.state
|
||||
}
|
||||
|
||||
// 重置状态恢复标志
|
||||
const resetStateRestored = () => {
|
||||
isStateRestored.value = false
|
||||
lastRestoredState.value = null
|
||||
}
|
||||
|
||||
// 检查状态管理器是否可用
|
||||
const isStateManagerAvailable = () => {
|
||||
return !!window.pwaStateController
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkPWAMode()
|
||||
|
||||
// 监听状态恢复事件
|
||||
window.addEventListener('pwaStateRestored', handleStateRestored)
|
||||
|
||||
// 监听PWA模式变化
|
||||
const mediaQuery = window.matchMedia('(display-mode: standalone)')
|
||||
const handleDisplayModeChange = (e: MediaQueryListEvent) => {
|
||||
isPWAMode.value = e.matches
|
||||
}
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleDisplayModeChange)
|
||||
} else {
|
||||
mediaQuery.addListener(handleDisplayModeChange)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', handleDisplayModeChange)
|
||||
} else {
|
||||
mediaQuery.removeListener(handleDisplayModeChange)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pwaStateRestored', handleStateRestored)
|
||||
})
|
||||
|
||||
return {
|
||||
// 响应式状态
|
||||
isPWAMode,
|
||||
isStateRestored,
|
||||
stateRestoreCount,
|
||||
lastRestoredState,
|
||||
|
||||
// 方法
|
||||
saveCurrentState,
|
||||
checkStateRestore,
|
||||
resetStateRestored,
|
||||
isStateManagerAvailable,
|
||||
checkPWAMode
|
||||
}
|
||||
}
|
||||
|
||||
// 全局PWA状态管理器
|
||||
export function useGlobalPWAState() {
|
||||
// 检查是否在PWA环境中
|
||||
const isPWAEnvironment = () => {
|
||||
return window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
}
|
||||
|
||||
// 获取存储的状态
|
||||
const getStoredState = () => {
|
||||
return localStorage.getItem('mp-pwa-app-state')
|
||||
}
|
||||
|
||||
// 清除存储的状态
|
||||
const clearStoredState = () => {
|
||||
localStorage.removeItem('mp-pwa-app-state')
|
||||
sessionStorage.removeItem('mp-pwa-session-state')
|
||||
}
|
||||
|
||||
return {
|
||||
isPWAEnvironment,
|
||||
getStoredState,
|
||||
clearStoredState
|
||||
}
|
||||
}
|
||||
283
src/composables/usePullDownGesture.ts
Normal file
283
src/composables/usePullDownGesture.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { ref, computed, onMounted, onBeforeUnmount, readonly, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { usePWA } from './usePWA'
|
||||
|
||||
// 下拉手势配置类型
|
||||
export interface PullDownConfig {
|
||||
START_THRESHOLD: number // 开始下拉的最小距离
|
||||
SHOW_INDICATOR: number // 显示指示器的距离
|
||||
TRIGGER_THRESHOLD: number // 触发回调的距离
|
||||
MAX_PULL_DISTANCE: number // 最大下拉距离
|
||||
PULL_RESISTANCE: number // 下拉阻力系数
|
||||
CONTENT_FOLLOW_RATIO: number // 页面内容跟随比例
|
||||
TOLERANCE: number // 手指抖动容忍度
|
||||
}
|
||||
|
||||
// 下拉手势选项
|
||||
export interface PullDownOptions {
|
||||
config?: Partial<PullDownConfig>
|
||||
// 检查是否可以使用下拉手势的函数
|
||||
canUsePullGesture?: () => boolean
|
||||
// 触发回调
|
||||
onTrigger?: () => void
|
||||
// 是否启用(默认true)
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG: PullDownConfig = {
|
||||
START_THRESHOLD: 20,
|
||||
SHOW_INDICATOR: 60,
|
||||
TRIGGER_THRESHOLD: 100,
|
||||
MAX_PULL_DISTANCE: 200,
|
||||
PULL_RESISTANCE: 0.75,
|
||||
CONTENT_FOLLOW_RATIO: 0.4,
|
||||
TOLERANCE: 80,
|
||||
}
|
||||
|
||||
export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||
const display = useDisplay()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 合并配置
|
||||
const config = { ...DEFAULT_CONFIG, ...options.config }
|
||||
|
||||
// 状态管理
|
||||
const isPulling = ref(false)
|
||||
const startY = ref(0)
|
||||
const pullDistance = ref(0)
|
||||
const initialScrollTop = ref(0)
|
||||
const hasDialogOpen = ref(false)
|
||||
const lastDialogCheckTime = ref(0)
|
||||
const DIALOG_CHECK_INTERVAL = 500
|
||||
|
||||
// 计算属性
|
||||
const contentTransform = computed(() => {
|
||||
if (!isPulling.value || pullDistance.value <= 0) return 'translateY(0)'
|
||||
const moveDistance = pullDistance.value * config.CONTENT_FOLLOW_RATIO
|
||||
return `translateY(${moveDistance}px)`
|
||||
})
|
||||
|
||||
const contentTransition = computed(() => {
|
||||
return isPulling.value ? 'none' : 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
})
|
||||
|
||||
const showPullIndicator = computed(() => {
|
||||
return isPulling.value && pullDistance.value >= config.SHOW_INDICATOR
|
||||
})
|
||||
|
||||
const indicatorRotation = computed(() => {
|
||||
if (!isPulling.value) return 0
|
||||
const progress = Math.min(
|
||||
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||||
1,
|
||||
)
|
||||
return progress * 180
|
||||
})
|
||||
|
||||
const indicatorOpacity = computed(() => {
|
||||
if (!isPulling.value) return 0
|
||||
const progress = Math.min(
|
||||
(pullDistance.value - config.SHOW_INDICATOR) / (config.TRIGGER_THRESHOLD - config.SHOW_INDICATOR),
|
||||
1,
|
||||
)
|
||||
return 0.7 + progress * 0.3
|
||||
})
|
||||
|
||||
const indicatorTransform = computed(() => {
|
||||
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
|
||||
})
|
||||
|
||||
// 弹窗检测函数
|
||||
const hasOpenDialog = (excludeSelector?: string) => {
|
||||
try {
|
||||
const dialogSelectors = [
|
||||
'.v-overlay--active:not(.v-overlay--scroll-blocked)',
|
||||
'.v-dialog--active',
|
||||
'.v-menu--active',
|
||||
'.v-bottom-sheet--active',
|
||||
'.v-snackbar--active',
|
||||
'[role="dialog"]:not([style*="display: none"])',
|
||||
'.modal:not(.d-none):not([style*="display: none"])',
|
||||
'[aria-modal="true"]:not([style*="display: none"])',
|
||||
]
|
||||
|
||||
for (const selector of dialogSelectors) {
|
||||
const elements = document.querySelectorAll(selector)
|
||||
if (elements.length > 0) {
|
||||
// 如果需要排除特定元素(如QuickAccess面板)
|
||||
if (excludeSelector && elements.length === 1) {
|
||||
const element = elements[0]
|
||||
if (element.closest(excludeSelector)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.warn('检测弹窗状态时出错:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理函数
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||
|
||||
// 检查是否有弹窗打开
|
||||
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||||
lastDialogCheckTime.value = Date.now()
|
||||
|
||||
if (hasDialogOpen.value) return
|
||||
|
||||
const touch = event.touches[0]
|
||||
startY.value = touch.clientY
|
||||
|
||||
// 重置下拉状态
|
||||
isPulling.value = false
|
||||
pullDistance.value = 0
|
||||
|
||||
// 记录开始时的滚动位置
|
||||
initialScrollTop.value = window.scrollY || document.documentElement.scrollTop || 0
|
||||
}
|
||||
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||
|
||||
// 只在必要时重新检测弹窗
|
||||
const currentTime = Date.now()
|
||||
if (currentTime - lastDialogCheckTime.value > DIALOG_CHECK_INTERVAL) {
|
||||
hasDialogOpen.value = hasOpenDialog('.quick-access-panel')
|
||||
lastDialogCheckTime.value = currentTime
|
||||
}
|
||||
|
||||
if (hasDialogOpen.value) {
|
||||
isPulling.value = false
|
||||
pullDistance.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const touch = event.touches[0]
|
||||
const deltaY = touch.clientY - startY.value
|
||||
|
||||
if (isPulling.value) {
|
||||
if (deltaY > -config.TOLERANCE) {
|
||||
pullDistance.value = Math.max(0, Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE))
|
||||
event.preventDefault()
|
||||
} else {
|
||||
isPulling.value = false
|
||||
pullDistance.value = 0
|
||||
}
|
||||
} else {
|
||||
if (deltaY > config.START_THRESHOLD) {
|
||||
const currentScrollTop = window.scrollY || document.documentElement.scrollTop || 0
|
||||
|
||||
if (currentScrollTop <= 100 && initialScrollTop.value <= 100) {
|
||||
isPulling.value = true
|
||||
pullDistance.value = Math.min(deltaY * config.PULL_RESISTANCE, config.MAX_PULL_DISTANCE)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (!appMode.value || !display.mdAndDown.value || !options.enabled) return
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
if (options.canUsePullGesture && !options.canUsePullGesture()) return
|
||||
|
||||
// 重置弹窗检测标志
|
||||
hasDialogOpen.value = false
|
||||
lastDialogCheckTime.value = 0
|
||||
|
||||
if (isPulling.value && pullDistance.value >= config.TRIGGER_THRESHOLD) {
|
||||
// 达到触发阈值,执行回调
|
||||
options.onTrigger?.()
|
||||
}
|
||||
|
||||
// 停止拖拽状态
|
||||
isPulling.value = false
|
||||
|
||||
// 延迟重置其他状态
|
||||
setTimeout(() => {
|
||||
pullDistance.value = 0
|
||||
startY.value = 0
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 生命周期管理
|
||||
let eventsAdded = false
|
||||
|
||||
const addEventListeners = () => {
|
||||
if (!eventsAdded && appMode.value && display.mdAndDown.value) {
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: false })
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
eventsAdded = true
|
||||
}
|
||||
}
|
||||
|
||||
const removeEventListeners = () => {
|
||||
if (eventsAdded) {
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
eventsAdded = false
|
||||
}
|
||||
}
|
||||
|
||||
// PWA状态确定后,一次性决定是否添加事件监听器
|
||||
onMounted(() => {
|
||||
// 如果PWA已经检测完成,直接添加事件监听器
|
||||
if (appMode.value !== null) {
|
||||
addEventListeners()
|
||||
} else {
|
||||
// 等待PWA检测完成(从null变为boolean)
|
||||
const stopWatcher = watch(
|
||||
appMode,
|
||||
newValue => {
|
||||
if (newValue !== null) {
|
||||
addEventListeners()
|
||||
// PWA状态确定后停止监听
|
||||
stopWatcher()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListeners()
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isPulling: readonly(isPulling),
|
||||
pullDistance: readonly(pullDistance),
|
||||
|
||||
// 计算属性
|
||||
contentTransform,
|
||||
contentTransition,
|
||||
showPullIndicator,
|
||||
indicatorRotation,
|
||||
indicatorOpacity,
|
||||
indicatorTransform,
|
||||
|
||||
// 配置
|
||||
config,
|
||||
|
||||
// 工具函数
|
||||
hasOpenDialog,
|
||||
}
|
||||
}
|
||||
113
src/composables/useRecentPlugins.ts
Normal file
113
src/composables/useRecentPlugins.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
|
||||
const RECENT_PLUGINS_KEY = 'moviepilot_recent_plugins'
|
||||
const MAX_RECENT_PLUGINS = 3
|
||||
|
||||
interface RecentPlugin {
|
||||
id: string
|
||||
plugin_name: string
|
||||
plugin_icon?: string
|
||||
has_page: boolean
|
||||
state: boolean
|
||||
plugin_id: string
|
||||
access_time: number
|
||||
}
|
||||
|
||||
// 将Plugin转换为RecentPlugin
|
||||
function pluginToRecentPlugin(plugin: Plugin): RecentPlugin {
|
||||
return {
|
||||
id: plugin.id || '',
|
||||
plugin_name: plugin.plugin_name || '',
|
||||
plugin_icon: plugin.plugin_icon,
|
||||
has_page: plugin.has_page || false,
|
||||
state: plugin.state || false,
|
||||
plugin_id: plugin.id || '',
|
||||
access_time: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// 将RecentPlugin转换为Plugin
|
||||
function recentPluginToPlugin(recentPlugin: RecentPlugin): Plugin {
|
||||
return {
|
||||
id: recentPlugin.id,
|
||||
plugin_name: recentPlugin.plugin_name,
|
||||
plugin_icon: recentPlugin.plugin_icon,
|
||||
has_page: recentPlugin.has_page,
|
||||
state: recentPlugin.state,
|
||||
plugin_id: recentPlugin.plugin_id,
|
||||
} as Plugin
|
||||
}
|
||||
|
||||
export function useRecentPlugins() {
|
||||
// 获取最近访问的插件
|
||||
function getRecentPlugins(): Plugin[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
|
||||
if (!stored) return []
|
||||
|
||||
const recentPlugins: RecentPlugin[] = JSON.parse(stored)
|
||||
|
||||
// 按访问时间倒序排列
|
||||
return recentPlugins.sort((a, b) => b.access_time - a.access_time).map(recentPluginToPlugin)
|
||||
} catch (error) {
|
||||
console.error('获取最近访问插件失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 添加插件到最近访问
|
||||
function addRecentPlugin(plugin: Plugin) {
|
||||
try {
|
||||
if (!plugin.id || !plugin.has_page) return
|
||||
|
||||
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
|
||||
let recentPlugins: RecentPlugin[] = stored ? JSON.parse(stored) : []
|
||||
|
||||
// 移除已存在的相同插件(如果有的话)
|
||||
recentPlugins = recentPlugins.filter(p => p.id !== plugin.id)
|
||||
|
||||
// 添加新的插件到开头
|
||||
recentPlugins.unshift(pluginToRecentPlugin(plugin))
|
||||
|
||||
// 限制最大数量
|
||||
if (recentPlugins.length > MAX_RECENT_PLUGINS) {
|
||||
recentPlugins = recentPlugins.slice(0, MAX_RECENT_PLUGINS)
|
||||
}
|
||||
|
||||
localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))
|
||||
} catch (error) {
|
||||
console.error('保存最近访问插件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有最近访问记录
|
||||
function clearRecentPlugins() {
|
||||
try {
|
||||
localStorage.removeItem(RECENT_PLUGINS_KEY)
|
||||
} catch (error) {
|
||||
console.error('清除最近访问插件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除特定插件
|
||||
function removeRecentPlugin(pluginId: string) {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_PLUGINS_KEY)
|
||||
if (!stored) return
|
||||
|
||||
let recentPlugins: RecentPlugin[] = JSON.parse(stored)
|
||||
recentPlugins = recentPlugins.filter(p => p.id !== pluginId)
|
||||
|
||||
localStorage.setItem(RECENT_PLUGINS_KEY, JSON.stringify(recentPlugins))
|
||||
} catch (error) {
|
||||
console.error('移除最近访问插件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getRecentPlugins,
|
||||
addRecentPlugin,
|
||||
clearRecentPlugins,
|
||||
removeRecentPlugin,
|
||||
}
|
||||
}
|
||||
159
src/composables/useScrollLock.ts
Normal file
159
src/composables/useScrollLock.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { ref, watch, onBeforeUnmount, readonly } from 'vue'
|
||||
|
||||
// 滚动锁定配置选项
|
||||
export interface ScrollLockOptions {
|
||||
// 是否在组件卸载时自动恢复滚动(默认true)
|
||||
autoRestore?: boolean
|
||||
// 是否保存和恢复滚动位置(默认true)
|
||||
preserveScrollPosition?: boolean
|
||||
// 自定义锁定时的样式
|
||||
lockStyles?: {
|
||||
overflow?: string
|
||||
position?: string
|
||||
width?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_OPTIONS: Required<ScrollLockOptions> = {
|
||||
autoRestore: true,
|
||||
preserveScrollPosition: true,
|
||||
lockStyles: {
|
||||
overflow: 'hidden',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
},
|
||||
}
|
||||
|
||||
export function useScrollLock(options: ScrollLockOptions = {}) {
|
||||
const config = { ...DEFAULT_OPTIONS, ...options }
|
||||
|
||||
// 状态管理
|
||||
const isLocked = ref(false)
|
||||
const savedScrollPosition = ref(0)
|
||||
const originalBodyStyles = ref<{ [key: string]: string }>({})
|
||||
const originalDocumentStyles = ref<{ [key: string]: string }>({})
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
if (config.preserveScrollPosition) {
|
||||
savedScrollPosition.value =
|
||||
window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 保存原始样式
|
||||
const saveOriginalStyles = () => {
|
||||
// 保存 body 样式
|
||||
originalBodyStyles.value = {
|
||||
overflow: document.body.style.overflow,
|
||||
position: document.body.style.position,
|
||||
top: document.body.style.top,
|
||||
width: document.body.style.width,
|
||||
}
|
||||
|
||||
// 保存 documentElement 样式
|
||||
originalDocumentStyles.value = {
|
||||
overflow: document.documentElement.style.overflow,
|
||||
}
|
||||
}
|
||||
|
||||
// 锁定滚动
|
||||
const lockScroll = () => {
|
||||
if (isLocked.value) return
|
||||
|
||||
// 保存当前状态
|
||||
saveScrollPosition()
|
||||
saveOriginalStyles()
|
||||
|
||||
// 应用锁定样式
|
||||
document.body.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
document.body.style.position = config.lockStyles.position || 'fixed'
|
||||
document.body.style.width = config.lockStyles.width || '100%'
|
||||
document.documentElement.style.overflow = config.lockStyles.overflow || 'hidden'
|
||||
|
||||
// 如果需要保持滚动位置,设置top偏移
|
||||
if (config.preserveScrollPosition) {
|
||||
document.body.style.top = `-${savedScrollPosition.value}px`
|
||||
}
|
||||
|
||||
isLocked.value = true
|
||||
}
|
||||
|
||||
// 恢复滚动
|
||||
const restoreScroll = () => {
|
||||
if (!isLocked.value) return
|
||||
|
||||
// 恢复原始样式
|
||||
document.body.style.overflow = originalBodyStyles.value.overflow || ''
|
||||
document.body.style.position = originalBodyStyles.value.position || ''
|
||||
document.body.style.top = originalBodyStyles.value.top || ''
|
||||
document.body.style.width = originalBodyStyles.value.width || ''
|
||||
document.documentElement.style.overflow = originalDocumentStyles.value.overflow || ''
|
||||
|
||||
// 恢复滚动位置
|
||||
if (config.preserveScrollPosition) {
|
||||
window.scrollTo(0, savedScrollPosition.value)
|
||||
}
|
||||
|
||||
isLocked.value = false
|
||||
}
|
||||
|
||||
// 切换滚动锁定状态
|
||||
const toggleScrollLock = (lock?: boolean) => {
|
||||
const shouldLock = lock !== undefined ? lock : !isLocked.value
|
||||
|
||||
if (shouldLock) {
|
||||
lockScroll()
|
||||
} else {
|
||||
restoreScroll()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听响应式值的变化
|
||||
const watchTarget = (target: any) => {
|
||||
return watch(
|
||||
target,
|
||||
newValue => {
|
||||
toggleScrollLock(!!newValue)
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
}
|
||||
|
||||
// 生命周期清理
|
||||
onBeforeUnmount(() => {
|
||||
if (config.autoRestore && isLocked.value) {
|
||||
restoreScroll()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isLocked: readonly(isLocked),
|
||||
savedScrollPosition: readonly(savedScrollPosition),
|
||||
|
||||
// 方法
|
||||
lockScroll,
|
||||
restoreScroll,
|
||||
toggleScrollLock,
|
||||
watchTarget,
|
||||
|
||||
// 工具方法
|
||||
saveScrollPosition,
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷的自动监听版本
|
||||
export function useScrollLockWithWatch(target: any, options: ScrollLockOptions = {}) {
|
||||
const scrollLock = useScrollLock(options)
|
||||
|
||||
// 自动监听目标值的变化
|
||||
const stopWatcher = scrollLock.watchTarget(target)
|
||||
|
||||
// 返回所有功能 + 停止监听的方法
|
||||
return {
|
||||
...scrollLock,
|
||||
stopWatcher,
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,27 @@ import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { onUnreadMessage } from '@/utils/badge'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { useScrollLockWithWatch } from '@/composables/useScrollLock'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode')
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
@@ -49,6 +59,145 @@ const organizeMenus = ref<NavMenu[]>([])
|
||||
// 系统菜单项
|
||||
const systemMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 插件快速访问相关状态
|
||||
const showPluginQuickAccess = ref(false)
|
||||
|
||||
// 离线状态管理
|
||||
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
|
||||
|
||||
// 动态标签页相关
|
||||
// 定义动态标签页类型
|
||||
interface DynamicHeaderTab {
|
||||
items: Array<{ title: string; icon: string; tab: string }>
|
||||
modelValue: string
|
||||
appendButtons?: Array<{
|
||||
icon: string
|
||||
color?: string | ComputedRef<string>
|
||||
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
|
||||
size?: string
|
||||
class?: string
|
||||
action?: () => void
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string
|
||||
}>
|
||||
routePath?: string // 用于标识哪个路由注册的
|
||||
onUpdateModelValue?: (value: string) => void // 用于通知值更新
|
||||
}
|
||||
|
||||
// 提供动态标签页注册和获取的方法
|
||||
const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
|
||||
|
||||
// 提供一个方法让其他组件注册动态标签页
|
||||
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||
// 保存注册标签页的路由路径
|
||||
tab.routePath = route.path
|
||||
// 强制更新,确保响应式系统能检测到变化
|
||||
dynamicHeaderTab.value = { ...tab }
|
||||
}
|
||||
|
||||
// 提供一个方法让其他组件取消注册动态标签页
|
||||
const unregisterDynamicHeaderTab = () => {
|
||||
dynamicHeaderTab.value = null
|
||||
}
|
||||
|
||||
// 标签页值更新处理
|
||||
const handleTabChange = (newValue: string) => {
|
||||
if (dynamicHeaderTab.value) {
|
||||
dynamicHeaderTab.value.modelValue = newValue
|
||||
// 通知注册的页面更新值
|
||||
if (dynamicHeaderTab.value.onUpdateModelValue) {
|
||||
dynamicHeaderTab.value.onUpdateModelValue(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加全局注册方法,解决注入不可用的问题
|
||||
if (typeof window !== 'undefined') {
|
||||
// 确保在浏览器环境中
|
||||
;(window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__ = registerDynamicHeaderTab
|
||||
}
|
||||
|
||||
// 提供给其他组件使用
|
||||
provide('registerDynamicHeaderTab', registerDynamicHeaderTab)
|
||||
provide('unregisterDynamicHeaderTab', unregisterDynamicHeaderTab)
|
||||
|
||||
// 监听路由变化来清除动态标签页
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
// 使用nextTick确保新页面的组件已经挂载完成
|
||||
nextTick(() => {
|
||||
// 如果当前标签页不属于新路由,则清除
|
||||
if (dynamicHeaderTab.value && dynamicHeaderTab.value.routePath !== route.path) {
|
||||
dynamicHeaderTab.value = null
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// 显示动态标签页
|
||||
const showDynamicHeaderTab = computed(() => {
|
||||
return (
|
||||
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
|
||||
)
|
||||
})
|
||||
|
||||
// 在组件销毁时清理
|
||||
onUnmounted(() => {
|
||||
dynamicHeaderTab.value = null
|
||||
// 清理全局方法
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
|
||||
}
|
||||
})
|
||||
|
||||
// 监听Service Worker消息
|
||||
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
||||
if (event.data && event.data.type === 'OFFLINE_STATUS') {
|
||||
if (event.data.offline) {
|
||||
setAppOffline(true, t('common.serverConnectionFailed'))
|
||||
} else {
|
||||
setAppOffline(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用滚动锁定 composable(自动监听showPluginQuickAccess的变化)
|
||||
useScrollLockWithWatch(showPluginQuickAccess)
|
||||
|
||||
// 检查是否可以使用下拉手势
|
||||
const canUsePullGesture = () => {
|
||||
// 检查是否在dashboard页面
|
||||
const isDashboard = route.name === 'dashboard' || route.path === '/dashboard'
|
||||
// 检查是否是管理员
|
||||
const isAdmin = superUser.value
|
||||
// 检查插件快速访问面板是否已显示
|
||||
const quickAccessOpen = showPluginQuickAccess.value
|
||||
// 检查是否离线
|
||||
const offline = isOffline.value
|
||||
|
||||
return isDashboard && isAdmin && !quickAccessOpen && !offline
|
||||
}
|
||||
|
||||
// 使用下拉手势 composable
|
||||
const {
|
||||
pullDistance,
|
||||
contentTransform,
|
||||
contentTransition,
|
||||
showPullIndicator,
|
||||
indicatorRotation,
|
||||
indicatorOpacity,
|
||||
indicatorTransform,
|
||||
config: PULL_CONFIG,
|
||||
} = usePullDownGesture({
|
||||
enabled: true,
|
||||
canUsePullGesture,
|
||||
onTrigger: () => {
|
||||
showPluginQuickAccess.value = true
|
||||
},
|
||||
})
|
||||
|
||||
// 根据分类获取菜单列表
|
||||
const getMenuList = (header: string) => {
|
||||
// 使用国际化菜单
|
||||
@@ -74,6 +223,16 @@ function handleUnreadMessage(count: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件快速访问
|
||||
function handleClosePluginQuickAccess() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
// 点击插件后关闭
|
||||
function handlePluginClick() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
@@ -85,18 +244,53 @@ onMounted(() => {
|
||||
// 监听全局未读消息事件
|
||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||
|
||||
// 监听Service Worker消息
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe()
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VerticalNavLayout>
|
||||
<!-- 👉 Offline Page -->
|
||||
<OfflinePage />
|
||||
|
||||
<!-- 👉 Pull Down Indicator -->
|
||||
<div
|
||||
v-if="appMode && showPullIndicator"
|
||||
class="pull-indicator"
|
||||
:style="{
|
||||
opacity: indicatorOpacity,
|
||||
transform: indicatorTransform,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="indicator-icon"
|
||||
:style="{
|
||||
transform: `scale(${
|
||||
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
|
||||
}) rotate(${indicatorRotation}deg)`,
|
||||
}"
|
||||
>
|
||||
<VIcon
|
||||
icon="mdi-gesture-swipe-down"
|
||||
size="24"
|
||||
:color="pullDistance >= PULL_CONFIG.TRIGGER_THRESHOLD ? 'success' : 'primary'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<VerticalNavLayout :style="{ '--navbar-tab-height': showDynamicHeaderTab ? '2.5rem' : '0px' }">
|
||||
<!-- 👉 Navbar -->
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
<div class="d-flex h-100 align-center mx-1">
|
||||
<div class="d-flex h-14 align-center mx-1">
|
||||
<!-- 👉 Vertical Nav Toggle -->
|
||||
<IconBtn v-if="!appMode && display.mdAndDown.value" class="ms-n2" @click="toggleVerticalOverlayNavActive(true)">
|
||||
<VIcon icon="mdi-menu" />
|
||||
@@ -155,22 +349,124 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #after-vertical-nav-items />
|
||||
<!-- 👉 Pages -->
|
||||
<slot />
|
||||
|
||||
<!-- 👉 Dynamic Header Tab -->
|
||||
<template #dynamic-header-tab>
|
||||
<div v-if="showDynamicHeaderTab">
|
||||
<HeaderTab
|
||||
:items="dynamicHeaderTab!.items"
|
||||
:model-value="dynamicHeaderTab!.modelValue"
|
||||
@update:model-value="handleTabChange"
|
||||
>
|
||||
<template #append>
|
||||
<template v-for="button in dynamicHeaderTab!.appendButtons" :key="button.icon">
|
||||
<VBtn
|
||||
v-if="typeof button.show === 'boolean' ? button.show !== false : (button.show as any)?.value !== false"
|
||||
:icon="button.icon"
|
||||
:variant="button.variant || 'text'"
|
||||
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
|
||||
:size="button.size || 'default'"
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</HeaderTab>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 👉 下拉跟随动画 -->
|
||||
<div
|
||||
class="main-content-wrapper"
|
||||
:style="{
|
||||
transform: contentTransform,
|
||||
transition: contentTransition,
|
||||
paddingTop: showDynamicHeaderTab ? '3rem' : '0px',
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 👉 Footer -->
|
||||
<template #footer>
|
||||
<Footer />
|
||||
</template>
|
||||
</VerticalNavLayout>
|
||||
|
||||
<!-- 👉 Plugin Quick Access -->
|
||||
<QuickAccess
|
||||
v-if="appMode"
|
||||
:visible="showPluginQuickAccess"
|
||||
:pull-distance="pullDistance"
|
||||
@close="handleClosePluginQuickAccess"
|
||||
@plugin-click="handlePluginClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.meta-key {
|
||||
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
block-size: 1.5625rem;
|
||||
line-height: 1.3125rem;
|
||||
padding-block: 0.125rem;
|
||||
padding-inline: 0.25rem;
|
||||
.main-content-wrapper {
|
||||
backface-visibility: hidden;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
transform: translateZ(0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(var(--v-theme-surface), 0.3);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: 80px;
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 透明主题适配 */
|
||||
html[class*='transparent'] .pull-indicator,
|
||||
html[class*='mica'] .pull-indicator,
|
||||
html[class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 20%);
|
||||
background: rgba(255, 255, 255, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
html[class*='transparent'] .indicator-icon,
|
||||
html[class*='mica'] .indicator-icon,
|
||||
html[class*='acrylic'] .indicator-icon {
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='mica'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 10%);
|
||||
background: rgba(18, 18, 18, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .indicator-icon,
|
||||
html[data-theme='dark'][class*='mica'] .indicator-icon,
|
||||
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
|
||||
background: rgba(var(--v-theme-primary), 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { NavMenu } from '@/@layouts/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 判断当前是否为英文环境
|
||||
@@ -245,8 +247,8 @@ const showDynamicButton = computed(() => {
|
||||
.footer-nav-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(16px);
|
||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
pointer-events: auto;
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ const scrollTabs = (direction: 'left' | 'right') => {
|
||||
const el = tabsContainerRef.value
|
||||
if (!el) return
|
||||
|
||||
const scrollAmount = 200 // 可以根据需要调整滚动量
|
||||
// 可以根据需要调整滚动量
|
||||
const scrollAmount = 200
|
||||
const scrollPosition = direction === 'left' ? el.scrollLeft - scrollAmount : el.scrollLeft + scrollAmount
|
||||
|
||||
el.scrollTo({
|
||||
@@ -77,9 +78,6 @@ onMounted(async () => {
|
||||
// Initial check for tabs indicator after DOM update
|
||||
await nextTick() // Ensure element is rendered
|
||||
updateTabsIndicator()
|
||||
|
||||
// Listen for scroll events specifically on the tabs container
|
||||
tabsContainerRef.value?.addEventListener('scroll', updateTabsIndicator, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -90,7 +88,7 @@ onUnmounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="tab-header rounded-t-lg">
|
||||
<div class="tab-header">
|
||||
<VBtn v-if="showLeftButton" class="scroll-button left-button" @click="scrollTabs('left')" variant="text" icon>
|
||||
<VIcon icon="tabler-chevron-left" size="small" color="secondary" />
|
||||
</VBtn>
|
||||
@@ -117,17 +115,11 @@ onUnmounted(() => {
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.tab-header {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
backdrop-filter: blur(10px);
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
inset-block-start: 0;
|
||||
margin-block-end: 16px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scroll-button {
|
||||
@@ -191,6 +183,7 @@ onUnmounted(() => {
|
||||
.header-tab-icon {
|
||||
color: rgba(var(--v-theme-on-background), 0.6);
|
||||
margin-inline-end: 6px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -206,6 +199,7 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
padding-block: 6px;
|
||||
padding-inline: 14px;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 10%);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -224,6 +218,7 @@ onUnmounted(() => {
|
||||
|
||||
&.active {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);
|
||||
|
||||
&::after {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
@@ -231,6 +226,7 @@ onUnmounted(() => {
|
||||
|
||||
.header-tab-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 15%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
266
src/layouts/components/OfflinePage.vue
Normal file
266
src/layouts/components/OfflinePage.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
interface Props {
|
||||
type?: 'offline' | 'online'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'offline',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
const handleRetry = async () => {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
// 尝试发送一个简单的请求来检测网络
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
// 如果成功,等待一下让状态更新
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 当网络恢复时自动隐藏页面
|
||||
const shouldShow = computed(() => {
|
||||
return !canPerformNetworkAction.value
|
||||
})
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition-all duration-300"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="shouldShow" class="offline-page">
|
||||
<div class="offline-container">
|
||||
<!-- 状态图标 -->
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="64" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要信息 -->
|
||||
<div class="content-section">
|
||||
<h1 class="offline-title">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h1>
|
||||
|
||||
<p class="offline-message">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<!-- 重试按钮 -->
|
||||
<div class="action-section">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="large"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 状态指示器 -->
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-section">
|
||||
<p class="app-info">{{ t('app.moviepilot') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-page {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgb(var(--v-theme-surface-variant)) 100%);
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.offline-container {
|
||||
padding: 40px;
|
||||
border-radius: 24px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 10%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
inline-size: 100%;
|
||||
max-inline-size: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 120px;
|
||||
inline-size: 120px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -4px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
margin-block-end: 32px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
margin-block-end: 32px;
|
||||
}
|
||||
|
||||
.help-panels {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (width <= 600px) {
|
||||
.offline-container {
|
||||
padding: 24px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
block-size: 100px;
|
||||
inline-size: 100px;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式优化 */
|
||||
.v-theme--dark .offline-page {
|
||||
background: linear-gradient(135deg, rgb(var(--v-theme-surface)) 0%, rgba(var(--v-theme-surface-variant), 0.8) 100%);
|
||||
}
|
||||
|
||||
.v-theme--dark .offline-container {
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 30%), 0 0 0 1px rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
</style>
|
||||
709
src/layouts/components/QuickAccess.vue
Normal file
709
src/layouts/components/QuickAccess.vue
Normal file
@@ -0,0 +1,709 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import noImage from '@images/logos/plugin.png'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRecentPlugins } from '@/composables/useRecentPlugins'
|
||||
import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
|
||||
import { VCard } from 'vuetify/components'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 最近访问插件管理
|
||||
const { getRecentPlugins, addRecentPlugin } = useRecentPlugins()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pullDistance: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// 事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'plugin-click', plugin: Plugin): void
|
||||
}>()
|
||||
|
||||
// 有详情页面的插件列表
|
||||
const pluginsWithPage = ref<Plugin[]>([])
|
||||
|
||||
// 最近访问的插件列表
|
||||
const recentPlugins = ref<Plugin[]>([])
|
||||
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 各插件的图标加载状态
|
||||
const pluginIconLoadError = ref<Record<string, boolean>>({})
|
||||
|
||||
// 各插件的背景颜色
|
||||
const pluginBackgroundColors = ref<Record<string, string>>({})
|
||||
|
||||
// 上滑关闭配置常量
|
||||
const SWIPE_CONFIG = {
|
||||
START_THRESHOLD: 10, // 开始检测上滑的最小距离
|
||||
CLOSE_THRESHOLD: 100, // 触发关闭的距离
|
||||
MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离
|
||||
VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)
|
||||
}
|
||||
|
||||
// 上滑关闭相关状态
|
||||
const isDraggingToClose = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
const startY = ref(0)
|
||||
const lastY = ref(0)
|
||||
const lastTime = ref(0)
|
||||
const velocity = ref(0)
|
||||
const startedFromBottomArea = ref(false)
|
||||
|
||||
// 插件弹窗相关状态
|
||||
const showPluginDataDialog = ref(false)
|
||||
const currentPlugin = ref<Plugin | null>(null)
|
||||
|
||||
// 计算显示状态
|
||||
const isVisible = computed(() => {
|
||||
return props.visible
|
||||
})
|
||||
|
||||
// 处理插件图标加载错误
|
||||
function handleIconError(plugin: Plugin) {
|
||||
pluginIconLoadError.value[plugin.id] = true
|
||||
}
|
||||
|
||||
// 处理插件图标加载完成
|
||||
async function handleIconLoaded(src: string | undefined, plugin: Plugin) {
|
||||
if (!src) return
|
||||
|
||||
try {
|
||||
// 创建一个临时的img元素来获取图片数据
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = async () => {
|
||||
try {
|
||||
// 从图片中提取背景色
|
||||
const backgroundColor = await getDominantColor(img)
|
||||
pluginBackgroundColors.value[plugin.id] = backgroundColor
|
||||
} catch (error) {
|
||||
// 如果提取失败,使用默认颜色
|
||||
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
// 如果加载失败,使用默认颜色
|
||||
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
|
||||
}
|
||||
img.src = src
|
||||
} catch (error) {
|
||||
// 如果提取失败,使用默认颜色
|
||||
pluginBackgroundColors.value[plugin.id] = '#28A9E1'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取插件背景颜色
|
||||
function getPluginBackgroundColor(plugin: Plugin): string {
|
||||
return pluginBackgroundColors.value[plugin.id] || '#28A9E1'
|
||||
}
|
||||
|
||||
// 计算整个组件的transform(包含拖动偏移)
|
||||
const componentTransform = computed(() => {
|
||||
let baseTransform = ''
|
||||
if (props.visible) {
|
||||
baseTransform = 'translateY(0)'
|
||||
} else {
|
||||
baseTransform = 'translateY(-100%)'
|
||||
}
|
||||
|
||||
// 如果正在拖动关闭,添加拖动偏移(向上拖拽为负值,让面板向上移动)
|
||||
if (isDraggingToClose.value) {
|
||||
return `${baseTransform} translateY(-${dragOffset.value}px)`
|
||||
}
|
||||
|
||||
return baseTransform
|
||||
})
|
||||
|
||||
// 计算组件透明度
|
||||
const componentOpacity = computed(() => {
|
||||
return props.visible ? 1 : 0
|
||||
})
|
||||
|
||||
// 计算插件图标路径
|
||||
function getPluginIcon(plugin: Plugin): string {
|
||||
if (!plugin.plugin_icon) return noImage
|
||||
if (pluginIconLoadError.value[plugin.id]) return noImage
|
||||
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(plugin?.plugin_icon)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${plugin?.plugin_icon}`
|
||||
}
|
||||
|
||||
// 获取有详情页面的插件
|
||||
async function fetchPluginsWithPage() {
|
||||
if (loading.value) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const allPlugins: Plugin[] = await api.get('plugin/', {
|
||||
params: {
|
||||
state: 'installed',
|
||||
},
|
||||
})
|
||||
|
||||
// 只保留有详情页面且已启用的插件
|
||||
pluginsWithPage.value = allPlugins
|
||||
.filter(plugin => plugin.has_page)
|
||||
.sort((a, b) => {
|
||||
// 按插件名称排序
|
||||
return (a.plugin_name || '').localeCompare(b.plugin_name || '')
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取插件列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近访问的插件
|
||||
function loadRecentPlugins() {
|
||||
recentPlugins.value = getRecentPlugins()
|
||||
}
|
||||
|
||||
// 点击插件
|
||||
function handlePluginClick(plugin: Plugin) {
|
||||
// 添加到最近访问列表
|
||||
addRecentPlugin(plugin)
|
||||
|
||||
// 更新最近访问列表显示
|
||||
loadRecentPlugins()
|
||||
|
||||
emit('plugin-click', plugin)
|
||||
|
||||
// 设置当前插件并显示数据弹窗
|
||||
currentPlugin.value = plugin
|
||||
showPluginDataDialog.value = true
|
||||
}
|
||||
|
||||
// 关闭面板
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 关闭插件数据弹窗
|
||||
function handleClosePluginDataDialog() {
|
||||
showPluginDataDialog.value = false
|
||||
currentPlugin.value = null
|
||||
}
|
||||
|
||||
// 监听可见性变化,加载数据
|
||||
watch(
|
||||
() => isVisible.value,
|
||||
visible => {
|
||||
if (visible) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (isVisible.value) {
|
||||
fetchPluginsWithPage()
|
||||
loadRecentPlugins()
|
||||
}
|
||||
})
|
||||
|
||||
// 处理触摸开始
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (!props.visible) return
|
||||
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
|
||||
// 检查是否从 bottom-drag-area 开始触摸
|
||||
const target = event.target as HTMLElement
|
||||
startedFromBottomArea.value = !!target.closest('.bottom-drag-area')
|
||||
|
||||
startY.value = touch.clientY
|
||||
lastY.value = touch.clientY
|
||||
lastTime.value = Date.now()
|
||||
velocity.value = 0
|
||||
|
||||
// 重置拖拽状态
|
||||
isDraggingToClose.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
// 处理触摸移动
|
||||
function handleTouchMove(event: TouchEvent) {
|
||||
if (!props.visible) return
|
||||
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
|
||||
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
|
||||
if (!startedFromBottomArea.value) return
|
||||
|
||||
const currentY = touch.clientY
|
||||
const currentTime = Date.now()
|
||||
const deltaY = startY.value - currentY // 向上为正值
|
||||
const timeDelta = currentTime - lastTime.value
|
||||
|
||||
// 计算速度
|
||||
if (timeDelta > 0) {
|
||||
const moveDistance = lastY.value - currentY
|
||||
velocity.value = moveDistance / timeDelta
|
||||
}
|
||||
|
||||
// 如果已经开始拖拽,继续拖拽
|
||||
if (isDraggingToClose.value) {
|
||||
if (deltaY >= 0) {
|
||||
// 向上拖拽,更新偏移量
|
||||
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
|
||||
event.preventDefault()
|
||||
} else {
|
||||
// 向下拖拽,停止拖拽
|
||||
isDraggingToClose.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
} else {
|
||||
// 还没开始拖拽,检查是否应该开始
|
||||
if (deltaY > SWIPE_CONFIG.START_THRESHOLD) {
|
||||
isDraggingToClose.value = true
|
||||
dragOffset.value = Math.min(deltaY, SWIPE_CONFIG.MAX_DRAG_DISTANCE)
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
lastY.value = currentY
|
||||
lastTime.value = currentTime
|
||||
}
|
||||
|
||||
// 处理触摸结束
|
||||
function handleTouchEnd() {
|
||||
if (!props.visible) return
|
||||
|
||||
// 只有从 bottom-drag-area 开始的触摸才处理上滑关闭
|
||||
if (!startedFromBottomArea.value) return
|
||||
|
||||
if (isDraggingToClose.value) {
|
||||
// 判断是否应该关闭:距离超过阈值或者快速上滑
|
||||
const shouldClose =
|
||||
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD
|
||||
|
||||
if (shouldClose) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 重置拖拽状态
|
||||
isDraggingToClose.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
startY.value = 0
|
||||
lastY.value = 0
|
||||
velocity.value = 0
|
||||
startedFromBottomArea.value = false
|
||||
}
|
||||
|
||||
// 点击底部空白区域关闭
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
// 点击根容器或底部提示区域时关闭
|
||||
if (
|
||||
target.classList.contains('plugin-quick-access') ||
|
||||
target.classList.contains('footer-hint') ||
|
||||
target.classList.contains('hint-text') ||
|
||||
target.classList.contains('bottom-drag-area')
|
||||
) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
:ripple="false"
|
||||
class="plugin-quick-access"
|
||||
:class="{ 'visible': isVisible }"
|
||||
:style="{
|
||||
opacity: componentOpacity,
|
||||
transform: componentTransform,
|
||||
transition: isDraggingToClose ? 'none' : 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}"
|
||||
@click="handleBackdropClick"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
>
|
||||
<!-- 顶部指示器 -->
|
||||
<div class="top-indicator"></div>
|
||||
|
||||
<!-- 标题栏 -->
|
||||
<div class="header">
|
||||
<div class="header-title">{{ t('plugin.quickAccess') }}</div>
|
||||
<VBtn icon variant="text" @click="handleClose" class="close-btn">
|
||||
<VIcon icon="mdi-close" />
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 插件网格 -->
|
||||
<div class="plugin-grid">
|
||||
<!-- 加载状态 -->
|
||||
<LoadingBanner v-if="loading" />
|
||||
|
||||
<!-- 最近访问 -->
|
||||
<template v-else>
|
||||
<div class="section-header">
|
||||
<div class="section-title">{{ t('plugin.recentlyUsed') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="recentPlugins.length > 0" class="recent-plugins-row">
|
||||
<div
|
||||
v-for="plugin in recentPlugins"
|
||||
:key="`recent-${plugin.id}`"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<VBadge dot :color="plugin.state ? 'success' : 'secondary'" location="top end">
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@error="handleIconError(plugin)"
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 没有最近访问时显示"无" -->
|
||||
<div v-else class="no-recent-plugins">
|
||||
<VIcon icon="mdi-puzzle-outline" size="24" color="grey" />
|
||||
</div>
|
||||
|
||||
<!-- 所有插件 -->
|
||||
<div v-if="pluginsWithPage.length > 0" class="section-header with-margin">
|
||||
<div class="section-title">{{ t('plugin.allPlugins') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pluginsWithPage.length > 0" class="all-plugins-grid">
|
||||
<div
|
||||
v-for="plugin in pluginsWithPage"
|
||||
:key="plugin.id"
|
||||
class="plugin-item"
|
||||
@click="handlePluginClick(plugin)"
|
||||
>
|
||||
<VBadge
|
||||
dot
|
||||
:color="plugin.state ? 'success' : 'secondary'"
|
||||
location="top end"
|
||||
:offset-x="-1"
|
||||
:offset-y="-1"
|
||||
>
|
||||
<div
|
||||
class="plugin-icon"
|
||||
:style="{
|
||||
background: `${getPluginBackgroundColor(plugin)}`,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
:src="getPluginIcon(plugin)"
|
||||
:alt="plugin.plugin_name"
|
||||
cover
|
||||
@load="src => handleIconLoaded(src, plugin)"
|
||||
@error="handleIconError(plugin)"
|
||||
class="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</VBadge>
|
||||
<div class="plugin-name">{{ plugin.plugin_name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态(只有在没有插件时显示) -->
|
||||
<div v-else-if="pluginsWithPage.length === 0" class="empty-state">
|
||||
<VIcon icon="mdi-puzzle-outline" size="48" color="grey" />
|
||||
<div class="empty-text">{{ t('plugin.noPluginsWithPage') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部拖动区域 -->
|
||||
<div class="bottom-drag-area" @click="handleBackdropClick">
|
||||
<!-- 底部指示器 -->
|
||||
<div class="bottom-indicator">
|
||||
<div
|
||||
class="indicator-bar bottom"
|
||||
:class="{ 'dragging': isDraggingToClose }"
|
||||
:style="{
|
||||
transform: isDraggingToClose
|
||||
? `scaleX(${Math.min(dragOffset / SWIPE_CONFIG.CLOSE_THRESHOLD, 1.5)})`
|
||||
: 'scaleX(1)',
|
||||
background: isDraggingToClose
|
||||
? dragOffset >= SWIPE_CONFIG.CLOSE_THRESHOLD
|
||||
? 'rgba(var(--v-theme-success), 0.8)'
|
||||
: 'rgba(var(--v-theme-primary), 0.8)'
|
||||
: 'rgba(var(--v-theme-on-surface), 0.12)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 插件数据弹窗 -->
|
||||
<PluginDataDialog
|
||||
v-if="showPluginDataDialog && currentPlugin"
|
||||
v-model="showPluginDataDialog"
|
||||
:plugin="currentPlugin"
|
||||
:show_switch="false"
|
||||
@close="handleClosePluginDataDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-quick-access {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(32px);
|
||||
background: rgba(var(--v-theme-surface), 0.95);
|
||||
block-size: 100vh;
|
||||
block-size: 100dvh;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
opacity: 0;
|
||||
padding-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
pointer-events: none;
|
||||
transform: translateY(-100%);
|
||||
transition: all 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.top-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-block: 12px 8px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
// 底部相关样式
|
||||
.bottom-indicator {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-block: 8px 12px;
|
||||
padding-inline: 0;
|
||||
|
||||
.indicator-bar.bottom {
|
||||
border-radius: 2px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 4px;
|
||||
inline-size: 30vw;
|
||||
transform-origin: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding-block: 0 16px;
|
||||
padding-inline: 20px;
|
||||
|
||||
.header-title {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: flex;
|
||||
overflow: hidden auto;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-block-size: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none; // IE/Edge
|
||||
overscroll-behavior: contain;
|
||||
padding-block: 24px;
|
||||
padding-inline: 20px;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none; // Firefox
|
||||
touch-action: pan-y;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none; // WebKit 浏览器
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-inline: 0;
|
||||
|
||||
.section-title {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.no-recent-plugins {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.recent-plugins-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
padding-block: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.all-plugins-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
|
||||
}
|
||||
|
||||
.plugin-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
block-size: 120px;
|
||||
cursor: pointer;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 16px;
|
||||
block-size: 64px;
|
||||
inline-size: 64px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.plugin-item:hover & {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
-webkit-box-orient: vertical;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.2;
|
||||
max-block-size: 2.4em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
grid-column: 1 / -1;
|
||||
padding-block: 40px;
|
||||
padding-inline: 0;
|
||||
|
||||
.empty-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-drag-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding-block: 8px 0;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.plugin-item:hover {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.plugin-item:active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// 深色模式适配
|
||||
html[data-theme='dark'] .plugin-quick-access {
|
||||
background: rgba(var(--v-theme-surface), 0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import useDragAndDrop from '@core/utils/workflow'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { actionStepDict } from '@/api/constants'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
interface ActionItem {
|
||||
name: string
|
||||
@@ -13,7 +14,8 @@ interface ActionItem {
|
||||
|
||||
const display = useDisplay()
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { onDragStart } = useDragAndDrop()
|
||||
|
||||
@@ -19,6 +19,11 @@ export default {
|
||||
noData: 'No data',
|
||||
noContent: 'No relevant content found',
|
||||
all: 'All',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
filter: 'Filter',
|
||||
noMatchingData: 'No matching data',
|
||||
tryChangingFilters: 'Try changing filters',
|
||||
default: 'Default',
|
||||
name: 'Name',
|
||||
create: 'Create',
|
||||
@@ -44,6 +49,17 @@ export default {
|
||||
pageText: '{0}-{1} of {2}',
|
||||
noDataText: 'No data',
|
||||
loadingText: 'Loading...',
|
||||
networkRequired: 'This feature requires network connection',
|
||||
networkDisconnected: 'Network connection lost',
|
||||
featuresLimited: 'Some features may be limited',
|
||||
serverConnectionFailed: 'Server connection failed',
|
||||
troubleshooting: 'Troubleshooting',
|
||||
checking: 'Checking',
|
||||
retry: 'Retry',
|
||||
networkOnline: 'Network Online',
|
||||
networkOffline: 'Network Offline',
|
||||
serviceAvailable: 'Service Available',
|
||||
serviceUnavailable: 'Service Unavailable',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -115,6 +131,7 @@ export default {
|
||||
},
|
||||
app: {
|
||||
moviepilot: 'MoviePilot',
|
||||
slogan: 'Intelligent Movie & TV Media Library Management Tool',
|
||||
recommend: 'Recommend',
|
||||
subscribeMovie: 'Movie Subscription',
|
||||
subscribeTv: 'TV Subscription',
|
||||
@@ -126,6 +143,10 @@ export default {
|
||||
restartTip: 'After restart, you will be logged out and need to log in again.',
|
||||
restartTimeout: 'Restart timeout, the system may need more time to recover, please refresh the page manually later',
|
||||
restartFailed: 'Restart failed, please check system status',
|
||||
offline: 'Offline Mode',
|
||||
offlineMessage: 'Network connection lost, some features may be limited',
|
||||
online: 'Online Mode',
|
||||
onlineMessage: 'Network connection restored',
|
||||
},
|
||||
login: {
|
||||
wallpapers: 'Wallpapers',
|
||||
@@ -575,6 +596,9 @@ export default {
|
||||
scheduler: 'Background Tasks',
|
||||
cpu: 'CPU',
|
||||
memory: 'Memory',
|
||||
network: 'Network Traffic',
|
||||
upload: 'Upload',
|
||||
download: 'Download',
|
||||
library: 'My Media Library',
|
||||
playing: 'Continue Watching',
|
||||
latest: 'Recently Added',
|
||||
@@ -733,7 +757,7 @@ export default {
|
||||
others: 'Others',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Page Not Found ⚠️',
|
||||
title: '⚠️ Page Not Found',
|
||||
description: 'The page you tried to access does not exist. Please check if the address is correct.',
|
||||
backButton: 'Go Back',
|
||||
},
|
||||
@@ -748,6 +772,7 @@ export default {
|
||||
sortSite: 'Site',
|
||||
sortSize: 'Size',
|
||||
sortSeeder: 'Seeder',
|
||||
sortPublishTime: 'Publish Time',
|
||||
filterSite: 'Site',
|
||||
filterSeason: 'Season',
|
||||
filterFreeState: 'Free State',
|
||||
@@ -773,7 +798,8 @@ export default {
|
||||
alipan: 'Aliyun Drive',
|
||||
u115: '115 Cloud',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB Network Share',
|
||||
custom: 'Custom',
|
||||
},
|
||||
filterRules: {
|
||||
@@ -883,6 +909,10 @@ export default {
|
||||
testing: 'Testing ...',
|
||||
testSuccess: '{name} connectivity test successful, ready to use!',
|
||||
testFailed: '{name} connectivity test failed: {message}',
|
||||
connectionNormal: 'Connection Normal',
|
||||
connectionSlow: 'Connection Slow',
|
||||
connectionFailed: 'Connection Failed',
|
||||
connectionUnknown: 'Connection Unknown',
|
||||
deleteConfirm: 'Are you sure you want to delete this site?',
|
||||
deleteSuccess: '{name} deleted successfully!',
|
||||
deleteFailed: '{name} deletion failed: {message}',
|
||||
@@ -1654,8 +1684,8 @@ export default {
|
||||
reset: 'Reset',
|
||||
},
|
||||
alistConfig: {
|
||||
title: 'Alist Configuration',
|
||||
serverUrl: 'Alist server address',
|
||||
title: 'OpenList Configuration',
|
||||
serverUrl: 'OpenList server address',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
tokenUrl: 'Token acquisition address',
|
||||
@@ -1668,6 +1698,21 @@ export default {
|
||||
complete: 'Complete',
|
||||
reset: 'Reset',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB Network Share Configuration',
|
||||
host: 'SMB Server Address',
|
||||
hostHint: 'IP address or hostname of the SMB server',
|
||||
share: 'Share Name',
|
||||
shareHint: 'Name of the shared folder to connect to',
|
||||
username: 'Username',
|
||||
usernameHint: 'SMB login username',
|
||||
password: 'Password',
|
||||
passwordHint: 'SMB login password',
|
||||
domain: 'Domain',
|
||||
domainHint: 'SMB domain name, such as WORKGROUP or domain controller name',
|
||||
complete: 'Complete',
|
||||
reset: 'Reset',
|
||||
},
|
||||
workflowAddEdit: {
|
||||
addTitle: 'Add Workflow',
|
||||
editTitle: 'Edit Workflow',
|
||||
@@ -2172,6 +2217,12 @@ export default {
|
||||
cloneFailed: 'Plugin clone creation failed: {message}',
|
||||
cloneFailedGeneral: 'Plugin clone creation failed',
|
||||
logTitle: 'Plugin Logging',
|
||||
quickAccess: 'Quick Access',
|
||||
noPluginsWithPage: 'No plugins with detail pages available',
|
||||
tapToOpen: 'Tap to Return',
|
||||
recentlyUsed: 'Recently Used',
|
||||
allPlugins: 'All Plugins',
|
||||
noRecentPlugins: 'None',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: 'Personal Information',
|
||||
|
||||
@@ -19,6 +19,11 @@ export default {
|
||||
noData: '暂无数据',
|
||||
noContent: '没有找到相关内容',
|
||||
all: '全部',
|
||||
active: '激活',
|
||||
inactive: '未激活',
|
||||
filter: '筛选',
|
||||
noMatchingData: '没有符合条件的数据',
|
||||
tryChangingFilters: '请尝试更改筛选条件',
|
||||
default: '默认',
|
||||
name: '名称',
|
||||
create: '新建',
|
||||
@@ -44,6 +49,17 @@ export default {
|
||||
pageText: '{0}-{1} 共 {2} 条',
|
||||
noDataText: '没有数据',
|
||||
loadingText: '加载中...',
|
||||
networkRequired: '此功能需要网络连接',
|
||||
networkDisconnected: '网络连接已断开',
|
||||
featuresLimited: '部分功能可能受限',
|
||||
serverConnectionFailed: '服务器连接失败',
|
||||
troubleshooting: '疑难解答',
|
||||
checking: '检查中',
|
||||
retry: '重试',
|
||||
networkOnline: '网络在线',
|
||||
networkOffline: '网络离线',
|
||||
serviceAvailable: '服务可用',
|
||||
serviceUnavailable: '服务不可用',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -115,6 +131,7 @@ export default {
|
||||
},
|
||||
app: {
|
||||
moviepilot: 'MoviePilot',
|
||||
slogan: '智能影视媒体库管理工具',
|
||||
recommend: '推荐',
|
||||
subscribeMovie: '电影订阅',
|
||||
subscribeTv: '电视剧订阅',
|
||||
@@ -126,6 +143,10 @@ export default {
|
||||
restartTip: '重启后,您将被注销并需要重新登录。',
|
||||
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
|
||||
restartFailed: '重启失败,请检查系统状态',
|
||||
offline: '离线模式',
|
||||
offlineMessage: '网络连接已断开,部分功能可能受限',
|
||||
online: '在线模式',
|
||||
onlineMessage: '网络连接已恢复',
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁纸',
|
||||
@@ -573,6 +594,9 @@ export default {
|
||||
scheduler: '后台任务',
|
||||
cpu: 'CPU',
|
||||
memory: '内存',
|
||||
network: '网络流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
library: '我的媒体库',
|
||||
playing: '继续观看',
|
||||
latest: '最近添加',
|
||||
@@ -730,7 +754,7 @@ export default {
|
||||
others: '其他',
|
||||
},
|
||||
notFound: {
|
||||
title: '页面不存在 ⚠️',
|
||||
title: '⚠️ 页面不存在',
|
||||
description: '您想要访问的页面不存在,请检查地址是否正确。',
|
||||
backButton: '返回',
|
||||
},
|
||||
@@ -745,6 +769,7 @@ export default {
|
||||
sortSite: '站点',
|
||||
sortSize: '大小',
|
||||
sortSeeder: '做种数',
|
||||
sortPublishTime: '发布时间',
|
||||
filterSite: '站点',
|
||||
filterSeason: '季',
|
||||
filterFreeState: '促销状态',
|
||||
@@ -770,7 +795,8 @@ export default {
|
||||
alipan: '阿里云盘',
|
||||
u115: '115网盘',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB网络共享',
|
||||
custom: '自定义',
|
||||
},
|
||||
filterRules: {
|
||||
@@ -880,6 +906,10 @@ export default {
|
||||
testing: '测试中 ...',
|
||||
testSuccess: '{name} 连通性测试成功,可正常使用!',
|
||||
testFailed: '{name} 连通性测试失败:{message}',
|
||||
connectionNormal: '连接正常',
|
||||
connectionSlow: '连接缓慢',
|
||||
connectionFailed: '连接失败',
|
||||
connectionUnknown: '连接未知',
|
||||
deleteConfirm: '是否确认删除站点?',
|
||||
deleteSuccess: '{name} 删除成功!',
|
||||
deleteFailed: '{name} 删除失败:{message}',
|
||||
@@ -1632,8 +1662,8 @@ export default {
|
||||
reset: '重置',
|
||||
},
|
||||
alistConfig: {
|
||||
title: 'Alist配置',
|
||||
serverUrl: 'Alist服务地址',
|
||||
title: 'OpenList配置',
|
||||
serverUrl: 'OpenList服务地址',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
tokenUrl: '获取Token地址',
|
||||
@@ -1646,6 +1676,21 @@ export default {
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB网络共享配置',
|
||||
host: 'SMB服务器地址',
|
||||
hostHint: 'SMB服务器的IP地址或主机名',
|
||||
share: '共享名称',
|
||||
shareHint: '要连接的共享文件夹名称',
|
||||
username: '用户名',
|
||||
usernameHint: 'SMB登录用户名',
|
||||
password: '密码',
|
||||
passwordHint: 'SMB登录密码',
|
||||
domain: '域名',
|
||||
domainHint: 'SMB域名,如WORKGROUP或域控制器名称',
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
workflowAddEdit: {
|
||||
addTitle: '添加工作流',
|
||||
editTitle: '编辑工作流',
|
||||
@@ -2147,6 +2192,12 @@ export default {
|
||||
cloneFailed: '插件分身创建失败:{message}',
|
||||
cloneFailedGeneral: '插件分身创建失败',
|
||||
logTitle: '插件日志',
|
||||
quickAccess: '快速访问',
|
||||
tapToOpen: '点击返回主界面',
|
||||
noPluginsWithPage: '暂无可用插件',
|
||||
recentlyUsed: '最近使用',
|
||||
allPlugins: '所有插件',
|
||||
noRecentPlugins: '无',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: '个人信息',
|
||||
|
||||
@@ -19,6 +19,11 @@ export default {
|
||||
noData: '暫無數據',
|
||||
noContent: '沒有找到相關內容',
|
||||
all: '全部',
|
||||
active: '激活',
|
||||
inactive: '未激活',
|
||||
filter: '篩選',
|
||||
noMatchingData: '沒有符合條件的數據',
|
||||
tryChangingFilters: '請嘗試更改篩選條件',
|
||||
default: '默認',
|
||||
name: '名稱',
|
||||
create: '新建',
|
||||
@@ -44,6 +49,17 @@ export default {
|
||||
pageText: '{0}-{1} 共 {2} 條',
|
||||
noDataText: '沒有數據',
|
||||
loadingText: '加載中...',
|
||||
networkRequired: '此功能需要網絡連接',
|
||||
networkDisconnected: '網絡連接已斷開',
|
||||
featuresLimited: '部分功能可能受限',
|
||||
serverConnectionFailed: '服務器連接失敗',
|
||||
troubleshooting: '疑難排解',
|
||||
checking: '檢查中',
|
||||
retry: '重試',
|
||||
networkOnline: '網絡在線',
|
||||
networkOffline: '網絡離線',
|
||||
serviceAvailable: '服務可用',
|
||||
serviceUnavailable: '服務不可用',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -115,6 +131,7 @@ export default {
|
||||
},
|
||||
app: {
|
||||
moviepilot: 'MoviePilot',
|
||||
slogan: '智能影視媒體庫管理工具',
|
||||
recommend: '推薦',
|
||||
subscribeMovie: '電影訂閱',
|
||||
subscribeTv: '電視劇訂閱',
|
||||
@@ -127,6 +144,10 @@ export default {
|
||||
restartTip: '重啟後,您將被註銷並需要重新登錄。',
|
||||
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
|
||||
restartFailed: '重啟失敗,請檢查系統狀態',
|
||||
offline: '離線模式',
|
||||
offlineMessage: '網絡連接已斷開,部分功能可能受限',
|
||||
online: '在線模式',
|
||||
onlineMessage: '網絡連接已恢復',
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁紙',
|
||||
@@ -571,6 +592,9 @@ export default {
|
||||
scheduler: '後台任務',
|
||||
cpu: 'CPU',
|
||||
memory: '內存',
|
||||
network: '網絡流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
library: '我的媒體庫',
|
||||
playing: '繼續觀看',
|
||||
latest: '最近添加',
|
||||
@@ -728,7 +752,7 @@ export default {
|
||||
others: '其他',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在 ⚠️',
|
||||
title: '⚠️ 頁面不存在',
|
||||
description: '您想要訪問的頁面不存在,請檢查地址是否正確。',
|
||||
backButton: '返回',
|
||||
},
|
||||
@@ -743,6 +767,7 @@ export default {
|
||||
sortSite: '站點',
|
||||
sortSize: '大小',
|
||||
sortSeeder: '做種數',
|
||||
sortPublishTime: '發布時間',
|
||||
filterSite: '站點',
|
||||
filterSeason: '季',
|
||||
filterFreeState: '促銷狀態',
|
||||
@@ -768,7 +793,8 @@ export default {
|
||||
alipan: '阿里雲盤',
|
||||
u115: '115網盤',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB網路共享',
|
||||
custom: '自定義',
|
||||
},
|
||||
|
||||
@@ -879,6 +905,10 @@ export default {
|
||||
testing: '測試中 ...',
|
||||
testSuccess: '{name} 連通性測試成功,可正常使用!',
|
||||
testFailed: '{name} 連通性測試失敗:{message}',
|
||||
connectionNormal: '連接正常',
|
||||
connectionSlow: '連接緩慢',
|
||||
connectionFailed: '連接失敗',
|
||||
connectionUnknown: '連接未知',
|
||||
deleteConfirm: '是否確認刪除站點?',
|
||||
deleteSuccess: '{name} 刪除成功!',
|
||||
deleteFailed: '{name} 刪除失敗:{message}',
|
||||
@@ -1631,8 +1661,8 @@ export default {
|
||||
reset: '重置',
|
||||
},
|
||||
alistConfig: {
|
||||
title: 'Alist配置',
|
||||
serverUrl: 'Alist服務地址',
|
||||
title: 'OpenList配置',
|
||||
serverUrl: 'OpenList服務地址',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
tokenUrl: '獲取Token地址',
|
||||
@@ -1645,6 +1675,21 @@ export default {
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB網路共享配置',
|
||||
host: 'SMB伺服器地址',
|
||||
hostHint: 'SMB伺服器的IP地址或主機名',
|
||||
share: '共享名稱',
|
||||
shareHint: '要連接的共享資料夾名稱',
|
||||
username: '用戶名',
|
||||
usernameHint: 'SMB登入用戶名',
|
||||
password: '密碼',
|
||||
passwordHint: 'SMB登入密碼',
|
||||
domain: '域名',
|
||||
domainHint: 'SMB域名,如WORKGROUP或域控制器名稱',
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
workflowAddEdit: {
|
||||
addTitle: '新增工作流',
|
||||
editTitle: '編輯工作流',
|
||||
@@ -2146,6 +2191,12 @@ export default {
|
||||
cloneFailed: '插件分身創建失敗:{message}',
|
||||
cloneFailedGeneral: '插件分身創建失敗',
|
||||
logTitle: '插件日誌',
|
||||
quickAccess: '快速訪問',
|
||||
noPluginsWithPage: '暫無可展示的插件',
|
||||
tapToOpen: '點擊返回主界面',
|
||||
recentlyUsed: '最近使用',
|
||||
allPlugins: '所有插件',
|
||||
noRecentPlugins: '無',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: '個人信息',
|
||||
|
||||
140
src/main.ts
140
src/main.ts
@@ -18,9 +18,7 @@ import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { isPWA } from './@core/utils/navigator'
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
import { fetchGlobalSettings } from './utils/globalSetting'
|
||||
|
||||
// 5. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
@@ -45,65 +43,99 @@ import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
|
||||
// 8. PWA状态管理
|
||||
import { PWAStateController } from '@/utils/pwaStateManager'
|
||||
|
||||
// PWA状态管理器初始化函数
|
||||
const initializePWABeforeMount = async () => {
|
||||
// 检查是否在PWA模式下运行
|
||||
const isPWA = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone ||
|
||||
document.referrer.includes('android-app://')
|
||||
|
||||
if (isPWA) {
|
||||
const pwaStateController = new PWAStateController()
|
||||
|
||||
// 等待状态恢复完成
|
||||
await pwaStateController.waitForStateRestore()
|
||||
|
||||
// 将状态管理器绑定到全局对象
|
||||
;(window as any).pwaStateController = pwaStateController
|
||||
|
||||
return pwaStateController
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 在创建Vue应用前初始化PWA状态管理器
|
||||
const pwaStateController = await initializePWABeforeMount()
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册pinia
|
||||
app.use(pinia)
|
||||
|
||||
// 初始化配置
|
||||
async function initializeApp() {
|
||||
try {
|
||||
// 是否为PWA
|
||||
const pwaMode = await isPWA()
|
||||
app.provide('pwaMode', pwaMode)
|
||||
// 异步加载远程组件(不阻塞启动)
|
||||
loadRemoteComponents().catch(error => {
|
||||
console.error('Failed to load remote components', error)
|
||||
})
|
||||
|
||||
// 全局设置
|
||||
const globalSettings = await fetchGlobalSettings()
|
||||
app.provide('globalSettings', globalSettings)
|
||||
// 1. 注册 UI 框架
|
||||
app.use(vuetify)
|
||||
|
||||
// 加载并注册远程联邦组件
|
||||
await loadRemoteComponents()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app', error)
|
||||
}
|
||||
// 2. 注册路由
|
||||
app.use(router)
|
||||
|
||||
// 3. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VScrollToTopBtn', ScrollToTopBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
.component('VBackdropCard', BackdropCard)
|
||||
.component('VPersonCard', PersonCard)
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
.component('VHeaderTab', HeaderTab)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 4. 注册其他插件
|
||||
app
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(Toast, {
|
||||
position: 'bottom-right',
|
||||
hideProgressBar: true,
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
// 5. 添加状态恢复事件监听器
|
||||
if (pwaStateController) {
|
||||
// 监听状态恢复事件
|
||||
window.addEventListener('pwaStateRestored', (event: Event) => {
|
||||
const customEvent = event as CustomEvent
|
||||
|
||||
// 可以在这里添加状态恢复后的处理逻辑
|
||||
// 例如:通知Vue组件状态已恢复
|
||||
app.config.globalProperties.$pwaStateRestored = true
|
||||
})
|
||||
|
||||
// 监听应用即将卸载事件,保存状态
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (pwaStateController) {
|
||||
pwaStateController.saveCurrentState()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 注册全局组件
|
||||
initializeApp().then(() => {
|
||||
// 1. 注册 UI 框架
|
||||
app.use(vuetify)
|
||||
|
||||
// 2. 注册路由
|
||||
app.use(router)
|
||||
|
||||
// 3. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VDialogCloseBtn', DialogCloseBtn)
|
||||
.component('VScrollToTopBtn', ScrollToTopBtn)
|
||||
.component('VMediaCard', MediaCard)
|
||||
.component('VPosterCard', PosterCard)
|
||||
.component('VBackdropCard', BackdropCard)
|
||||
.component('VPersonCard', PersonCard)
|
||||
.component('VMediaInfoCard', MediaInfoCard)
|
||||
.component('VTorrentCard', TorrentCard)
|
||||
.component('VMediaIdSelector', MediaIdSelector)
|
||||
.component('VCronField', CronField)
|
||||
.component('VPathField', PathField)
|
||||
.component('VHeaderTab', HeaderTab)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 5. 注册其他插件
|
||||
app
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(Toast, {
|
||||
position: 'bottom-right',
|
||||
hideProgressBar: true,
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
})
|
||||
// 导出状态管理器供其他模块使用
|
||||
export { pwaStateController }
|
||||
|
||||
@@ -7,11 +7,13 @@ const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
|
||||
<template #button>
|
||||
<VBtn to="/" class="mt-10">
|
||||
{{ t('notFound.backButton') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
<div class="pt-10">
|
||||
<NoDataFound error-code="404" :error-title="t('notFound.title')" :error-description="t('notFound.description')">
|
||||
<template #button>
|
||||
<VBtn to="/" class="mt-10" prepend-icon="mdi-home">
|
||||
{{ t('notFound.backButton') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</NoDataFound>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,13 +9,15 @@ import { useDisplay } from 'vuetify'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { VCardActions } from 'vuetify/components'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 从用户 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
@@ -46,6 +48,7 @@ const enableConfig = ref<{ [key: string]: boolean }>({
|
||||
weeklyOverview: false,
|
||||
cpu: false,
|
||||
memory: false,
|
||||
network: false,
|
||||
library: true,
|
||||
playing: true,
|
||||
latest: true,
|
||||
@@ -112,6 +115,14 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
cols: { cols: 12, md: 6 },
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
name: t('dashboard.network'),
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12, md: 6 },
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
id: 'library',
|
||||
name: t('dashboard.library'),
|
||||
@@ -342,16 +353,18 @@ onDeactivated(() => {
|
||||
</draggable>
|
||||
|
||||
<!-- 底部操作按钮(只在非移动设备上显示) -->
|
||||
<VFab
|
||||
v-if="!appMode"
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<VFab
|
||||
v-if="!appMode"
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
<VDialog v-if="dialog" v-model="dialog" max-width="35rem" :fullscreen="!display.mdAndUp.value" scrollable>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DiscoverSource } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
@@ -119,6 +120,26 @@ async function saveTabOrder() {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页(在setup阶段,但使用computed保证响应性)
|
||||
registerHeaderTab({
|
||||
items: discoverTabItems, // 传递computed值,会自动响应变化
|
||||
modelValue: activeTab,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-order-alphabetical-ascending',
|
||||
variant: 'text',
|
||||
color: 'grey',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
orderConfigDialog.value = true
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
onBeforeMount(async () => {
|
||||
initDiscoverTabs()
|
||||
await loadOrderConfig()
|
||||
@@ -130,28 +151,25 @@ onBeforeMount(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
onActivated(async () => {
|
||||
await loadExtraDiscoverSources()
|
||||
sortSubscribeOrder()
|
||||
// 如果当前没有选中任何标签页,或者当前选中的标签页不存在,则选中第一个标签页
|
||||
if (!activeTab.value || !discoverTabs.value.find(tab => tab.mediaid_prefix === activeTab.value)) {
|
||||
if (discoverTabs.value.length > 0) {
|
||||
activeTab.value = discoverTabs.value[0].mediaid_prefix
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHeaderTab :items="discoverTabItems" v-model="activeTab">
|
||||
<template #append>
|
||||
<VBtn
|
||||
icon="mdi-order-alphabetical-ascending"
|
||||
variant="text"
|
||||
color="grey"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="orderConfigDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</VHeaderTab>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="themoviedb">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
|
||||
@@ -4,12 +4,13 @@ import { DownloaderConf } from '@/api/types'
|
||||
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const activeTab = ref(route.query.tab)
|
||||
const activeTab = ref<string>((route.query.tab as string) || '')
|
||||
|
||||
// 下载器
|
||||
const downloaders = ref<DownloaderConf[]>([])
|
||||
@@ -22,6 +23,9 @@ const downloaderItems = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
try {
|
||||
@@ -33,19 +37,30 @@ async function loadDownloaderSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态标签页
|
||||
const registerTabs = () => {
|
||||
if (downloaderItems.value.length > 0) {
|
||||
registerHeaderTab({
|
||||
items: downloaderItems,
|
||||
modelValue: activeTab,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDownloaderSetting()
|
||||
registerTabs()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadDownloaderSetting()
|
||||
await loadDownloaderSetting()
|
||||
registerTabs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="downloaders.length > 0">
|
||||
<VHeaderTab :items="downloaderItems" v-model="activeTab" />
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in downloaders" :value="item.name">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RecommendSource } from '@/api/types'
|
||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
const display = useDisplay()
|
||||
|
||||
@@ -13,6 +14,9 @@ const { t } = useI18n()
|
||||
// 当前选择的分类
|
||||
const currentCategory = ref(t('recommend.all'))
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
|
||||
{
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
@@ -165,7 +169,7 @@ async function saveConfig() {
|
||||
}
|
||||
|
||||
// 标签图标映射
|
||||
const categoryItems: Record<string, string>[] = [
|
||||
const categoryItems = computed(() => [
|
||||
{
|
||||
title: t('recommend.all'),
|
||||
icon: 'mdi-filmstrip-box-multiple',
|
||||
@@ -191,7 +195,24 @@ const categoryItems: Record<string, string>[] = [
|
||||
icon: 'mdi-trophy',
|
||||
tab: t('recommend.categoryRankings'),
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
// 注册动态标签页
|
||||
registerHeaderTab({
|
||||
items: categoryItems,
|
||||
modelValue: currentCategory,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-tune',
|
||||
variant: 'text',
|
||||
color: 'grey',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
dialog.value = true
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadConfig()
|
||||
@@ -202,26 +223,12 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
loadExtraRecommendSources()
|
||||
await loadExtraRecommendSources()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mp-recommend">
|
||||
<!-- 页面顶部控制栏 -->
|
||||
<VHeaderTab :items="categoryItems" v-model="currentCategory">
|
||||
<template #append>
|
||||
<VBtn
|
||||
icon="mdi-tune"
|
||||
variant="text"
|
||||
color="grey"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="dialog = true"
|
||||
/>
|
||||
</template>
|
||||
</VHeaderTab>
|
||||
|
||||
<!-- 滚动内容区域 -->
|
||||
<div class="recommend-content">
|
||||
<TransitionGroup name="fade">
|
||||
@@ -362,12 +369,6 @@ onActivated(async () => {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -399,37 +400,47 @@ onActivated(async () => {
|
||||
&.动漫::before {
|
||||
background-color: #ff9800;
|
||||
} // Orange
|
||||
&.榜单::before {
|
||||
&.排行榜::before {
|
||||
background-color: #9c27b0;
|
||||
} // Purple
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.15);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.6);
|
||||
&.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
|
||||
.setting-label {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-weight: 500;
|
||||
}
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-on-surface), 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-check {
|
||||
margin-inline-end: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Remove old tune button styles if they exist */
|
||||
.tune-button {
|
||||
display: none; // Hide the old button definitively
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.enabled .setting-label {
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -276,7 +276,9 @@ onUnmounted(() => {
|
||||
<!-- 无数据显示 -->
|
||||
<div v-else-if="isRefreshed && !isViewChanging" class="d-flex flex-column align-center justify-center py-8">
|
||||
<NoDataFound :errorTitle="errorTitle" :errorDescription="errorDescription" />
|
||||
<VBtn class="mt-4" color="primary" prepend-icon="mdi-magnify" to="/">{{ t('resource.backToHome') }}</VBtn>
|
||||
<VBtn rounded="pill" class="mt-4" color="primary" prepend-icon="mdi-home" to="/">
|
||||
{{ t('resource.backToHome') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<!-- 初始加载状态 -->
|
||||
|
||||
@@ -13,17 +13,34 @@ import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue
|
||||
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import AccountSettingCache from '@/views/setting/AccountSettingCache.vue'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const activeTab = ref(route.query.tab)
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const settingTabs = computed(() => getSettingTabs())
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页
|
||||
registerHeaderTab({
|
||||
items: settingTabs.value,
|
||||
modelValue: activeTab,
|
||||
})
|
||||
|
||||
// 注册动态标签页
|
||||
onMounted(() => {
|
||||
// 设置初始activeTab值
|
||||
if (!activeTab.value && settingTabs.value.length > 0) {
|
||||
activeTab.value = settingTabs.value[0].tab
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHeaderTab :items="settingTabs" v-model="activeTab" />
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<!-- 系统 -->
|
||||
<VWindowItem value="system">
|
||||
<transition name="fade-slide" appear>
|
||||
|
||||
@@ -4,6 +4,7 @@ import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
|
||||
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||
|
||||
@@ -14,7 +15,7 @@ const route = useRoute()
|
||||
|
||||
const subType = route.meta.subType?.toString()
|
||||
const subId = ref(route.query.id as string)
|
||||
const activeTab = ref(route.query.tab)
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const shareViewKey = ref(0)
|
||||
|
||||
// 获取标签页
|
||||
@@ -46,89 +47,66 @@ const searchShares = () => {
|
||||
searchShareDialog.value = false
|
||||
shareViewKey.value++
|
||||
}
|
||||
|
||||
// VMenu activator选择器
|
||||
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
|
||||
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页
|
||||
registerHeaderTab({
|
||||
items: subscribeTabs.value,
|
||||
modelValue: activeTab,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
color: computed(() => (subscribeFilter.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'filter-btn',
|
||||
action: () => {
|
||||
filterSubscribeDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-movie-search-outline',
|
||||
variant: 'text',
|
||||
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'search-btn',
|
||||
action: () => {
|
||||
searchShareDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-clipboard-edit-outline',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
subscribeEditDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 注册动态标签页
|
||||
onMounted(() => {
|
||||
// 设置初始activeTab值
|
||||
if (!activeTab.value && subscribeTabs.value.length > 0) {
|
||||
activeTab.value = subscribeTabs.value[0].tab
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHeaderTab :items="subscribeTabs" v-model="activeTab">
|
||||
<template #append>
|
||||
<VMenu
|
||||
v-if="activeTab === 'mysub'"
|
||||
v-model="filterSubscribeDialog"
|
||||
width="20rem"
|
||||
:close-on-content-click="false"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-filter-multiple-outline"
|
||||
variant="text"
|
||||
:color="subscribeFilter ? 'primary' : 'gray'"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('subscribe.filterSubscriptions') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<VMenu
|
||||
v-if="activeTab === 'share'"
|
||||
v-model="searchShareDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-movie-search-outline"
|
||||
variant="text"
|
||||
:color="shareKeyword ? 'primary' : 'gray'"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
|
||||
{{ t('subscribe.searchShares') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="searchShareDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
|
||||
<template #append>
|
||||
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<VBtn
|
||||
v-if="activeTab === 'mysub'"
|
||||
icon="mdi-clipboard-edit-outline"
|
||||
variant="text"
|
||||
color="gray"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="subscribeEditDialog = true"
|
||||
/>
|
||||
</template>
|
||||
</VHeaderTab>
|
||||
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
@@ -152,6 +130,58 @@ const searchShares = () => {
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<!-- 订阅过滤弹窗 -->
|
||||
<Teleport to="body" v-if="filterSubscribeDialog">
|
||||
<VMenu
|
||||
v-model="filterSubscribeDialog"
|
||||
width="20rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="filterActivator"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('subscribe.filterSubscriptions') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<!-- 搜索订阅分享弹窗 -->
|
||||
<Teleport to="body" v-if="searchShareDialog">
|
||||
<VMenu
|
||||
v-model="searchShareDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="searchActivator"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
|
||||
{{ t('subscribe.searchShares') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="searchShareDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
|
||||
<template #append>
|
||||
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
@@ -163,3 +193,9 @@ const searchShares = () => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-window {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -168,7 +168,7 @@ const theme: VuetifyOptions['theme'] = {
|
||||
'on-primary': '#FFFFFF',
|
||||
'on-success': '#FFFFFF',
|
||||
'on-warning': '#FFFFFF',
|
||||
'background': '#000000',
|
||||
'background': '#1C1C1C',
|
||||
'on-background': '#E7E3FC',
|
||||
'surface': 'rgba(30, 30, 30, 0.3)',
|
||||
'on-surface': '#E7E3FC',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { configureNProgress } from '@/api/nprogress'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { setNavigatingState as setRequestNavigatingState } from '@/utils/requestOptimizer'
|
||||
|
||||
// Nprogress
|
||||
configureNProgress()
|
||||
@@ -208,23 +209,11 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
const abortControllers = new Set<AbortController>()
|
||||
|
||||
// 注册中止控制器
|
||||
function registerAbortController(controller: AbortController) {
|
||||
abortControllers.add(controller)
|
||||
}
|
||||
|
||||
// 中止所有组件的任务
|
||||
function abortAllControllers() {
|
||||
for (const controller of abortControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
abortControllers.clear()
|
||||
}
|
||||
|
||||
// 路由导航守卫
|
||||
router.beforeEach(async (to: any, from: any, next: any) => {
|
||||
// 设置导航状态 - 同时中断API请求
|
||||
setRequestNavigatingState(true)
|
||||
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 总是记录非login路由
|
||||
@@ -233,15 +222,19 @@ router.beforeEach(async (to: any, from: any, next: any) => {
|
||||
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
// 用户未登录,重定向到登录页
|
||||
setRequestNavigatingState(false)
|
||||
next('/login')
|
||||
} else {
|
||||
// 清理所有中止控制器
|
||||
abortAllControllers()
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 路由导航完成后
|
||||
router.afterEach(() => {
|
||||
setTimeout(() => {
|
||||
setRequestNavigatingState(false)
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 导出默认对象
|
||||
export default router
|
||||
// 另行导出其他功能
|
||||
export { registerAbortController }
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { createHandlerBoundToURL, cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
// Service Worker 类型声明
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// self.__WB_MANIFEST is default injection point
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// to allow work offline
|
||||
registerRoute(new NavigationRoute(createHandlerBoundToURL('index.html'), { denylist: [/^(\/[\w-]+)*\/api/] }))
|
||||
|
||||
// 通知选项
|
||||
const options = {
|
||||
icon: '/logo.png',
|
||||
@@ -21,6 +13,10 @@ const options = {
|
||||
// 存储未读消息数量的键名
|
||||
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
||||
|
||||
// 状态管理相关的缓存名称和端点
|
||||
const STATE_CACHE_NAME = 'mp-pwa-state-cache'
|
||||
const STATE_ENDPOINT = '/api/pwa-state'
|
||||
|
||||
// 从IndexedDB获取未读消息数量
|
||||
async function getStoredUnreadCount(): Promise<number> {
|
||||
try {
|
||||
@@ -41,6 +37,52 @@ async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 保存PWA状态到缓存
|
||||
async function saveStateToCache(request: Request): Promise<Response> {
|
||||
try {
|
||||
const state = await request.json()
|
||||
const cache = await caches.open(STATE_CACHE_NAME)
|
||||
|
||||
await cache.put(STATE_ENDPOINT, new Response(JSON.stringify({
|
||||
...state,
|
||||
timestamp: Date.now()
|
||||
})))
|
||||
|
||||
return new Response(JSON.stringify({ success: true }))
|
||||
} catch (error) {
|
||||
console.error('Failed to save state to cache:', error)
|
||||
return new Response(JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 从缓存获取PWA状态
|
||||
async function getStateFromCache(): Promise<Response> {
|
||||
try {
|
||||
const cache = await caches.open(STATE_CACHE_NAME)
|
||||
const response = await cache.match(STATE_ENDPOINT)
|
||||
|
||||
if (response) {
|
||||
const state = await response.json()
|
||||
return new Response(JSON.stringify(state), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get state from cache:', error)
|
||||
return new Response(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的IndexedDB包装器
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -56,6 +98,7 @@ async function openDB(): Promise<IDBDatabase> {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取IndexedDB中的数据
|
||||
async function get(key: string): Promise<any> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -67,6 +110,7 @@ async function get(key: string): Promise<any> {
|
||||
})
|
||||
}
|
||||
|
||||
// 保存数据到IndexedDB
|
||||
async function set(key: string, value: any): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -105,9 +149,109 @@ async function clearBadge() {
|
||||
}
|
||||
}
|
||||
|
||||
// 安装事件
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 预缓存关键状态数据
|
||||
try {
|
||||
const cache = await caches.open(STATE_CACHE_NAME)
|
||||
const existingState = await cache.match(STATE_ENDPOINT)
|
||||
|
||||
if (existingState) {
|
||||
// 预热状态数据
|
||||
const state = await existingState.json()
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
|
||||
// 强制等待中的Service Worker立即成为活动的Service Worker
|
||||
self.skipWaiting()
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
// 激活事件
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 启用导航预载功能以提高性能
|
||||
if ('navigationPreload' in self.registration) {
|
||||
await self.registration.navigationPreload.enable()
|
||||
}
|
||||
|
||||
// 清理旧版本的缓存
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName.includes('old-') || cacheName.includes('deprecated-')) {
|
||||
return caches.delete(cacheName)
|
||||
}
|
||||
})
|
||||
)
|
||||
})(),
|
||||
)
|
||||
// 告诉活动的Service Worker立即控制页面
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// 处理API请求,当离线时发送消息到客户端
|
||||
self.addEventListener('fetch', event => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// 处理PWA状态管理请求
|
||||
if (url.pathname === STATE_ENDPOINT) {
|
||||
if (event.request.method === 'POST') {
|
||||
event.respondWith(saveStateToCache(event.request))
|
||||
} else if (event.request.method === 'GET') {
|
||||
event.respondWith(getStateFromCache())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.request.url.includes('/api/v1/') && 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('api-cache')
|
||||
const cachedResponse = await cache.match(event.request)
|
||||
if (cachedResponse) {
|
||||
return cachedResponse
|
||||
}
|
||||
|
||||
// 如果没有缓存,抛出错误
|
||||
throw error
|
||||
}
|
||||
})(),
|
||||
)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化 Workbox
|
||||
cleanupOutdatedCaches()
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// 监听 push 事件,显示通知
|
||||
self.addEventListener('push', function (event) {
|
||||
console.log('notification push')
|
||||
if (!event.data) {
|
||||
return
|
||||
}
|
||||
@@ -116,7 +260,6 @@ self.addEventListener('push', function (event) {
|
||||
try {
|
||||
payload = event.data?.json()
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
payload = {
|
||||
title: event.data?.text(),
|
||||
}
|
||||
@@ -140,26 +283,13 @@ self.addEventListener('push', function (event) {
|
||||
await Promise.all([self.registration.showNotification(payload.title, content), updateBadge(newCount)])
|
||||
})(),
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
// 安装
|
||||
self.addEventListener('install', function (e) {
|
||||
console.log('worker install')
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// 激活
|
||||
self.addEventListener('activate', function (e) {
|
||||
console.log('worker activate')
|
||||
e.waitUntil(self.clients.claim())
|
||||
} catch (e) {
|
||||
// 静默处理错误
|
||||
}
|
||||
})
|
||||
|
||||
// 监听通知点击事件
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
console.log('notification click')
|
||||
const info = event.notification
|
||||
if (event.action === 'close') {
|
||||
info.close()
|
||||
@@ -170,7 +300,6 @@ self.addEventListener('notificationclick', function (event) {
|
||||
|
||||
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
|
||||
self.addEventListener('message', function (event) {
|
||||
console.log('service worker received message:', event.data)
|
||||
|
||||
if (event.data && event.data.type === 'CLEAR_BADGE') {
|
||||
// 清除徽章
|
||||
@@ -179,8 +308,7 @@ self.addEventListener('message', function (event) {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to clear badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
|
||||
// 更新徽章数量
|
||||
@@ -191,8 +319,7 @@ self.addEventListener('message', function (event) {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to update badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
|
||||
// 获取未读消息数量
|
||||
@@ -201,8 +328,32 @@ self.addEventListener('message', function (event) {
|
||||
event.ports[0]?.postMessage({ count })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to get unread count:', error)
|
||||
event.ports[0]?.postMessage({ count: 0 })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'SAVE_PWA_STATE') {
|
||||
// 保存PWA状态
|
||||
const state = event.data.state || {}
|
||||
saveStateToCache(new Request(STATE_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state)
|
||||
}))
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
event.ports[0]?.postMessage({ success: result.success })
|
||||
})
|
||||
.catch(error => {
|
||||
event.ports[0]?.postMessage({ success: false, error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_PWA_STATE') {
|
||||
// 获取PWA状态
|
||||
getStateFromCache()
|
||||
.then(response => response.json())
|
||||
.then(state => {
|
||||
event.ports[0]?.postMessage({ state })
|
||||
})
|
||||
.catch(error => {
|
||||
event.ports[0]?.postMessage({ state: {} })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
50
src/stores/global.ts
Normal file
50
src/stores/global.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { globalSettingsState } from '@/stores/types'
|
||||
import { fetchGlobalSettings } from '@/utils/globalSetting'
|
||||
|
||||
export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||
state: (): globalSettingsState => ({
|
||||
data: {},
|
||||
initialized: false,
|
||||
loading: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async initialize() {
|
||||
if (this.initialized || this.loading) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const result = await fetchGlobalSettings()
|
||||
this.data = result || {}
|
||||
this.initialized = true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize global settings', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
setData(data: { [key: string]: any }) {
|
||||
this.data = data
|
||||
this.initialized = true
|
||||
},
|
||||
|
||||
get(key: string) {
|
||||
return this.data[key]
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.data = {}
|
||||
this.initialized = false
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
|
||||
getters: {
|
||||
isInitialized: state => state.initialized,
|
||||
isLoading: state => state.loading,
|
||||
getData: state => state.data,
|
||||
globalSettings: state => state.data,
|
||||
},
|
||||
})
|
||||
@@ -12,5 +12,6 @@ export default pinia
|
||||
// 所有的 store
|
||||
import { useAuthStore } from './auth'
|
||||
import { useUserStore } from './user'
|
||||
import { useGlobalSettingsStore } from './global'
|
||||
|
||||
export { useAuthStore, useUserStore }
|
||||
export { useAuthStore, useUserStore, useGlobalSettingsStore }
|
||||
|
||||
@@ -21,3 +21,12 @@ export interface userState {
|
||||
// 权限
|
||||
permissions: { [key: string]: any }
|
||||
}
|
||||
|
||||
export interface globalSettingsState {
|
||||
// 全局设置数据
|
||||
data: { [key: string]: any }
|
||||
// 是否已初始化
|
||||
initialized: boolean
|
||||
// 是否正在加载
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ html.v-overlay-scroll-blocked {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
/* 防止Chrome移动端下拉刷新干扰 */
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@media (width <= 768px){
|
||||
html.v-overlay-scroll-blocked {
|
||||
position: relative;
|
||||
@@ -53,6 +58,18 @@ html.v-overlay-scroll-blocked {
|
||||
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
@media only screen and (width <= 600px){
|
||||
.Vue-Toastification__container {
|
||||
inline-size: 100vw;
|
||||
padding-block: 4.5rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.Vue-Toastification__toast {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.v-dialog > .v-overlay__content > .v-card > .v-card-item {
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -344,7 +361,11 @@ html.v-overlay-scroll-blocked {
|
||||
// 表格
|
||||
.v-table {
|
||||
border-radius: 0;
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
background-color: rgba(var(--v-theme-surface), 0);
|
||||
|
||||
.v-table__wrapper > table > thead {
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚
|
||||
@@ -384,11 +405,17 @@ html.v-overlay-scroll-blocked {
|
||||
.v-skeleton-loader {
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
}
|
||||
|
||||
// 输入框和搜索框
|
||||
.v-field {
|
||||
background-color: rgba(var(--v-theme-surface), 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 透明主题下的弹出窗口样式
|
||||
html[data-theme="transparent"] {
|
||||
.v-overlay__content {
|
||||
.v-overlay__content {
|
||||
border-radius: 12px !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
|
||||
@@ -402,8 +429,8 @@ html[data-theme="transparent"] {
|
||||
background-color: rgb(var(--v-theme-surface), 0.5) !important;
|
||||
}
|
||||
|
||||
.v-table thead {
|
||||
background-color: rgb(var(--v-theme-surface), 0.5) !important;
|
||||
.v-table__wrapper table thead {
|
||||
background-color: rgba(var(--v-theme-surface), 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
src/types/pwa.d.ts
vendored
Normal file
26
src/types/pwa.d.ts
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* PWA相关的类型声明
|
||||
*/
|
||||
|
||||
// 扩展Window接口
|
||||
declare global {
|
||||
interface Window {
|
||||
pwaStateController?: import('@/utils/pwaStateManager').PWAStateController
|
||||
orientation?: number
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
standalone?: boolean
|
||||
setAppBadge?: (count: number) => Promise<void>
|
||||
clearAppBadge?: () => Promise<void>
|
||||
}
|
||||
|
||||
// 自定义事件类型
|
||||
interface WindowEventMap {
|
||||
'pwaStateRestored': CustomEvent<{
|
||||
state: import('@/utils/pwaStateManager').PWAState
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
// @ts-ignore
|
||||
} from 'virtual:__federation__'
|
||||
|
||||
// 创建一个专用的AbortController,用于federationLoader请求
|
||||
const federationController = new AbortController()
|
||||
|
||||
// 定义远程模块接口
|
||||
interface RemoteModule {
|
||||
id: string
|
||||
@@ -62,7 +65,9 @@ export async function loadRemoteComponent(id: string, componentName: string = 'P
|
||||
*/
|
||||
async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
||||
try {
|
||||
const response = await api.get('plugin/remotes?token=moviepilot')
|
||||
const response = await api.get('plugin/remotes?token=moviepilot', {
|
||||
signal: federationController.signal,
|
||||
})
|
||||
return (response as any) || []
|
||||
} catch (error) {
|
||||
console.error('获取远程模块列表失败:', error)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import api from '@/api'
|
||||
|
||||
// 创建一个专用的AbortController,用于globalSetting请求
|
||||
const globalSettingController = new AbortController()
|
||||
|
||||
export async function fetchGlobalSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/global', {
|
||||
params: {
|
||||
token: 'moviepilot',
|
||||
},
|
||||
// 手动设置signal,防止reqestOptimizer添加可中断的controller
|
||||
signal: globalSettingController.signal,
|
||||
})
|
||||
return result.data || {}
|
||||
} catch (error) {
|
||||
|
||||
105
src/utils/loadingStateManager.ts
Normal file
105
src/utils/loadingStateManager.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* PWA加载状态管理器
|
||||
* 用于协调不同组件的加载状态,确保所有关键资源加载完成后再显示界面
|
||||
*/
|
||||
export class PWALoadingStateManager {
|
||||
private loadingStates: Map<string, boolean> = new Map()
|
||||
private listeners: Set<(isLoading: boolean) => void> = new Set()
|
||||
|
||||
/**
|
||||
* 设置加载状态
|
||||
* @param key 状态键名
|
||||
* @param loading 是否正在加载
|
||||
*/
|
||||
setLoadingState(key: string, loading: boolean): void {
|
||||
const wasLoading = this.isAnyLoading()
|
||||
this.loadingStates.set(key, loading)
|
||||
const isLoading = this.isAnyLoading()
|
||||
|
||||
// 如果总体加载状态发生变化,通知监听器
|
||||
if (wasLoading !== isLoading) {
|
||||
this.notifyListeners(isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有任何组件正在加载
|
||||
*/
|
||||
isAnyLoading(): boolean {
|
||||
return Array.from(this.loadingStates.values()).some(loading => loading)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待所有加载完成
|
||||
*/
|
||||
waitForAllComplete(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.isAnyLoading()) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const checkComplete = () => {
|
||||
if (!this.isAnyLoading()) {
|
||||
resolve()
|
||||
} else {
|
||||
// 检查间隔
|
||||
setTimeout(checkComplete, 50)
|
||||
}
|
||||
}
|
||||
checkComplete()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态变化监听器
|
||||
* @param listener 监听器函数
|
||||
*/
|
||||
addListener(listener: (isLoading: boolean) => void): void {
|
||||
this.listeners.add(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除状态变化监听器
|
||||
* @param listener 监听器函数
|
||||
*/
|
||||
removeListener(listener: (isLoading: boolean) => void): void {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器
|
||||
* @param isLoading 是否正在加载
|
||||
*/
|
||||
private notifyListeners(isLoading: boolean): void {
|
||||
this.listeners.forEach(listener => {
|
||||
try {
|
||||
listener(isLoading)
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前加载状态详情
|
||||
*/
|
||||
getLoadingStates(): Record<string, boolean> {
|
||||
return Object.fromEntries(this.loadingStates)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有加载状态
|
||||
*/
|
||||
reset(): void {
|
||||
const wasLoading = this.isAnyLoading()
|
||||
this.loadingStates.clear()
|
||||
|
||||
if (wasLoading) {
|
||||
this.notifyListeners(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
export const globalLoadingStateManager = new PWALoadingStateManager()
|
||||
703
src/utils/pwaStateManager.ts
Normal file
703
src/utils/pwaStateManager.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* PWA状态管理器
|
||||
* 用于在iOS设备上防止后台被杀时丢失状态,提供状态恢复功能
|
||||
*/
|
||||
|
||||
// 应用状态接口
|
||||
export interface PWAState {
|
||||
url: string
|
||||
scrollPosition: number
|
||||
orientation: number
|
||||
timestamp: number
|
||||
appData?: any
|
||||
formData?: Record<string, any>
|
||||
userSelections?: {
|
||||
selectedItems: string[]
|
||||
activeTab?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 当前上下文接口
|
||||
export interface PWAContext {
|
||||
url: string
|
||||
orientation: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础状态管理器(使用localStorage和sessionStorage)
|
||||
*/
|
||||
export class PWAStateManager {
|
||||
private storageKey = 'mp-pwa-app-state'
|
||||
private sessionKey = 'mp-pwa-session-state'
|
||||
|
||||
// 保存应用状态
|
||||
saveState(state: PWAState): void {
|
||||
try {
|
||||
// 主要状态存储到localStorage
|
||||
localStorage.setItem(this.storageKey, JSON.stringify({
|
||||
...state,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
|
||||
// 临时状态存储到sessionStorage
|
||||
sessionStorage.setItem(this.sessionKey, JSON.stringify({
|
||||
scrollPosition: state.scrollPosition,
|
||||
activeTab: state.appData?.activeTab,
|
||||
formData: state.formData
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('状态保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复应用状态
|
||||
restoreState(): PWAState | null {
|
||||
try {
|
||||
const savedState = localStorage.getItem(this.storageKey)
|
||||
const sessionState = sessionStorage.getItem(this.sessionKey)
|
||||
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState)
|
||||
const sessionData = sessionState ? JSON.parse(sessionState) : {}
|
||||
|
||||
return {
|
||||
...state,
|
||||
...sessionData,
|
||||
isRestored: true
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('状态恢复失败:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 清除过期状态
|
||||
clearExpiredState(maxAge = 24 * 60 * 60 * 1000): void { // 24小时
|
||||
try {
|
||||
const savedState = localStorage.getItem(this.storageKey)
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState)
|
||||
if (Date.now() - state.timestamp > maxAge) {
|
||||
localStorage.removeItem(this.storageKey)
|
||||
sessionStorage.removeItem(this.sessionKey)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清除过期状态失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IndexedDB状态管理器
|
||||
*/
|
||||
export class PWAIndexedDBManager {
|
||||
private dbName = 'MPPWAStateDB'
|
||||
private dbVersion = 1
|
||||
private storeName = 'appState'
|
||||
|
||||
private async initDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName, { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async saveState(state: PWAState): Promise<void> {
|
||||
try {
|
||||
const db = await this.initDB()
|
||||
const transaction = db.transaction([this.storeName], 'readwrite')
|
||||
const store = transaction.objectStore(this.storeName)
|
||||
|
||||
await store.put({
|
||||
id: 'appState',
|
||||
data: state,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('IndexedDB保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async restoreState(): Promise<PWAState | null> {
|
||||
try {
|
||||
const db = await this.initDB()
|
||||
const transaction = db.transaction([this.storeName], 'readonly')
|
||||
const store = transaction.objectStore(this.storeName)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get('appState')
|
||||
request.onsuccess = () => {
|
||||
const result = request.result
|
||||
resolve(result ? result.data : null)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('IndexedDB恢复失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service Worker状态同步
|
||||
*/
|
||||
export class ServiceWorkerStateSync {
|
||||
private stateEndpoint = '/api/pwa-state'
|
||||
|
||||
async saveState(state: PWAState): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(this.stateEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(state)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result.success
|
||||
} catch (error) {
|
||||
console.error('Service Worker状态保存失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async loadState(): Promise<PWAState | null> {
|
||||
try {
|
||||
const response = await fetch(this.stateEndpoint)
|
||||
const state = await response.json()
|
||||
return Object.keys(state).length > 0 ? state : null
|
||||
} catch (error) {
|
||||
console.error('Service Worker状态加载失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 使用MessageChannel与Service Worker通信
|
||||
async saveStateViaMessage(state: PWAState): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data.success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'SAVE_PWA_STATE',
|
||||
state
|
||||
}, [channel.port2])
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async loadStateViaMessage(): Promise<PWAState | null> {
|
||||
return new Promise((resolve) => {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const channel = new MessageChannel()
|
||||
channel.port1.onmessage = (event) => {
|
||||
resolve(event.data.state || null)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'GET_PWA_STATE'
|
||||
}, [channel.port2])
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态恢复决策器
|
||||
*/
|
||||
export class StateRestoreDecision {
|
||||
private maxStateAge = 60 * 60 * 1000 // 60分钟,延长有效期
|
||||
|
||||
shouldRestoreState(savedState: PWAState | null, currentContext: PWAContext): boolean {
|
||||
if (!savedState) return false
|
||||
|
||||
// 检查状态年龄 - 更宽松的过期检查
|
||||
if (this.isStateExpired(savedState)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// URL匹配检查 - 更宽松的匹配策略
|
||||
if (!this.isUrlCompatible(savedState.url, currentContext.url)) {
|
||||
// 即使URL不匹配,也可以恢复一些基础状态(如滚动位置除外)
|
||||
return true
|
||||
}
|
||||
|
||||
// 设备方向变化不阻止状态恢复
|
||||
if (this.isOrientationChanged(savedState, currentContext)) {
|
||||
// 继续恢复
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private isStateExpired(savedState: PWAState): boolean {
|
||||
return Date.now() - savedState.timestamp > this.maxStateAge
|
||||
}
|
||||
|
||||
private isUrlCompatible(savedUrl: string, currentUrl: string): boolean {
|
||||
if (!savedUrl || !currentUrl) return false
|
||||
|
||||
try {
|
||||
const savedPath = new URL(savedUrl).pathname
|
||||
const currentPath = new URL(currentUrl).pathname
|
||||
return savedPath === currentPath
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private isOrientationChanged(savedState: PWAState, currentContext: PWAContext): boolean {
|
||||
return savedState.orientation !== currentContext.orientation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面可见性状态管理器
|
||||
*/
|
||||
export class VisibilityStateManager {
|
||||
private stateManager: PWAStateManager
|
||||
private blurTimer: number | null = null
|
||||
private isRestoring = false
|
||||
private restorePromise: Promise<void> | null = null
|
||||
|
||||
constructor(stateManager: PWAStateManager) {
|
||||
this.stateManager = stateManager
|
||||
this.setupVisibilityListener()
|
||||
}
|
||||
|
||||
private setupVisibilityListener(): void {
|
||||
// 监听页面可见性变化
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handlePageHidden()
|
||||
} else {
|
||||
this.handlePageVisible()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听页面卸载
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.handlePageUnload()
|
||||
})
|
||||
|
||||
// 监听页面焦点变化
|
||||
window.addEventListener('blur', () => {
|
||||
this.handlePageBlur()
|
||||
})
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
this.handlePageFocus()
|
||||
})
|
||||
}
|
||||
|
||||
private handlePageHidden(): void {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
}
|
||||
|
||||
private handlePageVisible(): void {
|
||||
if (this.isRestoring) return
|
||||
|
||||
this.isRestoring = true
|
||||
this.restorePromise = this.performStateRestore()
|
||||
}
|
||||
|
||||
private async performStateRestore(): Promise<void> {
|
||||
try {
|
||||
const restoredState = this.stateManager.restoreState()
|
||||
if (restoredState) {
|
||||
await this.restoreAppState(restoredState)
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
} finally {
|
||||
this.isRestoring = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
private handlePageUnload(): void {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
}
|
||||
|
||||
private handlePageBlur(): void {
|
||||
if (this.blurTimer) clearTimeout(this.blurTimer)
|
||||
this.blurTimer = window.setTimeout(() => {
|
||||
const currentState = this.getCurrentAppState()
|
||||
this.stateManager.saveState(currentState)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private handlePageFocus(): void {
|
||||
if (this.blurTimer) {
|
||||
clearTimeout(this.blurTimer)
|
||||
this.blurTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentAppState(): PWAState {
|
||||
return {
|
||||
url: window.location.href,
|
||||
scrollPosition: window.scrollY,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now(),
|
||||
appData: this.getAppSpecificState()
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreAppState(state: PWAState): Promise<void> {
|
||||
// 立即恢复状态,无需延迟
|
||||
if (state.scrollPosition) {
|
||||
window.scrollTo(0, state.scrollPosition)
|
||||
}
|
||||
if (state.appData) {
|
||||
this.restoreAppSpecificState(state.appData)
|
||||
}
|
||||
|
||||
// 触发状态恢复完成事件
|
||||
window.dispatchEvent(new CustomEvent('pwaStateRestored', {
|
||||
detail: { state }
|
||||
}))
|
||||
}
|
||||
|
||||
private getAppSpecificState(): any {
|
||||
// 获取应用特定状态
|
||||
return {
|
||||
formData: this.getFormData(),
|
||||
userSelections: this.getUserSelections()
|
||||
}
|
||||
}
|
||||
|
||||
private restoreAppSpecificState(appData: any): void {
|
||||
if (appData.formData) {
|
||||
this.restoreFormData(appData.formData)
|
||||
}
|
||||
if (appData.userSelections) {
|
||||
this.restoreUserSelections(appData.userSelections)
|
||||
}
|
||||
}
|
||||
|
||||
private getFormData(): Record<string, any> {
|
||||
const forms = document.querySelectorAll('form')
|
||||
const formData: Record<string, any> = {}
|
||||
|
||||
forms.forEach((form, index) => {
|
||||
const data = new FormData(form)
|
||||
formData[`form-${index}`] = Object.fromEntries(data)
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
private restoreFormData(formData: Record<string, any>): void {
|
||||
Object.entries(formData).forEach(([formId, data]) => {
|
||||
const formIndex = parseInt(formId.split('-')[1])
|
||||
const form = document.querySelectorAll('form')[formIndex]
|
||||
|
||||
if (form) {
|
||||
Object.entries(data).forEach(([name, value]) => {
|
||||
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
||||
if (input) {
|
||||
input.value = value as string
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getUserSelections(): any {
|
||||
return {
|
||||
selectedItems: Array.from(document.querySelectorAll('.selected')).map(el => el.id),
|
||||
activeTab: document.querySelector('.tab.active')?.id
|
||||
}
|
||||
}
|
||||
|
||||
private restoreUserSelections(selections: any): void {
|
||||
if (selections.selectedItems) {
|
||||
selections.selectedItems.forEach((id: string) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
element.classList.add('selected')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (selections.activeTab) {
|
||||
const tab = document.getElementById(selections.activeTab)
|
||||
if (tab) {
|
||||
tab.classList.add('active')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完整的PWA状态管理器
|
||||
*/
|
||||
export class PWAStateController {
|
||||
private stateManager: PWAStateManager
|
||||
private indexedDBManager: PWAIndexedDBManager
|
||||
private swStateSync: ServiceWorkerStateSync
|
||||
private visibilityManager: VisibilityStateManager
|
||||
private restoreDecision: StateRestoreDecision
|
||||
private stateRestorePromise: Promise<void> | null = null
|
||||
private stateRestoreResolve: (() => void) | null = null
|
||||
private isRestoring = false
|
||||
|
||||
constructor() {
|
||||
this.stateManager = new PWAStateManager()
|
||||
this.indexedDBManager = new PWAIndexedDBManager()
|
||||
this.swStateSync = new ServiceWorkerStateSync()
|
||||
this.visibilityManager = new VisibilityStateManager(this.stateManager)
|
||||
this.restoreDecision = new StateRestoreDecision()
|
||||
|
||||
// 创建状态恢复Promise
|
||||
this.stateRestorePromise = new Promise((resolve) => {
|
||||
this.stateRestoreResolve = resolve
|
||||
})
|
||||
|
||||
this.init()
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待状态恢复完成
|
||||
*/
|
||||
async waitForStateRestore(): Promise<void> {
|
||||
return this.stateRestorePromise || Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前是否正在恢复状态
|
||||
*/
|
||||
get isRestoringState(): boolean {
|
||||
return this.isRestoring
|
||||
}
|
||||
|
||||
private async init(): Promise<void> {
|
||||
// 清理过期状态
|
||||
this.stateManager.clearExpiredState()
|
||||
|
||||
// 检查是否需要恢复状态
|
||||
await this.checkAndRestoreState()
|
||||
|
||||
// 设置定期保存
|
||||
this.setupPeriodicSave()
|
||||
}
|
||||
|
||||
private async checkAndRestoreState(): Promise<void> {
|
||||
this.isRestoring = true
|
||||
|
||||
try {
|
||||
const currentContext: PWAContext = {
|
||||
url: window.location.href,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
// 尝试从多个来源恢复状态
|
||||
const sources = [
|
||||
() => this.stateManager.restoreState(),
|
||||
() => this.indexedDBManager.restoreState(),
|
||||
() => this.swStateSync.loadState(),
|
||||
() => this.swStateSync.loadStateViaMessage()
|
||||
]
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const savedState = await source()
|
||||
if (this.restoreDecision.shouldRestoreState(savedState, currentContext)) {
|
||||
await this.restoreState(savedState!)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.isRestoring = false
|
||||
// 状态恢复完成(无论成功还是失败)
|
||||
if (this.stateRestoreResolve) {
|
||||
this.stateRestoreResolve()
|
||||
this.stateRestoreResolve = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveCurrentState(): Promise<void> {
|
||||
const state: PWAState = {
|
||||
url: window.location.href,
|
||||
scrollPosition: window.scrollY,
|
||||
orientation: window.orientation || 0,
|
||||
timestamp: Date.now(),
|
||||
appData: this.getAppSpecificState()
|
||||
}
|
||||
|
||||
// 多重保存策略
|
||||
await Promise.allSettled([
|
||||
this.stateManager.saveState(state),
|
||||
this.indexedDBManager.saveState(state),
|
||||
this.swStateSync.saveState(state),
|
||||
this.swStateSync.saveStateViaMessage(state)
|
||||
])
|
||||
}
|
||||
|
||||
private async restoreState(state: PWAState): Promise<void> {
|
||||
const currentUrl = window.location.href
|
||||
const urlMatches = this.isUrlExactMatch(state.url, currentUrl)
|
||||
|
||||
// 只有在URL完全匹配时才恢复滚动位置
|
||||
if (state.scrollPosition && urlMatches) {
|
||||
window.scrollTo({
|
||||
top: state.scrollPosition,
|
||||
behavior: 'auto'
|
||||
})
|
||||
}
|
||||
|
||||
// 恢复应用特定状态 - 过滤掉不适用的状态
|
||||
if (state.appData) {
|
||||
this.restoreAppSpecificState(state.appData, urlMatches)
|
||||
}
|
||||
|
||||
// 触发状态恢复事件
|
||||
this.dispatchStateRestoreEvent(state)
|
||||
}
|
||||
|
||||
private isUrlExactMatch(savedUrl: string, currentUrl: string): boolean {
|
||||
try {
|
||||
const saved = new URL(savedUrl)
|
||||
const current = new URL(currentUrl)
|
||||
return saved.pathname === current.pathname
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private setupPeriodicSave(): void {
|
||||
// 每30秒保存一次状态
|
||||
setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
this.saveCurrentState()
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private getAppSpecificState(): any {
|
||||
// 可以在这里添加MoviePilot特定的状态
|
||||
return {
|
||||
// 路由状态
|
||||
routerState: this.getRouterState(),
|
||||
// 用户界面状态
|
||||
uiState: this.getUIState(),
|
||||
// 表单状态
|
||||
formState: this.getFormState()
|
||||
}
|
||||
}
|
||||
|
||||
private getRouterState(): any {
|
||||
// 获取Vue Router状态
|
||||
return {
|
||||
currentRoute: window.location.pathname,
|
||||
query: window.location.search,
|
||||
hash: window.location.hash
|
||||
}
|
||||
}
|
||||
|
||||
private getUIState(): any {
|
||||
// 获取UI状态
|
||||
return {
|
||||
sidebarOpen: document.querySelector('.v-navigation-drawer--active') !== null,
|
||||
darkMode: document.documentElement.classList.contains('dark') ||
|
||||
document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
}
|
||||
}
|
||||
|
||||
private getFormState(): any {
|
||||
// 获取表单状态
|
||||
const forms = document.querySelectorAll('form')
|
||||
const formData: Record<string, any> = {}
|
||||
|
||||
forms.forEach((form, index) => {
|
||||
const inputs = form.querySelectorAll('input, select, textarea')
|
||||
const data: Record<string, any> = {}
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const element = input as HTMLInputElement
|
||||
if (element.name) {
|
||||
data[element.name] = element.value
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
formData[`form-${index}`] = data
|
||||
}
|
||||
})
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
private restoreAppSpecificState(appData: any, urlMatches: boolean = true): void {
|
||||
// 总是恢复UI状态(如主题等)
|
||||
if (appData.uiState) {
|
||||
this.restoreUIState(appData.uiState)
|
||||
}
|
||||
|
||||
// 只有在URL匹配时才恢复表单状态
|
||||
if (appData.formState && urlMatches) {
|
||||
this.restoreFormState(appData.formState)
|
||||
}
|
||||
}
|
||||
|
||||
private restoreUIState(uiState: any): void {
|
||||
// 恢复UI状态
|
||||
if (uiState.darkMode !== undefined) {
|
||||
// 这里可以根据实际的主题切换逻辑来恢复
|
||||
}
|
||||
}
|
||||
|
||||
private restoreFormState(formState: any): void {
|
||||
// 恢复表单状态
|
||||
Object.entries(formState).forEach(([formId, data]) => {
|
||||
const formIndex = parseInt(formId.split('-')[1])
|
||||
const form = document.querySelectorAll('form')[formIndex]
|
||||
|
||||
if (form) {
|
||||
Object.entries(data as Record<string, any>).forEach(([name, value]) => {
|
||||
const input = form.querySelector(`[name="${name}"]`) as HTMLInputElement
|
||||
if (input) {
|
||||
input.value = value as string
|
||||
// 触发change事件,以便Vue能够响应
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private dispatchStateRestoreEvent(state: PWAState): void {
|
||||
const event = new CustomEvent('pwaStateRestored', {
|
||||
detail: { state }
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
98
src/utils/requestOptimizer.ts
Normal file
98
src/utils/requestOptimizer.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// 全局请求优化器
|
||||
// 自动管理所有API请求的中断,无需手动注册
|
||||
|
||||
let isNavigating = false
|
||||
const activeRequests = new Set<AbortController>()
|
||||
|
||||
// 监听路由状态
|
||||
export function setNavigatingState(navigating: boolean) {
|
||||
isNavigating = navigating
|
||||
|
||||
if (navigating) {
|
||||
// 路由切换时,中断所有未完成的请求
|
||||
console.log('Navigation started - aborting active requests')
|
||||
abortAllActiveRequests()
|
||||
}
|
||||
}
|
||||
|
||||
// 中断所有活跃的请求
|
||||
function abortAllActiveRequests() {
|
||||
for (const controller of activeRequests) {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
activeRequests.clear()
|
||||
}
|
||||
|
||||
// 清理已完成的请求控制器
|
||||
function cleanupController(controller: AbortController) {
|
||||
activeRequests.delete(controller)
|
||||
}
|
||||
|
||||
// 初始化请求优化器
|
||||
export function initializeRequestOptimizer(axiosInstance: any) {
|
||||
// 拦截请求,自动添加 AbortController
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: any) => {
|
||||
// 如果请求已经有 signal,跳过(避免覆盖手动设置的)
|
||||
if (config.signal) {
|
||||
return config
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
const controller = new AbortController()
|
||||
config.signal = controller.signal
|
||||
|
||||
// 将控制器添加到活跃列表
|
||||
activeRequests.add(controller)
|
||||
|
||||
// 监听请求完成事件来清理控制器
|
||||
const cleanup = () => cleanupController(controller)
|
||||
|
||||
// 监听中断事件
|
||||
controller.signal.addEventListener('abort', cleanup, { once: true })
|
||||
|
||||
return config
|
||||
},
|
||||
(error: any) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 拦截响应,清理对应的控制器
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response: any) => {
|
||||
// 从配置中获取 signal 对应的控制器并清理
|
||||
if (response.config?.signal) {
|
||||
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === response.config.signal)
|
||||
if (controller) {
|
||||
cleanupController(controller)
|
||||
}
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: any) => {
|
||||
// 错误时也要清理控制器
|
||||
if (error.config?.signal) {
|
||||
const controller = Array.from(activeRequests).find(ctrl => ctrl.signal === error.config.signal)
|
||||
if (controller) {
|
||||
cleanupController(controller)
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
console.log('Request optimizer initialized - all requests will be auto-managed')
|
||||
}
|
||||
|
||||
// 获取当前活跃请求数量(调试用)
|
||||
export function getActiveRequestsCount() {
|
||||
return activeRequests.size
|
||||
}
|
||||
|
||||
// 手动中断所有请求(备用方法)
|
||||
export function abortAllRequests() {
|
||||
abortAllActiveRequests()
|
||||
}
|
||||
@@ -112,6 +112,8 @@ async function getCpuUsage() {
|
||||
try {
|
||||
// 请求数据
|
||||
current.value = (await api.get('dashboard/cpu')) ?? 0
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
// 添加到序列
|
||||
series.value[0].data.push(current.value)
|
||||
// 序列超过30条记录时,清掉前面的
|
||||
@@ -122,10 +124,13 @@ async function getCpuUsage() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getCpuUsage() // 启动定时器
|
||||
refreshTimer = setInterval(() => {
|
||||
// 延迟启动,确保组件完全挂载
|
||||
nextTick(() => {
|
||||
getCpuUsage()
|
||||
}, 2000)
|
||||
refreshTimer = setInterval(() => {
|
||||
getCpuUsage()
|
||||
}, 2000)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时停止定时器
|
||||
@@ -137,7 +142,9 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
chartKey.value += 1
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -118,6 +118,8 @@ async function getMemorgUsage() {
|
||||
try {
|
||||
// 请求数据
|
||||
;[usedMemory.value, memoryUsage.value] = await api.get('dashboard/memory')
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
series.value[0].data.push(memoryUsage.value)
|
||||
// 序列超过30条记录时,清掉前面的
|
||||
if (series.value[0].data.length > 30) series.value[0].data.shift()
|
||||
@@ -127,11 +129,14 @@ async function getMemorgUsage() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getMemorgUsage()
|
||||
// 启动定时器
|
||||
refreshTimer = setInterval(() => {
|
||||
// 延迟启动,确保组件完全挂载
|
||||
nextTick(() => {
|
||||
getMemorgUsage()
|
||||
}, 3000)
|
||||
// 启动定时器
|
||||
refreshTimer = setInterval(() => {
|
||||
getMemorgUsage()
|
||||
}, 3000)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时停止定时器
|
||||
@@ -143,7 +148,10 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
chartKey.value += 1
|
||||
// 使用nextTick确保DOM准备完成后再更新chartKey
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
215
src/views/dashboard/AnalyticsNetwork.vue
Normal file
215
src/views/dashboard/AnalyticsNetwork.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from 'vuetify'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
// 是否允许刷新数据
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
|
||||
const currentTheme = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => vuetifyTheme.current.value.colors,
|
||||
)
|
||||
const variableTheme = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => vuetifyTheme.current.value.variables,
|
||||
)
|
||||
|
||||
const chartKey = ref(0)
|
||||
|
||||
// 定时器
|
||||
let refreshTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 时间序列 - 上行和下行流量
|
||||
const series = ref([
|
||||
{
|
||||
name: '上行流量',
|
||||
data: [0],
|
||||
},
|
||||
{
|
||||
name: '下行流量',
|
||||
data: [0],
|
||||
},
|
||||
])
|
||||
|
||||
// 当前值
|
||||
const currentUpload = ref(0)
|
||||
const currentDownload = ref(0)
|
||||
|
||||
// 格式化流量显示
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B/s'
|
||||
const k = 1024
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const chartOptions = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => {
|
||||
return {
|
||||
chart: {
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: false },
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
},
|
||||
grid: {
|
||||
borderColor: `rgba(${hexToRgb(String(variableTheme.value['border-color']))},${
|
||||
variableTheme.value['border-opacity']
|
||||
})`,
|
||||
strokeDashArray: 6,
|
||||
xaxis: {
|
||||
lines: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
lines: { show: true },
|
||||
},
|
||||
padding: {
|
||||
top: -10,
|
||||
left: -7,
|
||||
right: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
},
|
||||
stroke: {
|
||||
width: 3,
|
||||
lineCap: 'butt',
|
||||
curve: 'smooth',
|
||||
},
|
||||
colors: [currentTheme.value.warning, currentTheme.value.info],
|
||||
markers: {
|
||||
size: 6,
|
||||
offsetY: 4,
|
||||
offsetX: -2,
|
||||
strokeWidth: 3,
|
||||
colors: ['transparent'],
|
||||
strokeColors: 'transparent',
|
||||
discrete: [
|
||||
{
|
||||
size: 5.5,
|
||||
seriesIndex: 0,
|
||||
strokeColor: currentTheme.value.warning,
|
||||
fillColor: currentTheme.value.surface,
|
||||
},
|
||||
{
|
||||
size: 5.5,
|
||||
seriesIndex: 1,
|
||||
strokeColor: currentTheme.value.info,
|
||||
fillColor: currentTheme.value.surface,
|
||||
},
|
||||
],
|
||||
hover: { size: 7 },
|
||||
},
|
||||
xaxis: {
|
||||
labels: { show: false },
|
||||
axisTicks: { show: false },
|
||||
axisBorder: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
labels: { show: false },
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
horizontalAlign: 'left',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'inherit',
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 调用API接口获取最新网络流量
|
||||
async function getNetworkUsage() {
|
||||
if (!props.allowRefresh) return
|
||||
try {
|
||||
// 请求数据 - 接口返回 [上行流量, 下行流量]
|
||||
const data: [number, number] = (await api.get('dashboard/network')) ?? [0, 0]
|
||||
currentUpload.value = data[0] || 0
|
||||
currentDownload.value = data[1] || 0
|
||||
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
|
||||
// 添加到序列
|
||||
series.value[0].data.push(currentUpload.value)
|
||||
series.value[1].data.push(currentDownload.value)
|
||||
|
||||
// 序列超过30条记录时,清掉前面的
|
||||
if (series.value[0].data.length > 30) {
|
||||
series.value[0].data.shift()
|
||||
series.value[1].data.shift()
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟启动,确保组件完全挂载
|
||||
nextTick(() => {
|
||||
getNetworkUsage()
|
||||
refreshTimer = setInterval(() => {
|
||||
getNetworkUsage()
|
||||
}, 2000)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时停止定时器
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
nextTick(() => {
|
||||
chartKey.value += 1
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard v-bind="hover.props">
|
||||
<VCardItem>
|
||||
<template #append>
|
||||
<VIcon class="cursor-move" v-if="hover.isHovering">mdi-drag</VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ t('dashboard.network') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VApexChart :key="chartKey" type="line" :options="chartOptions" :series="series" :height="150" />
|
||||
<div class="d-flex justify-space-between">
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<span class="text-warning">{{ t('dashboard.upload') }}</span
|
||||
>:{{ formatBytes(currentUpload) }}
|
||||
</p>
|
||||
<p class="text-center font-weight-medium mb-0">
|
||||
<span class="text-info">{{ t('dashboard.download') }}</span
|
||||
>:{{ formatBytes(currentDownload) }}
|
||||
</p>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -107,7 +107,8 @@ const totalCount = computed(() => series.value[0].data.reduce((a, b) => a + b, 0
|
||||
async function getWeeklyData() {
|
||||
try {
|
||||
const res: number[] = await api.get('dashboard/transfer')
|
||||
|
||||
// 使用nextTick确保DOM更新完成后再更新图表数据
|
||||
await nextTick()
|
||||
series.value = [{ data: res }]
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -115,11 +116,17 @@ async function getWeeklyData() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getWeeklyData()
|
||||
// 延迟启动,确保组件完全挂载
|
||||
nextTick(() => {
|
||||
getWeeklyData()
|
||||
})
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
getWeeklyData()
|
||||
// 使用nextTick确保DOM准备完成后再获取数据
|
||||
nextTick(() => {
|
||||
getWeeklyData()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import { registerAbortController } from '@/router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -28,10 +27,7 @@ const dataList = ref<MediaInfo[]>([])
|
||||
async function fetchData() {
|
||||
try {
|
||||
if (!props.apipath) return
|
||||
const abortController = new AbortController()
|
||||
registerAbortController(abortController)
|
||||
const { signal } = abortController
|
||||
dataList.value = await api.get(props.apipath, { signal })
|
||||
dataList.value = await api.get(props.apipath)
|
||||
if (dataList.value.length > 0) componentLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -15,6 +15,7 @@ import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -28,7 +29,9 @@ const mediaProps = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -5,6 +5,7 @@ import personIcon from '@images/misc/person.png'
|
||||
import type { Person } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -17,7 +18,9 @@ const personProps = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 媒体详情
|
||||
const personDetail = ref<Person>({} as Person)
|
||||
|
||||
@@ -13,6 +13,8 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -23,7 +25,8 @@ const route = useRoute()
|
||||
const display = useDisplay()
|
||||
|
||||
// APP
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 当前标签
|
||||
const activeTab = ref('installed')
|
||||
@@ -31,6 +34,81 @@ const activeTab = ref('installed')
|
||||
// 获取插件标签页
|
||||
const pluginTabs = computed(() => getPluginTabs())
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
// 注册动态标签页(在setup顶层立即执行)
|
||||
registerHeaderTab({
|
||||
items: pluginTabs.value,
|
||||
modelValue: activeTab,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
color: computed(() =>
|
||||
installedFilter.value || hasUpdateFilter.value || enabledFilter.value ? 'primary' : 'gray',
|
||||
),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'installed-filter-btn',
|
||||
action: () => {
|
||||
filterInstalledPluginDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
color: computed(() => (isFilterFormEmpty.value ? 'gray' : 'primary')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'market-filter-btn',
|
||||
action: () => {
|
||||
filterMarketPluginDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'market'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-refresh',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
refreshMarket()
|
||||
},
|
||||
show: computed(() => activeTab.value === 'market'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-store-cog',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
MarketSettingDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'market'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-folder-plus',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
showNewFolderDialog()
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed' && !currentFolder.value),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-arrow-left',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
backToMain()
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed' && !!currentFolder.value),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 插件ID参数
|
||||
const pluginId = ref(route.query.id)
|
||||
|
||||
@@ -603,7 +681,7 @@ function pluginIcon(item: Plugin) {
|
||||
if (pluginIconLoaded.value[item.id || '0'] === false) return noImage
|
||||
// 如果是网络图片则使用代理后返回
|
||||
if (item?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}`
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${item?.plugin_icon}`
|
||||
}
|
||||
@@ -796,6 +874,7 @@ function loadMarketMore({ done }: { done: any }) {
|
||||
}
|
||||
|
||||
// 组件挂载后
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPluginOrderConfig()
|
||||
await loadPluginFolders() // 加载文件夹配置
|
||||
@@ -1213,173 +1292,118 @@ function onDragStartPlugin(evt: any) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHeaderTab :items="pluginTabs" v-model="activeTab">
|
||||
<template #append>
|
||||
<VMenu
|
||||
v-if="activeTab === 'installed'"
|
||||
v-model="filterInstalledPluginDialog"
|
||||
width="20rem"
|
||||
:close-on-content-click="false"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-filter-multiple-outline"
|
||||
variant="text"
|
||||
:color="installedFilter || hasUpdateFilter || enabledFilter ? 'primary' : 'gray'"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('plugin.filterPlugins') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<!-- 过滤弹窗 -->
|
||||
<Teleport to="body" v-if="filterInstalledPluginDialog">
|
||||
<VMenu
|
||||
v-model="filterInstalledPluginDialog"
|
||||
width="20rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="'[data-menu-activator=installed-filter-btn]'"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('plugin.filterPlugins') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="installedFilter"
|
||||
:items="installedPluginNames"
|
||||
:label="t('plugin.name')"
|
||||
density="comfortable"
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body" v-if="filterMarketPluginDialog">
|
||||
<VMenu
|
||||
v-model="filterMarketPluginDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="'[data-menu-activator=market-filter-btn]'"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('plugin.filterPlugins') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<!-- 过滤表单 -->
|
||||
<div v-if="isAppMarketLoaded">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VCombobox
|
||||
v-model="installedFilter"
|
||||
:items="installedPluginNames"
|
||||
:label="t('plugin.name')"
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
|
||||
</VCol>
|
||||
<VCol v-if="authorFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.author"
|
||||
:items="authorFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.author')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
|
||||
<VCol v-if="labelFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.label"
|
||||
:items="labelFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.label')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
|
||||
<VCol v-if="repoFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.repo"
|
||||
:items="repoFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.repository')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="sortOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="activeSort"
|
||||
:items="sortOptions"
|
||||
density="comfortable"
|
||||
:label="t('plugin.sortTitle')"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<VMenu
|
||||
v-if="activeTab === 'market'"
|
||||
v-model="filterMarketPluginDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
scrim
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
icon="mdi-filter-multiple-outline"
|
||||
variant="text"
|
||||
:color="isFilterFormEmpty ? 'gray' : 'primary'"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
v-bind="props"
|
||||
/>
|
||||
</template>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('plugin.filterPlugins') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<!-- 过滤表单 -->
|
||||
<div v-if="isAppMarketLoaded">
|
||||
<VRow>
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
|
||||
</VCol>
|
||||
<VCol v-if="authorFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.author"
|
||||
:items="authorFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.author')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="labelFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.label"
|
||||
:items="labelFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.label')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="repoFilterOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="filterForm.repo"
|
||||
:items="repoFilterOptions"
|
||||
density="comfortable"
|
||||
chips
|
||||
:label="t('plugin.repository')"
|
||||
multiple
|
||||
clearable
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="sortOptions.length > 0" cols="6">
|
||||
<VSelect
|
||||
v-model="activeSort"
|
||||
:items="sortOptions"
|
||||
density="comfortable"
|
||||
:label="t('plugin.sortTitle')"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<VBtn
|
||||
v-if="activeTab === 'market'"
|
||||
icon="mdi-refresh"
|
||||
variant="text"
|
||||
color="gray"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
:loading="isMarketRefreshing"
|
||||
@click="refreshMarket"
|
||||
/>
|
||||
<VBtn
|
||||
v-if="activeTab === 'market'"
|
||||
icon="mdi-store-cog"
|
||||
variant="text"
|
||||
color="gray"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="MarketSettingDialog = true"
|
||||
/>
|
||||
<VBtn
|
||||
v-if="activeTab === 'installed' && !currentFolder"
|
||||
icon="mdi-folder-plus"
|
||||
variant="text"
|
||||
color="gray"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="showNewFolderDialog"
|
||||
/>
|
||||
<VBtn
|
||||
v-if="activeTab === 'installed' && currentFolder"
|
||||
icon="mdi-arrow-left"
|
||||
variant="text"
|
||||
color="gray"
|
||||
size="default"
|
||||
class="settings-icon-button"
|
||||
@click="backToMain"
|
||||
/>
|
||||
</template>
|
||||
</VHeaderTab>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<VWindow v-model="activeTab" class="mt-5 disable-tab-transition px-2" :touch="false">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition px-2" :touch="false">
|
||||
<!-- 我的插件 -->
|
||||
<VWindowItem value="installed">
|
||||
<transition name="fade-slide" appear>
|
||||
@@ -1504,18 +1528,20 @@ function onDragStartPlugin(evt: any) {
|
||||
|
||||
<div v-if="isRefreshed">
|
||||
<!-- 插件搜索图标 -->
|
||||
<VFab
|
||||
v-if="!appMode"
|
||||
icon="mdi-magnify"
|
||||
color="info"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="SearchDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<VFab
|
||||
v-if="!appMode"
|
||||
icon="mdi-magnify"
|
||||
color="info"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="SearchDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
<!-- 插件市场设置窗口 -->
|
||||
<PluginMarketSettingDialog
|
||||
@@ -1622,7 +1648,3 @@ function onDragStartPlugin(evt: any) {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 样式已移至 PluginMixedSortCard 组件
|
||||
</style>
|
||||
|
||||
@@ -11,13 +11,15 @@ import router from '@/router'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -213,7 +215,7 @@ const TransferDict: { [key: string]: string } = {
|
||||
}
|
||||
|
||||
const tableStyle = computed(() => {
|
||||
return appMode
|
||||
return appMode.value
|
||||
? 'height: calc(100vh - 15rem - env(safe-area-inset-bottom) - 7rem)'
|
||||
: 'height: calc(100vh - 15rem - env(safe-area-inset-bottom)'
|
||||
})
|
||||
@@ -698,29 +700,31 @@ onMounted(() => {
|
||||
</VCard>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div v-if="isRefreshed && selected.length > 0">
|
||||
<VFab
|
||||
icon="mdi-trash-can-outline"
|
||||
color="error"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="removeHistoryBatch"
|
||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||
/>
|
||||
<VFab
|
||||
:class="appMode ? 'mb-44' : 'mb-32'"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="isRefreshed && selected.length > 0">
|
||||
<VFab
|
||||
icon="mdi-trash-can-outline"
|
||||
color="error"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="removeHistoryBatch"
|
||||
:class="appMode ? 'mb-28' : 'mb-16'"
|
||||
/>
|
||||
<VFab
|
||||
:class="appMode ? 'mb-44' : 'mb-32'"
|
||||
icon="mdi-redo-variant"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- 底部弹窗 -->
|
||||
<VBottomSheet v-model="deleteConfirmDialog" inset>
|
||||
<VCard class="text-center">
|
||||
|
||||
@@ -3,18 +3,21 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { TorrentCacheData, TorrentCacheItem } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { formatFileSize, formatDateDifference } from '@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
@@ -9,6 +9,7 @@ import DirectoryCard from '@/components/cards/DirectoryCard.vue'
|
||||
import StorageCard from '@/components/cards/StorageCard.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -33,6 +34,17 @@ const sourceItems = [
|
||||
{ 'title': '豆瓣', 'value': 'douban' },
|
||||
]
|
||||
|
||||
// 存储选项(排除已添加的)
|
||||
const storageOptions = computed(() => {
|
||||
const existingTypes = storages.value.map(storage => storage.type)
|
||||
return storageAttributes
|
||||
.filter(item => !existingTypes.includes(item.type))
|
||||
.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
// 系统设置
|
||||
const SystemSettings = ref<any>({
|
||||
Basic: {
|
||||
@@ -156,12 +168,32 @@ async function loadMediaCategories() {
|
||||
}
|
||||
|
||||
// 添加存储
|
||||
function addStorage() {
|
||||
function addStorage(storageType = 'custom') {
|
||||
let name: string
|
||||
let type: string
|
||||
|
||||
if (storageType === 'custom') {
|
||||
// 自定义存储需要数字序号
|
||||
name = `${t(`storage.${storageType}`)} ${storages.value.length + 1}`
|
||||
while (storages.value.some(item => item.name === name)) {
|
||||
const num = parseInt(name.match(/\d+$/)?.[0] || '1') + 1
|
||||
name = `${t(`storage.${storageType}`)} ${num}`
|
||||
}
|
||||
type = `custom${storages.value.length + 1}`
|
||||
} else {
|
||||
// 预定义存储类型直接使用类型名称
|
||||
name = t(`storage.${storageType}`)
|
||||
type = storageType
|
||||
}
|
||||
|
||||
storages.value.push({
|
||||
name: `${t('storage.custom')} ${storages.value.length + 1}`,
|
||||
type: 'custom',
|
||||
name: name,
|
||||
type: type,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 保存存储
|
||||
saveStorages()
|
||||
}
|
||||
|
||||
// 移除存储
|
||||
@@ -172,14 +204,6 @@ function removeStorage(storage: StorageConf) {
|
||||
}
|
||||
}
|
||||
|
||||
// 更新存储
|
||||
async function updatedStorage(storage: StorageConf) {
|
||||
const index = storages.value.indexOf(storage)
|
||||
if (index > -1) {
|
||||
storages.value[index] = storage
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
async function saveSystemSettings(value: any) {
|
||||
try {
|
||||
@@ -218,7 +242,7 @@ onMounted(() => {
|
||||
:component-data="{ 'class': 'grid gap-3 grid-app-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<StorageCard :storage="element" @close="removeStorage(element)" @done="updatedStorage" />
|
||||
<StorageCard :storage="element" @close="removeStorage(element)" @done="loadStorages" />
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
@@ -228,8 +252,18 @@ onMounted(() => {
|
||||
<VBtn type="submit" class="me-2" @click="saveStorages" prepend-icon="mdi-content-save">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
<VBtn color="success" variant="tonal" @click="addStorage">
|
||||
<VBtn color="success" variant="tonal">
|
||||
<VIcon icon="mdi-plus" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem v-for="item in storageOptions" :key="item.value" @click="addStorage(item.value)">
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addStorage('custom')">
|
||||
<VListItemTitle>{{ t('storage.custom') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
|
||||
@@ -8,13 +8,15 @@ import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 站点列表
|
||||
const siteList = ref<Site[]>([])
|
||||
@@ -22,6 +24,9 @@ const siteList = ref<Site[]>([])
|
||||
// 站点数据列表
|
||||
const userDataList = ref<SiteUserData[]>([])
|
||||
|
||||
// 站点统计数据列表
|
||||
const siteStatsList = ref<{ [domain: string]: any }>({})
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -31,6 +36,56 @@ const loading = ref(false)
|
||||
// 新增站点对话框
|
||||
const siteAddDialog = ref(false)
|
||||
|
||||
// 筛选相关
|
||||
const filterMenu = ref(false)
|
||||
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
|
||||
|
||||
// 筛选选项
|
||||
const filterOptions = computed(() => [
|
||||
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
|
||||
{ value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },
|
||||
{ value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },
|
||||
{ value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },
|
||||
{ value: 'slow', label: t('site.connectionSlow'), icon: 'mdi-wifi-strength-2', color: 'warning' },
|
||||
{ value: 'failed', label: t('site.connectionFailed'), icon: 'mdi-wifi-off', color: 'error' },
|
||||
{ value: 'unknown', label: t('site.connectionUnknown'), icon: 'mdi-help-circle', color: 'secondary' },
|
||||
])
|
||||
|
||||
// 筛选后的站点列表
|
||||
const filteredSiteList = computed(() => {
|
||||
if (filterOption.value === 'all') {
|
||||
return siteList.value
|
||||
}
|
||||
return siteList.value.filter(site => {
|
||||
if (filterOption.value === 'active') {
|
||||
return site.is_active
|
||||
} else if (filterOption.value === 'inactive') {
|
||||
return !site.is_active
|
||||
} else if (['connected', 'slow', 'failed', 'unknown'].includes(filterOption.value)) {
|
||||
const connectionStatus = getConnectionStatus(site.domain)
|
||||
return connectionStatus === filterOption.value
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 用于拖拽排序的列表
|
||||
const draggableSiteList = computed({
|
||||
get() {
|
||||
return filterOption.value === 'all' ? siteList.value : filteredSiteList.value
|
||||
},
|
||||
set(value) {
|
||||
if (filterOption.value === 'all') {
|
||||
siteList.value = value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 当前筛选选项的显示信息
|
||||
const currentFilter = computed(() => {
|
||||
return filterOptions.value.find(option => option.value === filterOption.value)
|
||||
})
|
||||
|
||||
// 获取站点列表数据
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -38,6 +93,8 @@ async function fetchData() {
|
||||
siteList.value = await api.get('site/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
// 获取站点列表后,获取统计数据
|
||||
await fetchSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -52,13 +109,57 @@ async function fetchUserData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取站点统计数据
|
||||
async function fetchSiteStats() {
|
||||
try {
|
||||
// 使用批量接口一次性获取所有站点统计数据
|
||||
const response = await api.get('site/statistic')
|
||||
const stats = response.data || response
|
||||
|
||||
// 将数组转换为以domain为键的对象
|
||||
const statsMap: { [domain: string]: any } = {}
|
||||
if (Array.isArray(stats)) {
|
||||
stats.forEach((stat: any) => {
|
||||
if (stat.domain) {
|
||||
statsMap[stat.domain] = stat
|
||||
}
|
||||
})
|
||||
}
|
||||
siteStatsList.value = statsMap
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch site statistics:', error)
|
||||
siteStatsList.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
// 根据站点统计数据判断连接状态
|
||||
function getConnectionStatus(domain: string) {
|
||||
const stats = siteStatsList.value[domain]
|
||||
if (!stats || Object.keys(stats).length === 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (stats.lst_state === 1) {
|
||||
return 'failed'
|
||||
} else if (stats.lst_state === 0) {
|
||||
if (!stats.seconds) return 'unknown'
|
||||
if (stats.seconds >= 5) return 'slow'
|
||||
return 'connected'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
// 保存站点排序
|
||||
async function savaSitesPriority() {
|
||||
// 只在显示全部站点时允许排序
|
||||
if (filterOption.value !== 'all') {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新排序
|
||||
const priorities = siteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
|
||||
const priorities = draggableSiteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
|
||||
if (result.success) {
|
||||
if (!result.success) {
|
||||
fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -71,12 +172,39 @@ function getUserData(domain: string) {
|
||||
return userDataList.value.find(userData => userData.domain === domain)
|
||||
}
|
||||
|
||||
// 根据站点域名获取统计数据
|
||||
function getSiteStats(domain: string) {
|
||||
return siteStatsList.value[domain] || {}
|
||||
}
|
||||
|
||||
// 处理站点统计数据刷新请求
|
||||
async function handleRefreshStats(domain?: string) {
|
||||
if (domain) {
|
||||
// 刷新特定站点的统计数据
|
||||
try {
|
||||
const stats = await api.get(`site/statistic/${domain}`)
|
||||
siteStatsList.value[domain] = stats
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh stats for ${domain}:`, error)
|
||||
}
|
||||
} else {
|
||||
// 刷新所有站点统计数据
|
||||
await fetchSiteStats()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新站点事件时
|
||||
function onSiteSave() {
|
||||
siteAddDialog.value = false
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 选择筛选选项
|
||||
function selectFilter(value: string) {
|
||||
filterOption.value = value
|
||||
filterMenu.value = false
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onBeforeMount(() => {
|
||||
fetchData()
|
||||
@@ -101,41 +229,92 @@ useDynamicButton({
|
||||
|
||||
<template>
|
||||
<div class="card-list-container">
|
||||
<!-- 页面标题 -->
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" />
|
||||
<!-- 页面标题和筛选按钮 -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
|
||||
<!-- 筛选按钮 -->
|
||||
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
:icon="display.smAndDown.value"
|
||||
:variant="filterOption === 'all' ? 'text' : 'tonal'"
|
||||
:color="currentFilter?.color"
|
||||
>
|
||||
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ currentFilter?.label }}
|
||||
</span>
|
||||
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<!-- 筛选菜单 -->
|
||||
<VCard min-width="200">
|
||||
<VList class="px-2">
|
||||
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
|
||||
<VListItem
|
||||
v-for="option in filterOptions"
|
||||
:key="option.value"
|
||||
:active="filterOption === option.value"
|
||||
@click="selectFilter(option.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="option.icon" :color="option.color" />
|
||||
</template>
|
||||
<VListItemTitle>{{ option.label }}</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</div>
|
||||
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<draggable
|
||||
v-if="siteList.length > 0"
|
||||
v-model="siteList"
|
||||
v-if="draggableSiteList.length > 0"
|
||||
v-model="draggableSiteList"
|
||||
@end="savaSitesPriority"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
|
||||
:disabled="filterOption !== 'all'"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SiteCard :site="element" :data="getUserData(element.domain)" @remove="fetchData" @update="fetchData" />
|
||||
<SiteCard
|
||||
:site="element"
|
||||
:data="getUserData(element.domain)"
|
||||
:stats="getSiteStats(element.domain)"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="siteList.length === 0 && isRefreshed"
|
||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
:error-title="t('site.noSites')"
|
||||
:error-description="t('site.sitesWillBeShownHere')"
|
||||
:error-title="filterOption === 'all' ? t('site.noSites') : t('common.noMatchingData')"
|
||||
:error-description="filterOption === 'all' ? t('site.sitesWillBeShownHere') : t('common.tryChangingFilters')"
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-web-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="siteAddDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-web-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="siteAddDialog = true"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
</Teleport>
|
||||
<!-- 新增站点弹窗 -->
|
||||
<SiteAddEditDialog
|
||||
v-if="siteAddDialog"
|
||||
|
||||
@@ -213,9 +213,9 @@ onActivated(() => {
|
||||
.v-application .fc {
|
||||
--fc-today-bg-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
--fc-border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
--fc-neutral-bg-color: rgb(var(--v-theme-background));
|
||||
--fc-neutral-bg-color: rgb(var(--v-theme-background), 0.3);
|
||||
--fc-list-event-hover-bg-color: rgba(var(--v-theme-on-surface), 0.02);
|
||||
--fc-page-bg-color: rgb(var(--v-theme-surface));
|
||||
--fc-page-bg-color: rgb(var(--v-theme-background), 0.3);
|
||||
--fc-event-border-color: currentcolor;
|
||||
}
|
||||
|
||||
@@ -232,6 +232,16 @@ onActivated(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-title {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-col-header-cell-cushion {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
@@ -309,6 +319,22 @@ onActivated(() => {
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-button-primary {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--v-theme-on-surface);
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -322,10 +348,6 @@ onActivated(() => {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button-primary:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk:last-child .fc-button-group {
|
||||
border: 0.0625rem solid rgba(var(--v-theme-primary), var(--v-overlay-scrim-opacity));
|
||||
border-radius: 0.375rem;
|
||||
@@ -349,16 +371,6 @@ onActivated(() => {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-title {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-scrollgrid-section th {
|
||||
border-inline: 0;
|
||||
}
|
||||
@@ -424,10 +436,6 @@ onActivated(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-toolbar-chunk .fc-button-group .fc-button .fc-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
@@ -483,18 +491,6 @@ onActivated(() => {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-button-primary {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--v-theme-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.v-application .fc .fc-button-primary:hover {
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (width <= 776px) {
|
||||
.fc-daygrid-event-harness {
|
||||
display: flex;
|
||||
|
||||
@@ -9,13 +9,15 @@ import { useUserStore } from '@/stores'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
@@ -181,20 +183,22 @@ useDynamicButton({
|
||||
:error-description="keyword ? t('subscribe.noFilterData') : t('subscribe.noSubscribeData')"
|
||||
/>
|
||||
<!-- 底部操作按钮 -->
|
||||
<div v-if="isRefreshed">
|
||||
<VFab
|
||||
v-if="userStore.superUser && !appMode"
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
location="bottom"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="historyDialog = true"
|
||||
/>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div v-if="isRefreshed">
|
||||
<VFab
|
||||
v-if="userStore.superUser && !appMode"
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
location="bottom"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="historyDialog = true"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- 历史记录弹窗 -->
|
||||
<SubscribeHistoryDialog
|
||||
v-if="historyDialog"
|
||||
|
||||
@@ -67,6 +67,8 @@ async function loadMessages({ done }: { done: any }) {
|
||||
if (currData.value.length > 0) {
|
||||
// 取最后一条时间为存量消息最新时间
|
||||
lastTime.value = currData.value[currData.value.length - 1].reg_time ?? ''
|
||||
// 倒序
|
||||
currData.value.reverse()
|
||||
// 合并数据
|
||||
messages.value = [...currData.value, ...messages.value]
|
||||
if (page.value === 1) {
|
||||
@@ -119,7 +121,7 @@ onBeforeUnmount(() => {
|
||||
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||
side="start"
|
||||
:items="messages"
|
||||
class="overflow-visible message-scroll h-full"
|
||||
class="overflow-auto h-full"
|
||||
@load="loadMessages"
|
||||
:load-more-text="t('message.loadMore') + ' ...'"
|
||||
>
|
||||
@@ -141,9 +143,3 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-scroll {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,6 +49,7 @@ const sortTitles: Record<string, string> = {
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 过滤项映射
|
||||
@@ -275,6 +276,9 @@ function filterData() {
|
||||
} 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') {
|
||||
@@ -286,6 +290,9 @@ function filterData() {
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ const sortTitles: Record<string, string> = {
|
||||
site: t('torrent.sortSite'),
|
||||
size: t('torrent.sortSize'),
|
||||
seeder: t('torrent.sortSeeder'),
|
||||
publishTime: t('torrent.sortPublishTime'),
|
||||
}
|
||||
|
||||
// 统一存储过滤选项
|
||||
@@ -264,6 +265,11 @@ function filterData() {
|
||||
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') {
|
||||
@@ -276,6 +282,11 @@ function filterData() {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ import type { User } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import UserCard from '@/components/cards/UserCard.vue'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
@@ -96,17 +95,19 @@ useDynamicButton({
|
||||
</div>
|
||||
|
||||
<!-- 新增用户按钮 -->
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-account-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="openAddUserDialog"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-account-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="openAddUserDialog"
|
||||
:class="{ 'mb-12': appMode }"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
<!-- 用户添加弹窗 -->
|
||||
<UserAddEditDialog
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
const appMode = inject('pwaMode') && display.mdAndDown.value
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
@@ -72,17 +72,19 @@ useDynamicButton({
|
||||
</div>
|
||||
|
||||
<!-- 新增按钮 -->
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
:class="{ 'mb-12': appMode }"
|
||||
@click="addDialog = true"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<VFab
|
||||
v-if="isRefreshed && !appMode"
|
||||
icon="mdi-plus"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
:class="{ 'mb-12': appMode }"
|
||||
@click="addDialog = true"
|
||||
/>
|
||||
</Teleport>
|
||||
<!-- 新增对话框 -->
|
||||
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user