mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-10 17:42:50 +08:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5c9ebb9aae | ||
|
|
ebc2a764c2 | ||
|
|
bed21856ab | ||
|
|
61805d13ab | ||
|
|
e47d8d5d2b | ||
|
|
0bd81499f6 | ||
|
|
201ae2c237 | ||
|
|
df4c3c7676 | ||
|
|
667693902f | ||
|
|
9e261d30f8 | ||
|
|
5f6bade809 | ||
|
|
273168ae5c | ||
|
|
a55269e9e6 | ||
|
|
9c386f8533 | ||
|
|
17ee5f456a | ||
|
|
6cefdb5d37 | ||
|
|
74fc8bd131 | ||
|
|
aa9dab5d96 | ||
|
|
5b461f8e1f | ||
|
|
bde06be3df | ||
|
|
fe17986b2a | ||
|
|
e9160ecefd | ||
|
|
05ebd48f09 | ||
|
|
6dbc3f4bab | ||
|
|
bc7166789b | ||
|
|
750b91db66 | ||
|
|
b69a338e13 | ||
|
|
036fe65b12 | ||
|
|
732017ac77 | ||
|
|
5bd71b4688 | ||
|
|
44ba2dff78 | ||
|
|
0954e4bde2 | ||
|
|
5b183d31e2 | ||
|
|
b2017764eb | ||
|
|
f27cd796b6 | ||
|
|
3c051b8698 | ||
|
|
052d6edd13 | ||
|
|
e7dc61e3d9 | ||
|
|
f0aefdfdf8 | ||
|
|
0beec368b8 | ||
|
|
3f1d03a127 | ||
|
|
eb143c28e3 | ||
|
|
1631951a24 | ||
|
|
31bdd89373 | ||
|
|
ad5ae12d44 | ||
|
|
c838db262c | ||
|
|
623b807a11 | ||
|
|
ce9335a842 | ||
|
|
1c62465c3e | ||
|
|
a2c176bdee | ||
|
|
bff8c0f86b | ||
|
|
1065973e07 | ||
|
|
8e042d5691 |
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.3-1",
|
||||
"version": "2.6.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -56,7 +56,7 @@
|
||||
"tailwindcss": "^ 3.4.17",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-toast-notification": "^3.1.3",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.8.0",
|
||||
"vue3-perfect-scrollbar": "^2.0.0",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,66 @@ app.use(
|
||||
// 路径加上 /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');
|
||||
|
||||
// 动态设置超时时间:SSE无超时,普通请求600秒超时
|
||||
proxyReqOpts.timeout = isSSE ? 0 : 600000;
|
||||
|
||||
if (isSSE) {
|
||||
// SSE请求的特殊头部设置
|
||||
proxyReqOpts.headers['Cache-Control'] = 'no-cache';
|
||||
proxyReqOpts.headers['Connection'] = 'keep-alive';
|
||||
proxyReqOpts.headers['Accept'] = 'text/event-stream';
|
||||
}
|
||||
|
||||
return proxyReqOpts;
|
||||
},
|
||||
userResDecorator: (proxyRes, proxyResData, userReq, userRes) => {
|
||||
// 检测响应是否为SSE类型
|
||||
const isSSEResponse = proxyRes.headers['content-type'] &&
|
||||
proxyRes.headers['content-type'].includes('text/event-stream');
|
||||
|
||||
if (isSSEResponse) {
|
||||
// SSE响应:设置流式传输头部并禁用缓冲
|
||||
userRes.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'
|
||||
});
|
||||
return false; // 禁用默认响应处理,让数据直接流向客户端
|
||||
} else {
|
||||
// 普通响应:正常处理
|
||||
return proxyResData;
|
||||
}
|
||||
},
|
||||
// 统一错误处理(添加超时错误处理)
|
||||
proxyErrorHandler: (err, res, next) => {
|
||||
// 客户端断开连接的正常情况(常见于SSE)
|
||||
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
|
||||
console.log('Client disconnected:', err.code);
|
||||
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);
|
||||
next(err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -3,70 +3,44 @@
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
|
||||
|
||||
.v-theme--light & {
|
||||
backdrop-filter: blur(16px);
|
||||
background: rgba(var(--v-theme-surface), 0.9);
|
||||
box-shadow: 0 0 8px 0 rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
&::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;
|
||||
pointer-events: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
// 使用遮罩实现渐变效果
|
||||
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: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 1) 0%,
|
||||
rgba(var(--v-theme-background), 0.8) 20%,
|
||||
rgba(var(--v-theme-background), 0.6) 40%,
|
||||
rgba(var(--v-theme-background), 0.4) 60%,
|
||||
rgba(var(--v-theme-background), 0.2) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
background: rgba(var(--v-theme-background), 0.6);
|
||||
}
|
||||
|
||||
.v-theme--purple & {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 1) 0%,
|
||||
rgba(var(--v-theme-background), 0.8) 20%,
|
||||
rgba(var(--v-theme-background), 0.6) 40%,
|
||||
rgba(var(--v-theme-background), 0.4) 60%,
|
||||
rgba(var(--v-theme-background), 0.2) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
background: rgba(var(--v-theme-background), 0.6);
|
||||
}
|
||||
|
||||
.v-theme--transparent & {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0.5) 0%,
|
||||
rgba(var(--v-theme-background), 0.4) 20%,
|
||||
rgba(var(--v-theme-background), 0.3) 40%,
|
||||
rgba(var(--v-theme-background), 0.2) 60%,
|
||||
rgba(var(--v-theme-background), 0.1) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
|
||||
@media (width <= 640px) {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0.9) 0%,
|
||||
rgba(var(--v-theme-background), 0.7) 20%,
|
||||
rgba(var(--v-theme-background), 0.5) 40%,
|
||||
rgba(var(--v-theme-background), 0.3) 60%,
|
||||
rgba(var(--v-theme-background), 0.1) 80%,
|
||||
rgba(var(--v-theme-background), 0.0) 100%
|
||||
);
|
||||
}
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
190
src/App.vue
190
src/App.vue
@@ -3,9 +3,11 @@ 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'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
@@ -17,13 +19,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'))
|
||||
|
||||
@@ -40,43 +42,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +103,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
|
||||
@@ -96,108 +115,73 @@ 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')
|
||||
|
||||
// 等待动画完成后再移除元素
|
||||
setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
// 将background属性从html的style中移除
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 显示页面
|
||||
show.value = true
|
||||
}, 500) // 与CSS动画持续时间匹配
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载背景图片
|
||||
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 () => {
|
||||
// 初始化全局设置
|
||||
await globalSettingsStore.initialize()
|
||||
|
||||
// 配置 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()
|
||||
}, 1500)
|
||||
// 移除加载动画,显示页面
|
||||
animateAndRemoveLoader()
|
||||
// 页面完全显示后,检查未读消息
|
||||
checkAndEmitUnreadMessages()
|
||||
})
|
||||
})
|
||||
|
||||
// 添加页面可见性变化监听
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadBackgroundImages()
|
||||
}
|
||||
})
|
||||
|
||||
// 添加PWA的页面恢复事件监听
|
||||
window.addEventListener('pageshow', event => {
|
||||
// persisted属性为true表示页面是从bfcache中恢复的
|
||||
if (event.persisted) {
|
||||
loadBackgroundImages()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除页面可见性监听
|
||||
document.removeEventListener('visibilitychange', () => {})
|
||||
// 移除PWA的页面恢复事件监听
|
||||
window.removeEventListener('pageshow', () => {})
|
||||
|
||||
// 清除轮换定时器
|
||||
if (backgroundRotationTimer) {
|
||||
clearInterval(backgroundRotationTimer)
|
||||
@@ -216,12 +200,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>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -1008,6 +1010,8 @@ export interface SystemNotification {
|
||||
text: string
|
||||
// 通知时间
|
||||
date: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
|
||||
// 下载器配置
|
||||
|
||||
BIN
src/assets/images/logos/python.png
Normal file
BIN
src/assets/images/logos/python.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
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 = {
|
||||
// 压缩包
|
||||
@@ -242,7 +244,7 @@ function stopDrag() {
|
||||
// 外层DIV大小控制
|
||||
const scrollStyle = computed(() => {
|
||||
return appMode
|
||||
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 6.5rem)'
|
||||
? 'height: calc(100vh - 10.5rem - env(safe-area-inset-bottom) - 7rem)'
|
||||
: 'height: calc(100vh - 10.5rem - 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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { CustomRule } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import filter_svg from '@images/svg/filter.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { DownloaderConf } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderInfo } from '@/api/types'
|
||||
import qbittorrent_image from '@images/logos/qbittorrent.png'
|
||||
import transmission_image from '@images/logos/transmission.png'
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatFileSize } from '@/@core/utils/formatters'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
info: Object as PropType<DownloadingInfo>,
|
||||
downloaderName: String,
|
||||
})
|
||||
|
||||
// 是否显示卡片
|
||||
@@ -51,7 +52,11 @@ function getTextClass() {
|
||||
async function toggleDownload() {
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`)
|
||||
const result: { [key: string]: any } = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
name: props.downloaderName
|
||||
}
|
||||
})
|
||||
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
@@ -62,7 +67,7 @@ async function toggleDownload() {
|
||||
// 删除下截
|
||||
async function deleteDownload() {
|
||||
try {
|
||||
await api.delete(`download/${props.info?.hash}`)
|
||||
await api.delete(`download/${props.info?.hash}`, {params: {name: props.downloaderName}})
|
||||
cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
@@ -3,7 +3,7 @@ import draggable from 'vuedraggable'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import filter_group_svg from '@images/svg/filter-group.svg'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
@@ -4,17 +4,18 @@ import tmdbImage from '@images/logos/tmdb.png'
|
||||
import doubanImage from '@images/logos/douban-black.png'
|
||||
import bangumiImage from '@images/logos/bangumi.png'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
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'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -27,7 +28,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
const globalSettings: any = inject('globalSettings')
|
||||
// 全局设置
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const globalSettings = globalSettingsStore.globalSettings
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
@@ -231,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,
|
||||
@@ -242,7 +242,6 @@ async function handleCheckExists() {
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
@@ -254,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
|
||||
@@ -481,7 +477,13 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn icon="mdi-magnify" color="white" @click.stop="clickSearch" />
|
||||
<IconBtn
|
||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
@click.stop="clickSearch"
|
||||
/>
|
||||
<VSpacer />
|
||||
<IconBtn icon="mdi-heart" :color="isSubscribed ? 'error' : 'white'" @click.stop="handleSubscribe" />
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaServerConf, MediaServerLibrary, MediaStatistic } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import emby_image from '@images/logos/emby.png'
|
||||
import jellyfin_image from '@images/logos/jellyfin.png'
|
||||
import plex_image from '@images/logos/plex.png'
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const props = defineProps({
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['imageload'])
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -19,6 +22,7 @@ const imageLoadError = ref(false)
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
emit('imageload')
|
||||
}
|
||||
|
||||
// 链接打开新窗口
|
||||
@@ -55,7 +59,14 @@ function replaceNewLine(value: string) {
|
||||
position="top"
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
min-height="10rem"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
|
||||
@@ -7,7 +7,7 @@ import synologychat_image from '@images/logos/synologychat.png'
|
||||
import slack_image from '@images/logos/slack.webp'
|
||||
import chrome_image from '@images/logos/chrome.png'
|
||||
import custom_image from '@images/logos/notification.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -303,6 +303,16 @@ function onClose() {
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.API_URL"
|
||||
:label="t('notification.telegram.apiUrl')"
|
||||
:placeholder="t('notification.telegram.apiUrlPlaceholder')"
|
||||
:hint="t('notification.telegram.apiUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'slack'">
|
||||
<VCol cols="12" md="6">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import VersionHistory from '../misc/VersionHistory.vue'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
// 重置插件
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import noImage from '@images/logos/site.webp'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
|
||||
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
|
||||
@@ -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 { useToast } from 'vue-toast-notification'
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeFilesDialog from '../dialog/SubscribeFilesDialog.vue'
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/api'
|
||||
import { Subscribe, User } from '@/api/types'
|
||||
import { useUserStore } from '@/stores'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -123,157 +123,176 @@ onMounted(() => {
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="flex flex-column"
|
||||
@click="userEditDialog = true"
|
||||
>
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
<div class="position-relative mr-4">
|
||||
<VAvatar
|
||||
size="72"
|
||||
rounded="lg"
|
||||
:class="[
|
||||
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
|
||||
!user.is_active ? 'grayscale-50 opacity-90' : '',
|
||||
]"
|
||||
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
|
||||
>
|
||||
<VImg :src="user.avatar || avatar1" :alt="user.name" />
|
||||
<div
|
||||
v-if="!user.is_active"
|
||||
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
|
||||
style="inset: 0"
|
||||
>
|
||||
<VIcon icon="mdi-account-lock" color="white" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div v-if="user.is_superuser" class="admin-crown">
|
||||
<VIcon icon="mdi-crown" color="warning" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="pa-0 d-flex flex-column">
|
||||
<div class="d-flex flex-column mb-1">
|
||||
<div class="d-flex align-center">
|
||||
<span
|
||||
<div class="flex-grow">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
<template v-slot:prepend>
|
||||
<div class="position-relative mr-4">
|
||||
<VAvatar
|
||||
size="72"
|
||||
rounded="lg"
|
||||
:class="[
|
||||
'text-h6 font-weight-bold truncate',
|
||||
user.is_superuser ? 'text-warning' : '',
|
||||
!user.is_active ? 'text-medium-emphasis' : '',
|
||||
user.is_superuser ? 'admin-avatar' : 'border-4 bg-surface',
|
||||
!user.is_active ? 'grayscale-50 opacity-90' : '',
|
||||
]"
|
||||
:style="user.is_superuser ? 'border: 4px solid rgba(var(--v-theme-warning), 0.3);' : ''"
|
||||
>
|
||||
{{ displayName }}
|
||||
<VIcon
|
||||
v-if="user.nickname || user.settings?.nickname"
|
||||
icon="mdi-format-quote-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
class="animate-pulse"
|
||||
/>
|
||||
</span>
|
||||
<VImg :src="user.avatar || avatar1" :alt="user.name" />
|
||||
<div
|
||||
v-if="!user.is_active"
|
||||
class="position-absolute d-flex align-center justify-center rounded-lg bg-surface-variant opacity-20"
|
||||
style="inset: 0"
|
||||
>
|
||||
<VIcon icon="mdi-account-lock" color="white" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div v-if="user.is_superuser" class="admin-crown">
|
||||
<VIcon icon="mdi-crown" color="warning" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1 overflow-auto">
|
||||
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
|
||||
t('user.admin')
|
||||
}}</VChip>
|
||||
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
|
||||
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
|
||||
{{ user.is_active ? t('user.active') : t('user.inactive') }}
|
||||
</VChip>
|
||||
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="pa-0 d-flex flex-column">
|
||||
<div class="d-flex flex-column mb-1">
|
||||
<div class="d-flex align-center">
|
||||
<span
|
||||
:class="[
|
||||
'text-h6 font-weight-bold truncate',
|
||||
user.is_superuser ? 'text-warning' : '',
|
||||
!user.is_active ? 'text-medium-emphasis' : '',
|
||||
]"
|
||||
>
|
||||
{{ displayName }}
|
||||
<VIcon
|
||||
v-if="user.nickname || user.settings?.nickname"
|
||||
icon="mdi-format-quote-close"
|
||||
size="x-small"
|
||||
color="info"
|
||||
class="animate-pulse"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1 overflow-auto">
|
||||
<VChip v-if="user.is_superuser" size="x-small" color="error" variant="outlined" label>{{
|
||||
t('user.admin')
|
||||
}}</VChip>
|
||||
<VChip v-else size="x-small" label>{{ t('user.normal') }}</VChip>
|
||||
<VChip size="x-small" :color="user.is_active ? 'success' : 'grey'" variant="tonal" label>
|
||||
{{ user.is_active ? t('user.active') : t('user.inactive') }}
|
||||
</VChip>
|
||||
<VChip v-if="user.is_otp" size="x-small" color="info" variant="tonal" label>2FA</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端订阅数据信息 -->
|
||||
<div v-if="isMobile" class="d-flex gap-5 mt-2">
|
||||
<div class="d-flex align-center">
|
||||
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
|
||||
<span class="text-body-2">{{ movieSubscriptions }}</span>
|
||||
<!-- 移动端订阅数据信息 -->
|
||||
<div v-if="isMobile" class="d-flex gap-5 mt-2">
|
||||
<div class="d-flex align-center">
|
||||
<VIcon size="x-small" icon="mdi-movie-outline" color="primary" class="mr-1" />
|
||||
<span class="text-body-2">{{ movieSubscriptions }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
|
||||
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon size="x-small" icon="mdi-television-classic" color="primary" class="mr-1" />
|
||||
<span class="text-body-2">{{ tvShowSubscriptions }}</span>
|
||||
</VCardTitle>
|
||||
|
||||
<!-- 头部操作按钮 -->
|
||||
<template v-slot:append>
|
||||
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
:color="user.is_superuser ? 'warning' : 'primary'"
|
||||
variant="text"
|
||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||
@click.stop="editUser"
|
||||
>
|
||||
<VIcon icon="mdi-pencil" />
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||
@click.stop="removeUser"
|
||||
>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardTitle>
|
||||
|
||||
<!-- 头部操作按钮 -->
|
||||
<template v-slot:append>
|
||||
<div :class="['d-flex', isMobile ? 'position-absolute top-2 right-2' : '']">
|
||||
<VBtn
|
||||
icon
|
||||
size="small"
|
||||
:color="user.is_superuser ? 'warning' : 'primary'"
|
||||
variant="text"
|
||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||
@click.stop="editUser"
|
||||
>
|
||||
<VIcon icon="mdi-pencil" />
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="props.user.id != currentLoginUserId && currentUserIsSuperuser"
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
variant="text"
|
||||
class="opacity-70 hover:opacity-100 transition-opacity"
|
||||
@click.stop="removeUser"
|
||||
>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VCardItem>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<!-- 权限显示 -->
|
||||
<div v-if="!user.is_superuser && user.permissions" class="d-flex flex-wrap gap-1 px-7 pb-3">
|
||||
<VChip v-if="user.permissions.discovery" size="x-small" color="purple" variant="outlined" label>
|
||||
{{ t('dialog.userAddEdit.permissions.discovery') }}
|
||||
</VChip>
|
||||
<VChip v-if="user.permissions.search" size="x-small" color="blue" variant="outlined" label>
|
||||
{{ t('dialog.userAddEdit.permissions.search') }}
|
||||
</VChip>
|
||||
<VChip v-if="user.permissions.subscribe" size="x-small" color="green" variant="outlined" label>
|
||||
{{ t('dialog.userAddEdit.permissions.subscribe') }}
|
||||
</VChip>
|
||||
<VChip v-if="user.permissions.manage" size="x-small" color="orange" variant="outlined" label>
|
||||
{{ t('dialog.userAddEdit.permissions.manage') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 独立的邮箱显示 -->
|
||||
<VDivider class="mx-4" />
|
||||
<div>
|
||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||
</VCardText>
|
||||
|
||||
<VCardText class="d-flex align-center py-2 px-4 text-medium-emphasis">
|
||||
<VIcon icon="mdi-email-outline" size="small" color="primary" class="mr-2 opacity-70" />
|
||||
<span class="text-body-2 truncate">{{ user.email || t('user.noEmail') }}</span>
|
||||
</VCardText>
|
||||
|
||||
<!-- PC端显示订阅统计信息 -->
|
||||
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
|
||||
<div rounded="lg" class="d-flex justify-space-around pa-3">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="large"
|
||||
class="mr-1"
|
||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||
>
|
||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
||||
<!-- PC端显示订阅统计信息 -->
|
||||
<VCardText v-if="!isMobile" class="px-4 pt-0 pb-4">
|
||||
<div rounded="lg" class="d-flex justify-space-around">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="large"
|
||||
class="mr-1"
|
||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||
>
|
||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-movie-outline" size="20" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="large"
|
||||
class="mr-1"
|
||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||
>
|
||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-lg text-medium-emphasis font-weight-bold">{{ movieSubscriptions }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ t('user.movieSubscriptions') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="large"
|
||||
class="mr-1"
|
||||
:class="user.is_superuser ? 'admin-stats-container' : 'user-stats-container'"
|
||||
>
|
||||
<div :class="['d-flex align-center justify-center rounded-lg w-10 h-10']">
|
||||
<VIcon :color="user.is_superuser ? 'warning' : 'primary'" icon="mdi-television-classic" />
|
||||
</div>
|
||||
</VAvatar>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="text-lg text-medium-emphasis">{{ tvShowSubscriptions }}</span>
|
||||
<span class="text-caption text-medium-emphasis">{{ t('user.tvSubscriptions') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<!-- 用户编辑弹窗 -->
|
||||
@@ -294,9 +313,10 @@ onMounted(() => {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
padding: 8px 12px;
|
||||
inline-size: 100%;
|
||||
inset-block-start: 0;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
@@ -326,10 +346,12 @@ onMounted(() => {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
@@ -340,19 +362,21 @@ onMounted(() => {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
top: -10px;
|
||||
left: -6px;
|
||||
transform: rotate(-25deg);
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 40%));
|
||||
inset-block-start: -10px;
|
||||
inset-inline-start: -6px;
|
||||
transform: rotate(-25deg);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-25deg) translateY(-3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(-25deg) translateY(0);
|
||||
}
|
||||
@@ -368,6 +392,7 @@ onMounted(() => {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowActionsDialog from '@/components/dialog/WorkflowActionsDialog.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { DownloaderConf, MediaInfo, TorrentInfo, TransferDirectoryConf } from '@/api/types'
|
||||
|
||||
@@ -3,9 +3,10 @@ import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
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()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDisplay } from 'vuetify'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import api from '@/api'
|
||||
import { transferTypeOptions } from '@/api/constants'
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUserStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
@@ -31,6 +32,37 @@ const superUser = userStore.superUser
|
||||
// 当前用户名
|
||||
const userName = userStore.userName
|
||||
|
||||
// 权限检查
|
||||
const hasSearchPermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'search',
|
||||
)
|
||||
})
|
||||
|
||||
const hasSubscribePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'subscribe',
|
||||
)
|
||||
})
|
||||
|
||||
const hasManagePermission = computed(() => {
|
||||
return hasPermission(
|
||||
{
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
},
|
||||
'manage',
|
||||
)
|
||||
})
|
||||
|
||||
// 所有订阅数据
|
||||
const SubscribeItems = ref<Subscribe[]>([])
|
||||
|
||||
@@ -109,18 +141,27 @@ function getMenus(): NavMenu[] {
|
||||
return menus
|
||||
}
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
|
||||
// 匹配的菜单列表
|
||||
const matchedMenuItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
const menuItems = getMenus()
|
||||
if (menuItems)
|
||||
return menuItems.filter(
|
||||
if (menuItems) {
|
||||
// 先根据用户权限过滤菜单
|
||||
const filteredMenus = filterMenusByPermission(menuItems, userPermissions.value)
|
||||
// 再根据搜索词过滤
|
||||
return filteredMenus.filter(
|
||||
item =>
|
||||
item.title.toLowerCase().includes(lowerWord) ||
|
||||
(item.description && item.description.toLowerCase().includes(lowerWord)),
|
||||
)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -140,10 +181,10 @@ async function fetchInstalledPlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
// 区配的插件列表
|
||||
// 匹配的插件列表
|
||||
const matchedPluginItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!superUser) return []
|
||||
if (!hasManagePermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return pluginItems.value.filter((item: Plugin) => {
|
||||
if (!item.plugin_name && !item.plugin_desc) return false
|
||||
@@ -196,6 +237,7 @@ const openSiteDialog = () => {
|
||||
// 匹配的订阅列表
|
||||
const matchedSubscribeItems = computed(() => {
|
||||
if (!searchWord.value) return []
|
||||
if (!hasSubscribePermission.value) return []
|
||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
||||
@@ -298,11 +340,20 @@ onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
fetchInstalledPlugins()
|
||||
fetchSubscribes()
|
||||
// 根据权限加载不同的数据
|
||||
if (hasManagePermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
}
|
||||
if (hasSubscribePermission.value) {
|
||||
fetchSubscribes()
|
||||
}
|
||||
loadRecentSearches()
|
||||
loadUserSitePreferences()
|
||||
if (superUser) queryAllSites()
|
||||
if (hasSearchPermission.value) {
|
||||
loadUserSitePreferences()
|
||||
if (hasManagePermission.value) {
|
||||
queryAllSites()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
@@ -433,7 +484,7 @@ onMounted(() => {
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
<VHover v-if="superUser">
|
||||
<VHover v-if="hasManagePermission">
|
||||
<template #default="hover">
|
||||
<VListItem
|
||||
density="comfortable"
|
||||
@@ -578,7 +629,7 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<!-- 将站点资源搜索移到最底部 -->
|
||||
<template v-if="searchWord">
|
||||
<template v-if="searchWord && hasSearchPermission">
|
||||
<VDivider class="mx-4 mx-sm-6 my-2 search-divider" />
|
||||
<VListSubheader class="font-weight-medium text-uppercase py-2 px-4 px-sm-6">{{
|
||||
t('dialog.searchBar.siteResources')
|
||||
@@ -611,7 +662,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="superUser"
|
||||
v-if="hasManagePermission"
|
||||
class="d-flex align-center flex-wrap site-chips-container mt-4 py-2 px-2 px-sm-3"
|
||||
>
|
||||
<div class="d-flex align-center flex-wrap flex-grow-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { DownloaderConf, Site } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { numberValidator, requiredValidator } from '@/@validators'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { Site } from '@/api/types'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { numberValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { DownloaderConf, FilterRuleGroup, Site, Subscribe, TransferDirectoryConf } from '@/api/types'
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
import api from '@/api'
|
||||
import type { Subscribe, SubscribeShare } from '@/api/types'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { User } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import api from '@/api'
|
||||
@@ -65,6 +65,14 @@ interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 权限类型定义
|
||||
interface UserPermissions {
|
||||
discovery: boolean // 发现权限
|
||||
search: boolean // 搜索权限
|
||||
subscribe: boolean // 订阅权限
|
||||
manage: boolean // 管理权限
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
@@ -75,7 +83,12 @@ const userForm = ref<ExtendedUser>({
|
||||
is_superuser: false,
|
||||
avatar: avatar1,
|
||||
is_otp: false,
|
||||
permissions: {},
|
||||
permissions: {
|
||||
discovery: true,
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: false,
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
telegram_userid: null,
|
||||
@@ -86,6 +99,59 @@ const userForm = ref<ExtendedUser>({
|
||||
nickname: '', // 昵称字段
|
||||
})
|
||||
|
||||
// 权限选项
|
||||
const permissionOptions = [
|
||||
{
|
||||
key: 'discovery',
|
||||
title: t('dialog.userAddEdit.permissions.discovery'),
|
||||
description: t('dialog.userAddEdit.permissions.discoveryDesc'),
|
||||
icon: 'mdi-star-outline',
|
||||
},
|
||||
{
|
||||
key: 'search',
|
||||
title: t('dialog.userAddEdit.permissions.search'),
|
||||
description: t('dialog.userAddEdit.permissions.searchDesc'),
|
||||
icon: 'mdi-magnify',
|
||||
},
|
||||
{
|
||||
key: 'subscribe',
|
||||
title: t('dialog.userAddEdit.permissions.subscribe'),
|
||||
description: t('dialog.userAddEdit.permissions.subscribeDesc'),
|
||||
icon: 'mdi-rss',
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: t('dialog.userAddEdit.permissions.manage'),
|
||||
description: t('dialog.userAddEdit.permissions.manageDesc'),
|
||||
icon: 'mdi-cog-outline',
|
||||
},
|
||||
]
|
||||
|
||||
// 权限状态计算属性
|
||||
const userPermissions = computed({
|
||||
get: () => {
|
||||
const permissions = userForm.value.permissions as UserPermissions
|
||||
return {
|
||||
discovery: permissions?.discovery ?? true,
|
||||
search: permissions?.search ?? true,
|
||||
subscribe: permissions?.subscribe ?? true,
|
||||
manage: permissions?.manage ?? false,
|
||||
}
|
||||
},
|
||||
set: (value: UserPermissions) => {
|
||||
userForm.value.permissions = value
|
||||
},
|
||||
})
|
||||
|
||||
// 切换权限状态
|
||||
function togglePermission(key: keyof UserPermissions) {
|
||||
const currentPermissions = userPermissions.value
|
||||
userPermissions.value = {
|
||||
...currentPermissions,
|
||||
[key]: !currentPermissions[key],
|
||||
}
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
@@ -164,6 +230,10 @@ async function addUser() {
|
||||
}
|
||||
userForm.value.password = newPassword.value
|
||||
}
|
||||
|
||||
// 设置权限数据
|
||||
userForm.value.permissions = userPermissions.value
|
||||
|
||||
isAdding.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -216,8 +286,10 @@ async function updateUser() {
|
||||
isUpdating.value = true
|
||||
startNProgress()
|
||||
try {
|
||||
// 确保昵称保存,使用一个临时变量存储完整数据
|
||||
// 确保昵称和权限保存,使用一个临时变量存储完整数据
|
||||
const userData = { ...userForm.value }
|
||||
// 确保权限数据正确传递
|
||||
userData.permissions = userPermissions.value
|
||||
|
||||
const result: { [key: string]: any } = await api.put('user/', userData)
|
||||
|
||||
@@ -235,6 +307,10 @@ async function updateUser() {
|
||||
if (oldAvatar !== currentAvatar.value && isCurrentUser.value) {
|
||||
userStore.setAvatar(currentAvatar.value)
|
||||
}
|
||||
// 如果是当前登录用户,更新权限信息
|
||||
if (isCurrentUser.value) {
|
||||
userStore.setPermissions(userPermissions.value)
|
||||
}
|
||||
emit('save')
|
||||
} else {
|
||||
if (oldUserName !== currentUserName.value) {
|
||||
@@ -473,6 +549,48 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VDivider class="my-10" v-if="canControl">
|
||||
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
|
||||
</VDivider>
|
||||
<!-- 权限设置 -->
|
||||
<div v-if="canControl">
|
||||
<VRow>
|
||||
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
|
||||
<VCard
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
|
||||
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer transition-all h-full"
|
||||
@click="togglePermission(option.key as keyof UserPermissions)"
|
||||
hover
|
||||
>
|
||||
<VCardText class="d-flex align-center pa-4">
|
||||
<VAvatar
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
|
||||
size="40"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon :icon="option.icon" />
|
||||
</VAvatar>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
|
||||
{{ option.title }}
|
||||
<VIcon
|
||||
v-if="userPermissions[option.key as keyof UserPermissions]"
|
||||
icon="mdi-check-circle"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="ms-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { VueFlow, useVueFlow, type Connection, type GraphNode } from '@vue-flow/
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import type { Workflow } from '@/api/types'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { requiredValidator } from '@/@validators'
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { PropType } from 'vue'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import ReorganizeDialog from '../dialog/ReorganizeDialog.vue'
|
||||
import { formatBytes } from '@core/utils/formatters'
|
||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
||||
@@ -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'" />
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
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(20 + pullDistance.value - config.SHOW_INDICATOR, 50)}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,21 +7,41 @@ 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 { 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()
|
||||
|
||||
// 是否超级用户
|
||||
let superUser = userStore.superUser
|
||||
// 响应式的超级用户状态
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
|
||||
// ShortcutBar 引用
|
||||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -38,11 +58,64 @@ const organizeMenus = ref<NavMenu[]>([])
|
||||
// 系统菜单项
|
||||
const systemMenus = ref<NavMenu[]>([])
|
||||
|
||||
// 插件快速访问相关状态
|
||||
const showPluginQuickAccess = ref(false)
|
||||
|
||||
// 离线状态管理
|
||||
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
|
||||
|
||||
// 监听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) => {
|
||||
// 使用国际化菜单
|
||||
const menus = getNavMenus()
|
||||
return menus.filter((item: NavMenu) => item.header === header && (superUser || !item.admin))
|
||||
const filteredMenus = filterMenusByPermission(menus, userPermissions.value)
|
||||
return filteredMenus.filter((item: NavMenu) => item.header === header)
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
@@ -50,6 +123,28 @@ function goBack() {
|
||||
history.back()
|
||||
}
|
||||
|
||||
// 处理未读消息事件
|
||||
function handleUnreadMessage(count: number) {
|
||||
if (superUser.value && count > 0) {
|
||||
// 延迟一点时间确保组件已渲染
|
||||
setTimeout(() => {
|
||||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
||||
shortcutBarRef.value.openMessageDialog()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件快速访问
|
||||
function handleClosePluginQuickAccess() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
// 点击插件后关闭
|
||||
function handlePluginClick() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
@@ -57,10 +152,53 @@ onMounted(() => {
|
||||
subscribeMenus.value = getMenuList(t('menu.subscribe'))
|
||||
organizeMenus.value = getMenuList(t('menu.organize'))
|
||||
systemMenus.value = getMenuList(t('menu.system'))
|
||||
|
||||
// 监听全局未读消息事件
|
||||
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>
|
||||
<!-- 👉 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>
|
||||
<!-- 👉 Navbar -->
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
@@ -78,7 +216,7 @@ onMounted(() => {
|
||||
<!-- 👉 Spacer -->
|
||||
<VSpacer />
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="superUser" />
|
||||
<ShortcutBar v-if="superUser" ref="shortcutBarRef" />
|
||||
<!-- 👉 Notification -->
|
||||
<UserNofification />
|
||||
<!-- 👉 UserProfile -->
|
||||
@@ -123,22 +261,97 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #after-vertical-nav-items />
|
||||
<!-- 👉 Pages -->
|
||||
<slot />
|
||||
|
||||
<!-- 👉 下拉跟随动画 -->
|
||||
<div
|
||||
class="main-content-wrapper"
|
||||
:style="{
|
||||
transform: contentTransform,
|
||||
transition: contentTransition,
|
||||
}"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -3,9 +3,13 @@ import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { useDisplay } from 'vuetify'
|
||||
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()
|
||||
|
||||
// 判断当前是否为英文环境
|
||||
@@ -13,8 +17,33 @@ const isEnglish = computed(() => locale.value === 'en-US')
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 用户Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => {
|
||||
// 确保用户已认证且信息已加载
|
||||
if (!userStore || userStore.userID === -1) {
|
||||
return {
|
||||
is_superuser: false,
|
||||
discovery: false,
|
||||
search: false,
|
||||
subscribe: false,
|
||||
manage: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取导航菜单
|
||||
const navMenus = computed(() => getNavMenus())
|
||||
const navMenus = computed(() => {
|
||||
const allMenus = getNavMenus()
|
||||
return filterMenusByPermission(allMenus, userPermissions.value)
|
||||
})
|
||||
|
||||
// 根据当前路径获取匹配的菜单路径
|
||||
function getMenuPathFromRoute(path: string): string {
|
||||
@@ -27,7 +56,42 @@ const currentMenu = ref<string>(getMenuPathFromRoute(route.path))
|
||||
|
||||
// 过滤出底部菜单项
|
||||
const footerMenus = computed(() => {
|
||||
return navMenus.value.filter((menu: NavMenu) => menu.footer === true)
|
||||
// 获取所有有权限的菜单
|
||||
const allAuthorizedMenus = navMenus.value
|
||||
|
||||
// 优先获取有 footer: true 属性的菜单
|
||||
const footerMenusWithProperty = allAuthorizedMenus.filter((menu: NavMenu) => menu.footer === true)
|
||||
|
||||
// 设置期望的底部菜单数量(不包括"更多"按钮)
|
||||
// 一般来说,底部导航栏显示 3-4 个主要功能比较合适
|
||||
const expectedFooterMenuCount = 3
|
||||
|
||||
// 如果有 footer 属性的菜单已经足够,优先显示它们
|
||||
if (footerMenusWithProperty.length >= expectedFooterMenuCount) {
|
||||
return footerMenusWithProperty.slice(0, expectedFooterMenuCount)
|
||||
}
|
||||
|
||||
// 如果不够,从没有 footer 属性或 footer 为 false 的菜单中补充
|
||||
// 优先选择一些常用的功能菜单
|
||||
const nonFooterMenus = allAuthorizedMenus.filter(
|
||||
(menu: NavMenu) =>
|
||||
menu.footer !== true &&
|
||||
// 排除已经在 footerMenusWithProperty 中的菜单
|
||||
!footerMenusWithProperty.some(footerMenu => footerMenu.to === menu.to),
|
||||
)
|
||||
|
||||
// 计算还需要多少个菜单
|
||||
const needCount = expectedFooterMenuCount - footerMenusWithProperty.length
|
||||
|
||||
// 合并菜单:优先显示有 footer 属性的,然后按菜单定义顺序添加其他菜单
|
||||
let finalMenus = [...footerMenusWithProperty, ...nonFooterMenus.slice(0, needCount)]
|
||||
|
||||
// 确保至少有一个菜单显示,如果都没有权限,则显示第一个有权限的菜单
|
||||
if (finalMenus.length === 0 && allAuthorizedMenus.length > 0) {
|
||||
finalMenus = [allAuthorizedMenus[0]]
|
||||
}
|
||||
|
||||
return finalMenus
|
||||
})
|
||||
|
||||
// 监听路由变化来更新currentMenu
|
||||
@@ -117,7 +181,7 @@ const showDynamicButton = computed(() => {
|
||||
:value="menu.to"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon :icon="menu.icon" :size="isEnglish ? 32 : 24"></VIcon>
|
||||
<VIcon :icon="menu.icon" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ menu.title }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
@@ -134,8 +198,8 @@ const showDynamicButton = computed(() => {
|
||||
value="/apps"
|
||||
>
|
||||
<div class="btn-content">
|
||||
<VIcon icon="mdi-dots-horizontal" :size="isEnglish ? 32 : 24"></VIcon>
|
||||
<span v-if="!isEnglish" class="btn-text">{{ t('nav.more') }}</span>
|
||||
<VIcon icon="mdi-dots-horizontal" size="32"></VIcon>
|
||||
<span v-if="!isEnglish" class="text-xs">{{ t('nav.more') }}</span>
|
||||
</div>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
@@ -153,7 +217,7 @@ const showDynamicButton = computed(() => {
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="24"></VIcon>
|
||||
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="28"></VIcon>
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -191,12 +255,17 @@ const showDynamicButton = computed(() => {
|
||||
&.shift-left {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.v-btn-toggle {
|
||||
block-size: auto;
|
||||
min-block-size: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-card-content {
|
||||
position: relative;
|
||||
padding-block: 6px;
|
||||
padding-inline: 8px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.footer-btn-group {
|
||||
@@ -212,8 +281,11 @@ const showDynamicButton = computed(() => {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 0;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
block-size: 48px;
|
||||
|
||||
&.v-btn--active {
|
||||
background-color: transparent;
|
||||
@@ -229,12 +301,8 @@ const showDynamicButton = computed(() => {
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
max-inline-size: 100%;
|
||||
scale: var(--text-scale, 1);
|
||||
text-overflow: ellipsis;
|
||||
transform-origin: center;
|
||||
transition: scale 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -252,9 +320,9 @@ const showDynamicButton = computed(() => {
|
||||
|
||||
.footer-nav-btn {
|
||||
padding: 0;
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
min-inline-size: 36px;
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
min-inline-size: 40px;
|
||||
|
||||
.btn-content {
|
||||
margin: 0;
|
||||
|
||||
@@ -191,6 +191,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 +207,7 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
padding-block: 6px;
|
||||
padding-inline: 14px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -224,6 +226,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 +234,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>
|
||||
@@ -9,6 +9,7 @@ import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { getQueryValue } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { clearAppBadge } from '@/utils/badge'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -103,9 +104,38 @@ function openDialog(dialogRef: any) {
|
||||
dialogRef.value = true
|
||||
}
|
||||
|
||||
// 滚动到底部
|
||||
// 打开消息弹窗并清除徽章
|
||||
async function openMessageDialog() {
|
||||
messageDialog.value = true
|
||||
// 延迟清除徽章,确保对话框已经打开
|
||||
setTimeout(async () => {
|
||||
await clearAppBadge()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 智能滚动到底部(只有用户在底部附近时才滚动)
|
||||
function scrollMessageToEnd() {
|
||||
// 使用更长的延迟确保DOM已更新
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const cardText = document.querySelector('.v-dialog .v-card-text')
|
||||
if (cardText) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = cardText
|
||||
// 计算距离底部的距离
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
// 如果用户距离底部小于1/3屏幕高度,认为用户在底部附近,执行自动滚动
|
||||
if (distanceFromBottom <= clientHeight / 3) {
|
||||
cardText.scrollTop = cardText.scrollHeight
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, 500) // 增加延迟时间
|
||||
}
|
||||
|
||||
// 强制滚动到底部(用于发送消息后)
|
||||
function forceScrollToEnd() {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const cardText = document.querySelector('.v-dialog .v-card-text')
|
||||
@@ -115,7 +145,7 @@ function scrollMessageToEnd() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}, 500) // 增加延迟时间
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 拼接全部日志url
|
||||
@@ -131,15 +161,25 @@ async function sendMessage() {
|
||||
await api.post(`message/web?text=${user_message.value}`)
|
||||
user_message.value = ''
|
||||
sendButtonDisabled.value = false
|
||||
scrollMessageToEnd()
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 供外部调用的打开消息弹窗方法
|
||||
function openMessageDialogFromExternal() {
|
||||
openMessageDialog()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
openMessageDialog: openMessageDialogFromExternal,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
scrollMessageToEnd()
|
||||
forceScrollToEnd() // 初始化时强制滚动到底部
|
||||
const shortcut = getQueryValue('shortcut')
|
||||
if (shortcut) {
|
||||
const found = shortcuts.find(item => item.dialog === shortcut)
|
||||
@@ -187,7 +227,7 @@ onMounted(() => {
|
||||
flat
|
||||
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full"
|
||||
hover
|
||||
@click="openDialog(item.dialogRef)"
|
||||
@click="item.dialog === 'message' ? openMessageDialog() : openDialog(item.dialogRef)"
|
||||
>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
<VIcon color="primary" :icon="item.icon" size="24" />
|
||||
|
||||
@@ -17,6 +17,16 @@ let eventSource: EventSource | null = null
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
|
||||
// 标记所有消息为已读
|
||||
function markAllAsRead() {
|
||||
hasNewMessage.value = false
|
||||
// 标记所有消息为已读
|
||||
notificationList.value.forEach(item => {
|
||||
item.read = true
|
||||
})
|
||||
appsMenu.value = false
|
||||
}
|
||||
|
||||
// SSE持续接收消息
|
||||
function startSSEMessager() {
|
||||
// 延迟 3 秒启动 SSE,避免相关认证信息尚未写入 Cookie 导致 403
|
||||
@@ -70,15 +80,7 @@ onBeforeUnmount(() => {
|
||||
<template #append>
|
||||
<VTooltip :text="t('notification.markRead')">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn
|
||||
v-bind="props"
|
||||
@click="
|
||||
() => {
|
||||
hasNewMessage = false
|
||||
appsMenu = false
|
||||
}
|
||||
"
|
||||
>
|
||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
@@ -87,7 +89,7 @@ onBeforeUnmount(() => {
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<div class="notification-list-container">
|
||||
<div v-if="notificationList.length > 0" class="h-full overflow-y-auto">
|
||||
<div v-if="notificationList.length > 0">
|
||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
||||
<template #prepend>
|
||||
<VAvatar rounded>
|
||||
@@ -120,7 +122,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.notification-list-container {
|
||||
overflow: hidden;
|
||||
max-block-size: 50vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from 'vue-toast-notification'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import router from '@/router'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import api from '@/api'
|
||||
@@ -45,36 +45,118 @@ const showLanguageMenu = ref(false)
|
||||
// 自定义CSS
|
||||
const customCSS = ref('')
|
||||
|
||||
// 重启轮询控制标识
|
||||
const restartPollingId = ref<number | null>(null)
|
||||
const isRestarting = ref(false)
|
||||
|
||||
// 确认框
|
||||
const { createConfirm } = useConfirm()
|
||||
|
||||
// 执行注销操作
|
||||
function logout() {
|
||||
// 清理重启相关状态
|
||||
isRestarting.value = false
|
||||
if (restartPollingId.value) {
|
||||
clearTimeout(restartPollingId.value)
|
||||
restartPollingId.value = null
|
||||
}
|
||||
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
userStore.reset()
|
||||
// 重定向到登录页面或其他适当的页面
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// 检测服务状态
|
||||
async function checkServiceStatus(): Promise<boolean> {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/env', { timeout: 3000 })
|
||||
return result?.success === true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询检测服务恢复状态
|
||||
async function pollServiceStatus() {
|
||||
// 如果已经有轮询在运行,先清除
|
||||
if (restartPollingId.value) {
|
||||
clearTimeout(restartPollingId.value)
|
||||
restartPollingId.value = null
|
||||
}
|
||||
|
||||
// 最大重试次数(约3分钟)
|
||||
const maxRetries = 60
|
||||
let retryCount = 0
|
||||
|
||||
const poll = async () => {
|
||||
// 如果不在重启状态,停止轮询
|
||||
if (!isRestarting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
retryCount++
|
||||
const isServiceUp = await checkServiceStatus()
|
||||
|
||||
if (isServiceUp) {
|
||||
// 服务已恢复,清理状态并执行注销
|
||||
isRestarting.value = false
|
||||
progressDialog.value = false
|
||||
restartPollingId.value = null
|
||||
|
||||
setTimeout(() => {
|
||||
logout()
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
// 超时未恢复,清理状态并提示用户
|
||||
isRestarting.value = false
|
||||
progressDialog.value = false
|
||||
restartPollingId.value = null
|
||||
$toast.error(t('app.restartTimeout'))
|
||||
return
|
||||
}
|
||||
|
||||
// 继续轮询,每3秒检测一次
|
||||
restartPollingId.value = setTimeout(poll, 3000) as unknown as number
|
||||
}
|
||||
|
||||
// 开始轮询
|
||||
poll()
|
||||
}
|
||||
|
||||
// 执行重启操作
|
||||
async function restart() {
|
||||
// 设置重启状态
|
||||
isRestarting.value = true
|
||||
|
||||
// 调用API重启
|
||||
try {
|
||||
// 显示等待框
|
||||
progressDialog.value = true
|
||||
const result: { [key: string]: any } = await api.get('system/restart')
|
||||
if (!result?.success) {
|
||||
// 隐藏等待框
|
||||
// 重启失败,清理状态
|
||||
isRestarting.value = false
|
||||
progressDialog.value = false
|
||||
// 重启不成功
|
||||
$toast.error(result.message)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
// 重启失败,清理状态
|
||||
isRestarting.value = false
|
||||
progressDialog.value = false
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
// 注销
|
||||
logout()
|
||||
|
||||
// 重启请求成功,开始轮询检测服务状态
|
||||
setTimeout(() => {
|
||||
pollServiceStatus()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 显示重启确认对话框
|
||||
@@ -257,6 +339,16 @@ const getThemeIcon = computed(() => {
|
||||
onMounted(() => {
|
||||
getCustomCSS()
|
||||
})
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
onUnmounted(() => {
|
||||
// 清理重启轮询
|
||||
if (restartPollingId.value) {
|
||||
clearTimeout(restartPollingId.value)
|
||||
restartPollingId.value = null
|
||||
}
|
||||
isRestarting.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -282,7 +374,7 @@ onMounted(() => {
|
||||
</template>
|
||||
<div>
|
||||
<span class="text-primary text-sm font-medium d-block">
|
||||
{{ superUser ? t('user.admin') : t('user.normalUser') }}
|
||||
{{ superUser ? t('user.admin') : t('user.normal') }}
|
||||
</span>
|
||||
<span class="text-high-emphasis text-lg font-weight-bold">
|
||||
{{ userName }}
|
||||
@@ -299,7 +391,7 @@ onMounted(() => {
|
||||
<VListItemTitle>{{ t('user.profile') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
|
||||
<VListItem link @click="router.push('/setting')" class="mb-1 rounded-lg" hover>
|
||||
<VListItem v-if="superUser" link @click="router.push('/setting')" class="mb-1 rounded-lg" hover>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog-outline" />
|
||||
</template>
|
||||
|
||||
@@ -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',
|
||||
@@ -124,6 +141,12 @@ export default {
|
||||
restarting: 'Restarting...',
|
||||
confirmRestart: 'Confirm restart system?',
|
||||
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',
|
||||
@@ -135,6 +158,7 @@ export default {
|
||||
networkError: 'Login failed, please check your network connection!',
|
||||
authFailure: 'Login failed, please check your username, password or two-factor authentication!',
|
||||
permissionDenied: 'Login failed, you do not have permission to access!',
|
||||
noPermission: 'Login failed, you have no functional permissions, please contact the administrator!',
|
||||
serverError: 'Login failed, server error!',
|
||||
loginFailed: 'Login Failed',
|
||||
checkCredentials: 'Please check your username, password or two-factor authentication code!',
|
||||
@@ -324,6 +348,9 @@ export default {
|
||||
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
usersPlaceholder: 'User IDs list, separated by commas',
|
||||
apiUrl: 'Proxy Api Url',
|
||||
apiUrlHint: 'Custom proxy Api Url, format: https://api.telegram.org',
|
||||
apiUrlPlaceholder: 'https://api.telegram.org',
|
||||
},
|
||||
slack: {
|
||||
name: 'Slack',
|
||||
@@ -569,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',
|
||||
@@ -727,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',
|
||||
},
|
||||
@@ -742,6 +772,7 @@ export default {
|
||||
sortSite: 'Site',
|
||||
sortSize: 'Size',
|
||||
sortSeeder: 'Seeder',
|
||||
sortPublishTime: 'Publish Time',
|
||||
filterSite: 'Site',
|
||||
filterSeason: 'Season',
|
||||
filterFreeState: 'Free State',
|
||||
@@ -767,7 +798,8 @@ export default {
|
||||
alipan: 'Aliyun Drive',
|
||||
u115: '115 Cloud',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB Network Share',
|
||||
custom: 'Custom',
|
||||
},
|
||||
filterRules: {
|
||||
@@ -877,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}',
|
||||
@@ -1013,8 +1049,6 @@ export default {
|
||||
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
|
||||
bigMemoryMode: 'Large Memory Mode',
|
||||
bigMemoryModeHint: 'Use more memory to cache data and improve system performance',
|
||||
memoryMonitorEnable: 'Memory Monitor',
|
||||
memoryMonitorEnableHint: 'Monitor system memory usage and force memory recycling when exceeded',
|
||||
dbWalEnable: 'WAL Mode',
|
||||
dbWalEnableHint:
|
||||
'Can improve read/write concurrency performance, but may increase the risk of data loss in exceptional cases, requires restart to take effect',
|
||||
@@ -1040,6 +1074,8 @@ export default {
|
||||
scrapOriginalImageHint: 'Scrap original language image from themoviedb, otherwise scrap metadata language image',
|
||||
fanartEnable: 'Fanart Image Data Source',
|
||||
fanartEnableHint: 'Use image data from fanart.tv',
|
||||
fanartLang: 'Fanart Language',
|
||||
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
|
||||
githubProxy: 'Github Acceleration Proxy',
|
||||
githubProxyPlaceholder: 'Leave empty for no proxy',
|
||||
githubProxyHint: 'Use proxy to accelerate Github access speed',
|
||||
@@ -1082,6 +1118,18 @@ export default {
|
||||
zhTW: 'Traditional Chinese',
|
||||
en: 'English',
|
||||
},
|
||||
fanartLanguage: {
|
||||
zh: 'Chinese',
|
||||
en: 'English',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
it: 'Italian',
|
||||
pt: 'Portuguese',
|
||||
ru: 'Russian',
|
||||
},
|
||||
logLevelItems: {
|
||||
debug: 'DEBUG',
|
||||
info: 'INFO',
|
||||
@@ -1112,6 +1160,34 @@ export default {
|
||||
moviePilotAutoUpdateHint: 'Automatically update MoviePilot to the latest release version when restarting',
|
||||
autoUpdateResource: 'Auto Update Resource',
|
||||
autoUpdateResourceHint: 'Automatically detect and update site resource package when restarting',
|
||||
// Scraping Switch Settings
|
||||
scrapingSwitchSettings: 'Scraping Switch Settings',
|
||||
scrapingSwitchSettingsDesc: 'Control various media file scraping function switches',
|
||||
movie: 'Movie',
|
||||
tv: 'TV Show',
|
||||
season: 'Season',
|
||||
episode: 'Episode',
|
||||
movieNfo: 'NFO',
|
||||
seasonNfo: 'NFO',
|
||||
moviePoster: 'Poster',
|
||||
movieBackdrop: 'Backdrop',
|
||||
movieLogo: 'Logo',
|
||||
movieDisc: 'Disc',
|
||||
movieBanner: 'Banner',
|
||||
movieThumb: 'Thumb',
|
||||
tvNfo: 'NFO',
|
||||
tvPoster: 'Poster',
|
||||
tvBackdrop: 'Backdrop',
|
||||
tvBanner: 'Banner',
|
||||
tvLogo: 'Logo',
|
||||
tvThumb: 'Thumb',
|
||||
seasonPoster: 'Poster',
|
||||
seasonBanner: 'Banner',
|
||||
seasonThumb: 'Thumb',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: 'Thumb',
|
||||
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
|
||||
scrapingSwitchSaveError: 'Scraping switch settings save failed',
|
||||
},
|
||||
site: {
|
||||
siteSync: 'Site Synchronization',
|
||||
@@ -1511,6 +1587,19 @@ export default {
|
||||
saveUserInfo: 'Save User Information',
|
||||
cannotDeleteCurrentUser: 'Cannot delete current logged-in user',
|
||||
deleteUser: 'Delete User',
|
||||
permissions: {
|
||||
title: 'Permission Settings',
|
||||
presetNormal: 'Normal User',
|
||||
presetAdmin: 'Administrator',
|
||||
discovery: 'Discovery',
|
||||
discoveryDesc: 'Access recommendation and exploration features',
|
||||
search: 'Search',
|
||||
searchDesc: 'Search site resources and add downloads',
|
||||
subscribe: 'Subscribe',
|
||||
subscribeDesc: 'Manage movie and TV show subscriptions',
|
||||
manage: 'Manage',
|
||||
manageDesc: 'Access download management and site management etc.',
|
||||
},
|
||||
},
|
||||
searchBar: {
|
||||
search: 'Search',
|
||||
@@ -1595,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',
|
||||
@@ -1609,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',
|
||||
@@ -2022,6 +2126,7 @@ export default {
|
||||
filterPlugins: 'Filter Plugins',
|
||||
name: 'Name',
|
||||
hasNewVersion: 'Has New Version',
|
||||
running: 'Running',
|
||||
author: 'Author',
|
||||
label: 'Label',
|
||||
repository: 'Repository',
|
||||
@@ -2112,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: '电视剧订阅',
|
||||
@@ -124,6 +141,12 @@ export default {
|
||||
restarting: '正在重启...',
|
||||
confirmRestart: '确认重启系统吗?',
|
||||
restartTip: '重启后,您将被注销并需要重新登录。',
|
||||
restartTimeout: '重启超时,系统可能需要更长时间恢复,请稍后手动刷新页面',
|
||||
restartFailed: '重启失败,请检查系统状态',
|
||||
offline: '离线模式',
|
||||
offlineMessage: '网络连接已断开,部分功能可能受限',
|
||||
online: '在线模式',
|
||||
onlineMessage: '网络连接已恢复',
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁纸',
|
||||
@@ -135,6 +158,7 @@ export default {
|
||||
networkError: '登录失败,请检查网络连接!',
|
||||
authFailure: '登录失败,请检查用户名、密码或双重验证是否正确!',
|
||||
permissionDenied: '登录失败,您没有权限访问!',
|
||||
noPermission: '登录失败,您没有任何功能权限,请联系管理员!',
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
checkCredentials: '请检查用户名、密码或双重验证码是否正确!',
|
||||
@@ -322,6 +346,9 @@ export default {
|
||||
adminsHint: '可使用管理菜单及命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
usersPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
apiUrl: '代理API地址',
|
||||
apiUrlHint: '自定义代理API地址,格式:https://api.telegram.org',
|
||||
apiUrlPlaceholder: 'https://api.telegram.org',
|
||||
},
|
||||
slack: {
|
||||
name: 'Slack',
|
||||
@@ -567,6 +594,9 @@ export default {
|
||||
scheduler: '后台任务',
|
||||
cpu: 'CPU',
|
||||
memory: '内存',
|
||||
network: '网络流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
library: '我的媒体库',
|
||||
playing: '继续观看',
|
||||
latest: '最近添加',
|
||||
@@ -724,7 +754,7 @@ export default {
|
||||
others: '其他',
|
||||
},
|
||||
notFound: {
|
||||
title: '页面不存在 ⚠️',
|
||||
title: '⚠️ 页面不存在',
|
||||
description: '您想要访问的页面不存在,请检查地址是否正确。',
|
||||
backButton: '返回',
|
||||
},
|
||||
@@ -739,6 +769,7 @@ export default {
|
||||
sortSite: '站点',
|
||||
sortSize: '大小',
|
||||
sortSeeder: '做种数',
|
||||
sortPublishTime: '发布时间',
|
||||
filterSite: '站点',
|
||||
filterSeason: '季',
|
||||
filterFreeState: '促销状态',
|
||||
@@ -764,7 +795,8 @@ export default {
|
||||
alipan: '阿里云盘',
|
||||
u115: '115网盘',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB网络共享',
|
||||
custom: '自定义',
|
||||
},
|
||||
filterRules: {
|
||||
@@ -874,6 +906,10 @@ export default {
|
||||
testing: '测试中 ...',
|
||||
testSuccess: '{name} 连通性测试成功,可正常使用!',
|
||||
testFailed: '{name} 连通性测试失败:{message}',
|
||||
connectionNormal: '连接正常',
|
||||
connectionSlow: '连接缓慢',
|
||||
connectionFailed: '连接失败',
|
||||
connectionUnknown: '连接未知',
|
||||
deleteConfirm: '是否确认删除站点?',
|
||||
deleteSuccess: '{name} 删除成功!',
|
||||
deleteFailed: '{name} 删除失败:{message}',
|
||||
@@ -1009,8 +1045,6 @@ export default {
|
||||
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
|
||||
bigMemoryMode: '大内存模式',
|
||||
bigMemoryModeHint: '使用更大的内存缓存数据,提升系统性能',
|
||||
memoryMonitorEnable: '内存监控',
|
||||
memoryMonitorEnableHint: '监控系统内存使用情况,超出阈值时强制回收内存',
|
||||
dbWalEnable: 'WAL模式',
|
||||
dbWalEnableHint: '可提升读写并发性能,但可能在异常情况下增加数据丢失风险,更改后需重启生效',
|
||||
tmdbApiDomain: 'TMDB API服务地址',
|
||||
@@ -1034,6 +1068,8 @@ export default {
|
||||
scrapOriginalImageHint: '刮削原语种图片,否则刮削元数据语种图片',
|
||||
fanartEnable: 'Fanart图片数据源',
|
||||
fanartEnableHint: '使用 fanart.tv 的图片数据',
|
||||
fanartLang: 'Fanart语言',
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github访问速度',
|
||||
@@ -1073,6 +1109,18 @@ export default {
|
||||
zhTW: '繁体中文',
|
||||
en: '英文',
|
||||
},
|
||||
fanartLanguage: {
|
||||
zh: '中文',
|
||||
en: '英文',
|
||||
ja: '日文',
|
||||
ko: '韩文',
|
||||
de: '德文',
|
||||
fr: '法文',
|
||||
es: '西班牙文',
|
||||
it: '意大利文',
|
||||
pt: '葡萄牙文',
|
||||
ru: '俄文',
|
||||
},
|
||||
logLevelItems: {
|
||||
debug: 'DEBUG - 调试',
|
||||
info: 'INFO - 信息',
|
||||
@@ -1102,6 +1150,34 @@ export default {
|
||||
moviePilotAutoUpdateHint: '重启时自动更新MoviePilot到最新发行版本',
|
||||
autoUpdateResource: '自动更新站点资源',
|
||||
autoUpdateResourceHint: '重启时自动检测和更新站点资源包',
|
||||
// 刮削开关设置
|
||||
scrapingSwitchSettings: '刮削开关设置',
|
||||
scrapingSwitchSettingsDesc: '控制各类媒体文件的刮削功能开关',
|
||||
movie: '电影',
|
||||
tv: '电视剧',
|
||||
season: '季',
|
||||
episode: '集',
|
||||
movieNfo: 'NFO',
|
||||
moviePoster: '海报',
|
||||
movieBackdrop: '背景图',
|
||||
movieLogo: 'Logo',
|
||||
movieDisc: '光盘图',
|
||||
movieBanner: '横幅图',
|
||||
movieThumb: '缩略图',
|
||||
tvNfo: 'NFO',
|
||||
seasonNfo: 'NFO',
|
||||
tvPoster: '海报',
|
||||
tvBackdrop: '背景图',
|
||||
tvBanner: '横幅图',
|
||||
tvLogo: 'Logo',
|
||||
tvThumb: '缩略图',
|
||||
seasonPoster: '海报',
|
||||
seasonBanner: '横幅图',
|
||||
seasonThumb: '缩略图',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: '缩略图',
|
||||
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
|
||||
scrapingSwitchSaveError: '刮削开关设置保存失败',
|
||||
},
|
||||
site: {
|
||||
siteSync: '站点同步',
|
||||
@@ -1491,6 +1567,19 @@ export default {
|
||||
saveUserInfo: '保存用户信息',
|
||||
cannotDeleteCurrentUser: '不能删除当前登录用户',
|
||||
deleteUser: '删除用户',
|
||||
permissions: {
|
||||
title: '权限设置',
|
||||
presetNormal: '普通用户',
|
||||
presetAdmin: '管理员',
|
||||
discovery: '发现',
|
||||
discoveryDesc: '访问推荐和探索功能',
|
||||
search: '搜索',
|
||||
searchDesc: '搜索站点资源和添加下载',
|
||||
subscribe: '订阅',
|
||||
subscribeDesc: '管理电影和电视剧订阅',
|
||||
manage: '管理',
|
||||
manageDesc: '访问下载管理和站点管理等功能',
|
||||
},
|
||||
},
|
||||
searchBar: {
|
||||
search: '搜索',
|
||||
@@ -1573,8 +1662,8 @@ export default {
|
||||
reset: '重置',
|
||||
},
|
||||
alistConfig: {
|
||||
title: 'Alist配置',
|
||||
serverUrl: 'Alist服务地址',
|
||||
title: 'OpenList配置',
|
||||
serverUrl: 'OpenList服务地址',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
tokenUrl: '获取Token地址',
|
||||
@@ -1587,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: '编辑工作流',
|
||||
@@ -2000,6 +2104,7 @@ export default {
|
||||
filterPlugins: '过滤插件',
|
||||
name: '名称',
|
||||
hasNewVersion: '有新版本',
|
||||
running: '运行中',
|
||||
author: '作者',
|
||||
label: '标签',
|
||||
repository: '仓库',
|
||||
@@ -2087,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: '電視劇訂閱',
|
||||
@@ -125,6 +142,12 @@ export default {
|
||||
restarting: '正在重啟...',
|
||||
confirmRestart: '確認重啟系統嗎?',
|
||||
restartTip: '重啟後,您將被註銷並需要重新登錄。',
|
||||
restartTimeout: '重啟超時,系統可能需要更長時間恢復,請稍後手動刷新頁面',
|
||||
restartFailed: '重啟失敗,請檢查系統狀態',
|
||||
offline: '離線模式',
|
||||
offlineMessage: '網絡連接已斷開,部分功能可能受限',
|
||||
online: '在線模式',
|
||||
onlineMessage: '網絡連接已恢復',
|
||||
},
|
||||
login: {
|
||||
wallpapers: '壁紙',
|
||||
@@ -137,6 +160,7 @@ export default {
|
||||
authFailure: '登錄失敗,請檢查用戶名、密碼或雙重驗證是否正確!',
|
||||
permissionDenied: '登錄失敗,您沒有權限訪問!',
|
||||
serverError: '登錄失敗,服務器錯誤!',
|
||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||
loginFailed: '登錄失敗',
|
||||
checkCredentials: '請檢查用戶名、密碼或雙重驗證碼是否正確!',
|
||||
},
|
||||
@@ -568,6 +592,9 @@ export default {
|
||||
scheduler: '後台任務',
|
||||
cpu: 'CPU',
|
||||
memory: '內存',
|
||||
network: '網絡流量',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
library: '我的媒體庫',
|
||||
playing: '繼續觀看',
|
||||
latest: '最近添加',
|
||||
@@ -725,7 +752,7 @@ export default {
|
||||
others: '其他',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在 ⚠️',
|
||||
title: '⚠️ 頁面不存在',
|
||||
description: '您想要訪問的頁面不存在,請檢查地址是否正確。',
|
||||
backButton: '返回',
|
||||
},
|
||||
@@ -740,6 +767,7 @@ export default {
|
||||
sortSite: '站點',
|
||||
sortSize: '大小',
|
||||
sortSeeder: '做種數',
|
||||
sortPublishTime: '發布時間',
|
||||
filterSite: '站點',
|
||||
filterSeason: '季',
|
||||
filterFreeState: '促銷狀態',
|
||||
@@ -765,7 +793,8 @@ export default {
|
||||
alipan: '阿里雲盤',
|
||||
u115: '115網盤',
|
||||
rclone: 'RClone',
|
||||
alist: 'AList',
|
||||
alist: 'OpenList',
|
||||
smb: 'SMB網路共享',
|
||||
custom: '自定義',
|
||||
},
|
||||
|
||||
@@ -876,6 +905,10 @@ export default {
|
||||
testing: '測試中 ...',
|
||||
testSuccess: '{name} 連通性測試成功,可正常使用!',
|
||||
testFailed: '{name} 連通性測試失敗:{message}',
|
||||
connectionNormal: '連接正常',
|
||||
connectionSlow: '連接緩慢',
|
||||
connectionFailed: '連接失敗',
|
||||
connectionUnknown: '連接未知',
|
||||
deleteConfirm: '是否確認刪除站點?',
|
||||
deleteSuccess: '{name} 刪除成功!',
|
||||
deleteFailed: '{name} 刪除失敗:{message}',
|
||||
@@ -1011,8 +1044,6 @@ export default {
|
||||
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
|
||||
bigMemoryMode: '大內存模式',
|
||||
bigMemoryModeHint: '使用更大的內存緩存數據,提升系統性能',
|
||||
memoryMonitorEnable: '內存監控',
|
||||
memoryMonitorEnableHint: '監控系統內存使用情況,超出閾值時強制回收內存',
|
||||
dbWalEnable: 'WAL模式',
|
||||
dbWalEnableHint: '可提升讀寫併發性能,但可能在異常情況下增加數據丟失風險,更改後需重啟生效',
|
||||
tmdbApiDomain: 'TMDB API服務地址',
|
||||
@@ -1036,6 +1067,8 @@ export default {
|
||||
scrapOriginalImageHint: '刮削原語种圖片,否则數據元数据語种圖片',
|
||||
fanartEnable: 'Fanart圖片數據源',
|
||||
fanartEnableHint: '使用 fanart.tv 的圖片數據',
|
||||
fanartLang: 'Fanart語言',
|
||||
fanartLangHint: '設定Fanart圖片的語言偏好,多選時按優先級順序排列',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github訪問速度',
|
||||
@@ -1075,6 +1108,18 @@ export default {
|
||||
zhTW: '繁體中文',
|
||||
en: '英文',
|
||||
},
|
||||
fanartLanguage: {
|
||||
zh: '中文',
|
||||
en: '英文',
|
||||
ja: '日文',
|
||||
ko: '韓文',
|
||||
de: '德文',
|
||||
fr: '法文',
|
||||
es: '西班牙文',
|
||||
it: '意大利文',
|
||||
pt: '葡萄牙文',
|
||||
ru: '俄文',
|
||||
},
|
||||
logLevelItems: {
|
||||
debug: 'DEBUG - 調試',
|
||||
info: 'INFO - 信息',
|
||||
@@ -1104,6 +1149,34 @@ export default {
|
||||
moviePilotAutoUpdateHint: '重啟時自動更新MoviePilot到最新發行版本',
|
||||
autoUpdateResource: '自動更新站點資源',
|
||||
autoUpdateResourceHint: '重啟時自動檢測和更新站點資源包',
|
||||
// 刮削開關設定
|
||||
scrapingSwitchSettings: '刮削開關設定',
|
||||
scrapingSwitchSettingsDesc: '控制各類媒體檔案的刮削功能開關',
|
||||
movie: '電影',
|
||||
tv: '電視劇',
|
||||
season: '季',
|
||||
episode: '集',
|
||||
movieNfo: 'NFO',
|
||||
moviePoster: '海報',
|
||||
movieBackdrop: '背景圖',
|
||||
movieLogo: 'Logo',
|
||||
movieDisc: '光碟圖',
|
||||
movieBanner: '橫幅圖',
|
||||
movieThumb: '縮略圖',
|
||||
tvNfo: 'NFO',
|
||||
seasonNfo: 'NFO',
|
||||
tvPoster: '海報',
|
||||
tvBackdrop: '背景圖',
|
||||
tvBanner: '橫幅圖',
|
||||
tvLogo: 'Logo',
|
||||
tvThumb: '縮略圖',
|
||||
seasonPoster: '海報',
|
||||
seasonBanner: '橫幅圖',
|
||||
seasonThumb: '縮略圖',
|
||||
episodeNfo: 'NFO',
|
||||
episodeThumb: '縮略圖',
|
||||
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
|
||||
scrapingSwitchSaveError: '刮削開關設定保存失敗',
|
||||
},
|
||||
site: {
|
||||
siteSync: '站點同步',
|
||||
@@ -1493,6 +1566,19 @@ export default {
|
||||
saveUserInfo: '保存用戶信息',
|
||||
cannotDeleteCurrentUser: '不能刪除當前登錄用戶',
|
||||
deleteUser: '刪除用戶',
|
||||
permissions: {
|
||||
title: '權限設置',
|
||||
presetNormal: '普通用戶',
|
||||
presetAdmin: '管理員',
|
||||
discovery: '發現',
|
||||
discoveryDesc: '存取推薦和探索功能',
|
||||
search: '搜索',
|
||||
searchDesc: '搜索站點資源和添加下載',
|
||||
subscribe: '訂閱',
|
||||
subscribeDesc: '管理電影和電視劇訂閱',
|
||||
manage: '管理',
|
||||
manageDesc: '存取下載管理和站點管理等功能',
|
||||
},
|
||||
},
|
||||
searchBar: {
|
||||
search: '搜索',
|
||||
@@ -1575,8 +1661,8 @@ export default {
|
||||
reset: '重置',
|
||||
},
|
||||
alistConfig: {
|
||||
title: 'Alist配置',
|
||||
serverUrl: 'Alist服務地址',
|
||||
title: 'OpenList配置',
|
||||
serverUrl: 'OpenList服務地址',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
tokenUrl: '獲取Token地址',
|
||||
@@ -1589,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: '編輯工作流',
|
||||
@@ -2002,6 +2103,7 @@ export default {
|
||||
filterPlugins: '過濾插件',
|
||||
name: '名稱',
|
||||
hasNewVersion: '有新版本',
|
||||
running: '運行中',
|
||||
author: '作者',
|
||||
label: '標籤',
|
||||
repository: '倉庫',
|
||||
@@ -2089,6 +2191,12 @@ export default {
|
||||
cloneFailed: '插件分身創建失敗:{message}',
|
||||
cloneFailedGeneral: '插件分身創建失敗',
|
||||
logTitle: '插件日誌',
|
||||
quickAccess: '快速訪問',
|
||||
noPluginsWithPage: '暫無可展示的插件',
|
||||
tapToOpen: '點擊返回主界面',
|
||||
recentlyUsed: '最近使用',
|
||||
allPlugins: '所有插件',
|
||||
noRecentPlugins: '無',
|
||||
},
|
||||
profile: {
|
||||
personalInfo: '個人信息',
|
||||
|
||||
97
src/main.ts
97
src/main.ts
@@ -18,12 +18,10 @@ 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 ToastPlugin from 'vue-toast-notification'
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
|
||||
@@ -51,58 +49,43 @@ const app = createApp(App)
|
||||
// 注册pinia
|
||||
app.use(pinia)
|
||||
|
||||
// 初始化配置
|
||||
async function initializeApp() {
|
||||
try {
|
||||
// 是否为PWA
|
||||
const pwaMode = await isPWA()
|
||||
app.provide('pwaMode', pwaMode)
|
||||
|
||||
// 全局设置
|
||||
const globalSettings = await fetchGlobalSettings()
|
||||
app.provide('globalSettings', globalSettings)
|
||||
|
||||
// 加载并注册远程联邦组件
|
||||
await loadRemoteComponents()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 注册全局组件
|
||||
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(ToastPlugin, {
|
||||
position: 'bottom-right',
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
// 异步加载远程组件(不阻塞启动)
|
||||
loadRemoteComponents().catch(error => {
|
||||
console.error('Failed to load remote components', error)
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
// 4. 注册其他插件
|
||||
app
|
||||
.use(PerfectScrollbarPlugin)
|
||||
.use(Toast, {
|
||||
position: 'bottom-right',
|
||||
hideProgressBar: true,
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,20 +3,29 @@ import { NavMenu } from '@/@layouts/types'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 Store 中获取superuser信息
|
||||
const superUser = useUserStore().superUser
|
||||
// 从 Store 中获取用户信息
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
is_superuser: userStore.superUser,
|
||||
...userStore.permissions,
|
||||
}))
|
||||
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取可见的菜单项
|
||||
const menus = getNavMenus().filter((item: NavMenu) => (!item.admin || superUser) && !item.footer)
|
||||
// 获取所有菜单并根据权限过滤
|
||||
const allMenus = getNavMenus()
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
// 按header属性分组
|
||||
const groupedMenus: Record<string, NavMenu[]> = {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { urlBase64ToUint8Array } from '@/@core/utils/navigator'
|
||||
import { SUPPORTED_LOCALES, SupportedLocale } from '@/types/i18n'
|
||||
import { getCurrentLocale, setI18nLanguage } from '@/plugins/i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -19,6 +21,9 @@ const authStore = useAuthStore()
|
||||
//用户 Store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 获取有权限的菜单
|
||||
const navMenus = getNavMenus()
|
||||
|
||||
// 表单
|
||||
const form = ref({
|
||||
username: '',
|
||||
@@ -111,9 +116,15 @@ async function subscribeForPushNotifications() {
|
||||
}
|
||||
|
||||
// 登录后处理
|
||||
async function afterLogin(superuser: boolean) {
|
||||
// 跳转到首页或回原始页面
|
||||
router.push(authStore.originalPath ?? '/')
|
||||
async function afterLogin(superuser: boolean, userPayload: userState, filteredMenus: any[]) {
|
||||
// 如果有原始路径,优先跳转到原始路径
|
||||
if (authStore.originalPath && authStore.originalPath !== '/') {
|
||||
router.push(authStore.originalPath)
|
||||
} else {
|
||||
// 跳转到第一个有权限的菜单
|
||||
router.push(filteredMenus[0].to)
|
||||
}
|
||||
|
||||
// 订阅推送通知
|
||||
if (superuser) await subscribeForPushNotifications()
|
||||
// 登录按钮 loading
|
||||
@@ -147,11 +158,6 @@ function login() {
|
||||
},
|
||||
})
|
||||
.then((response: any) => {
|
||||
const authPayLoad: authState = {
|
||||
token: response.access_token,
|
||||
remember: form.value.remember,
|
||||
}
|
||||
|
||||
const userPayload: userState = {
|
||||
superUser: response.super_user,
|
||||
userID: response.user_id,
|
||||
@@ -161,11 +167,32 @@ function login() {
|
||||
permissions: response.permissions,
|
||||
}
|
||||
|
||||
// 在保存用户信息之前检查权限
|
||||
const userPermissions = {
|
||||
is_superuser: userPayload.superUser,
|
||||
...userPayload.permissions,
|
||||
}
|
||||
|
||||
const filteredMenus = filterMenusByPermission(navMenus, userPermissions)
|
||||
// 如果用户没有任何可用菜单,拒绝登录
|
||||
if (filteredMenus.length === 0) {
|
||||
// 显示错误信息
|
||||
errorMessage.value = t('login.noPermission')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 权限检查通过,保存用户信息
|
||||
const authPayLoad: authState = {
|
||||
token: response.access_token,
|
||||
remember: form.value.remember,
|
||||
}
|
||||
|
||||
authStore.login(authPayLoad)
|
||||
userStore.loginUser(userPayload)
|
||||
|
||||
// 登录后处理
|
||||
afterLogin(userPayload.superUser)
|
||||
afterLogin(userPayload.superUser, userPayload, filteredMenus)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
// 登录失败,显示错误提示
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 初始加载状态 -->
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,6 +12,7 @@ export function getNavMenus() {
|
||||
header: t('menu.start'),
|
||||
admin: false,
|
||||
footer: true,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.searchResult'),
|
||||
@@ -19,6 +20,7 @@ export function getNavMenus() {
|
||||
to: '/resource',
|
||||
header: t('menu.start'),
|
||||
admin: false,
|
||||
permission: 'search',
|
||||
},
|
||||
{
|
||||
title: t('navItems.recommend'),
|
||||
@@ -27,6 +29,7 @@ export function getNavMenus() {
|
||||
header: t('menu.discovery'),
|
||||
admin: false,
|
||||
footer: true,
|
||||
permission: 'discovery',
|
||||
},
|
||||
{
|
||||
title: t('navItems.explore'),
|
||||
@@ -35,6 +38,7 @@ export function getNavMenus() {
|
||||
header: t('menu.discovery'),
|
||||
admin: false,
|
||||
footer: true,
|
||||
permission: 'discovery',
|
||||
},
|
||||
{
|
||||
title: t('navItems.movie'),
|
||||
@@ -44,6 +48,7 @@ export function getNavMenus() {
|
||||
header: t('menu.subscribe'),
|
||||
admin: false,
|
||||
footer: false,
|
||||
permission: 'subscribe',
|
||||
},
|
||||
{
|
||||
title: t('navItems.tv'),
|
||||
@@ -53,6 +58,7 @@ export function getNavMenus() {
|
||||
header: t('menu.subscribe'),
|
||||
admin: false,
|
||||
footer: false,
|
||||
permission: 'subscribe',
|
||||
},
|
||||
{
|
||||
title: t('navItems.workflow'),
|
||||
@@ -62,6 +68,7 @@ export function getNavMenus() {
|
||||
header: t('menu.subscribe'),
|
||||
admin: true,
|
||||
footer: false,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.calendar'),
|
||||
@@ -70,6 +77,7 @@ export function getNavMenus() {
|
||||
to: '/calendar',
|
||||
header: t('menu.subscribe'),
|
||||
admin: false,
|
||||
permission: 'subscribe',
|
||||
},
|
||||
{
|
||||
title: t('navItems.downloadManager'),
|
||||
@@ -77,6 +85,7 @@ export function getNavMenus() {
|
||||
to: '/downloading',
|
||||
header: t('menu.organize'),
|
||||
admin: false,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.mediaOrganize'),
|
||||
@@ -84,6 +93,7 @@ export function getNavMenus() {
|
||||
to: '/history',
|
||||
header: t('menu.organize'),
|
||||
admin: true,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.fileManager'),
|
||||
@@ -91,6 +101,7 @@ export function getNavMenus() {
|
||||
to: '/filemanager',
|
||||
header: t('menu.organize'),
|
||||
admin: true,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.pluginManager'),
|
||||
@@ -98,6 +109,7 @@ export function getNavMenus() {
|
||||
to: '/plugins',
|
||||
header: t('menu.system'),
|
||||
admin: true,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.siteManager'),
|
||||
@@ -105,6 +117,7 @@ export function getNavMenus() {
|
||||
to: '/site',
|
||||
header: t('menu.system'),
|
||||
admin: true,
|
||||
permission: 'manage',
|
||||
},
|
||||
{
|
||||
title: t('navItems.userManager'),
|
||||
@@ -112,6 +125,7 @@ export function getNavMenus() {
|
||||
to: '/user',
|
||||
header: t('menu.system'),
|
||||
admin: true,
|
||||
permission: 'admin',
|
||||
},
|
||||
{
|
||||
title: t('navItems.settings'),
|
||||
@@ -119,6 +133,7 @@ export function getNavMenus() {
|
||||
to: '/setting',
|
||||
header: t('menu.system'),
|
||||
admin: true,
|
||||
permission: 'admin',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,37 +209,32 @@ 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((to: any, from: any, next: any) => {
|
||||
router.beforeEach(async (to: any, from: any, next: any) => {
|
||||
// 设置导航状态 - 同时中断API请求
|
||||
setRequestNavigatingState(true)
|
||||
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 总是记录非login路由
|
||||
if (to.fullPath != '/login') authStore.originalPath = to.fullPath
|
||||
const isAuthenticated = authStore.token !== null
|
||||
|
||||
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',
|
||||
@@ -18,6 +10,159 @@ const options = {
|
||||
actions: [{ action: 'close', title: '关闭' }],
|
||||
}
|
||||
|
||||
// 存储未读消息数量的键名
|
||||
const UNREAD_COUNT_KEY = 'mp_unread_count'
|
||||
|
||||
// 从IndexedDB获取未读消息数量
|
||||
async function getStoredUnreadCount(): Promise<number> {
|
||||
try {
|
||||
const count = await get(UNREAD_COUNT_KEY)
|
||||
return count || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to get stored unread count:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 保存未读消息数量到IndexedDB
|
||||
async function setStoredUnreadCount(count: number): Promise<void> {
|
||||
try {
|
||||
await set(UNREAD_COUNT_KEY, count)
|
||||
} catch (error) {
|
||||
console.error('Failed to set stored unread count:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 简单的IndexedDB包装器
|
||||
async function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('mp_badge_db', 1)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onupgradeneeded = event => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
if (!db.objectStoreNames.contains('badge')) {
|
||||
db.createObjectStore('badge')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取IndexedDB中的数据
|
||||
async function get(key: string): Promise<any> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['badge'], 'readonly')
|
||||
const store = transaction.objectStore('badge')
|
||||
const request = store.get(key)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
})
|
||||
}
|
||||
|
||||
// 保存数据到IndexedDB
|
||||
async function set(key: string, value: any): Promise<void> {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['badge'], 'readwrite')
|
||||
const store = transaction.objectStore('badge')
|
||||
const request = store.put(value, key)
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve()
|
||||
})
|
||||
}
|
||||
|
||||
// 更新桌面图标徽章
|
||||
async function updateBadge(count: number) {
|
||||
if ('setAppBadge' in navigator) {
|
||||
try {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count)
|
||||
} else {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清除桌面图标徽章
|
||||
async function clearBadge() {
|
||||
if ('clearAppBadge' in navigator) {
|
||||
try {
|
||||
await navigator.clearAppBadge()
|
||||
await setStoredUnreadCount(0)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear app badge:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 安装事件
|
||||
self.addEventListener('install', event => {
|
||||
console.log('Service Worker install')
|
||||
// 强制等待中的Service Worker立即成为活动的Service Worker
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// 激活事件
|
||||
self.addEventListener('activate', event => {
|
||||
console.log('Service Worker activate')
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 启用导航预载功能以提高性能
|
||||
if ('navigationPreload' in self.registration) {
|
||||
await self.registration.navigationPreload.enable()
|
||||
}
|
||||
})(),
|
||||
)
|
||||
// 告诉活动的Service Worker立即控制页面
|
||||
self.clients.claim()
|
||||
})
|
||||
|
||||
// 处理API请求,当离线时发送消息到客户端
|
||||
self.addEventListener('fetch', event => {
|
||||
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')
|
||||
@@ -43,24 +188,21 @@ self.addEventListener('push', function (event) {
|
||||
data: { url: payload.url },
|
||||
actions: options.actions,
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification(payload.title, content))
|
||||
|
||||
// 增加未读消息计数并持久化存储
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const currentCount = await getStoredUnreadCount()
|
||||
const newCount = currentCount + 1
|
||||
await setStoredUnreadCount(newCount)
|
||||
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())
|
||||
})
|
||||
|
||||
// 监听通知点击事件
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
console.log('notification click')
|
||||
@@ -71,3 +213,42 @@ self.addEventListener('notificationclick', function (event) {
|
||||
event.waitUntil(self.clients.openWindow(info.data?.url))
|
||||
}
|
||||
})
|
||||
|
||||
// 监听来自主应用的消息,用于清除徽章或更新徽章数量
|
||||
self.addEventListener('message', function (event) {
|
||||
console.log('service worker received message:', event.data)
|
||||
|
||||
if (event.data && event.data.type === 'CLEAR_BADGE') {
|
||||
// 清除徽章
|
||||
clearBadge()
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to clear badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'UPDATE_BADGE') {
|
||||
// 更新徽章数量
|
||||
const count = event.data.count || 0
|
||||
setStoredUnreadCount(count)
|
||||
.then(() => updateBadge(count))
|
||||
.then(() => {
|
||||
event.ports[0]?.postMessage({ success: true })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to update badge:', error)
|
||||
event.ports[0]?.postMessage({ success: false, error: error.message })
|
||||
})
|
||||
} else if (event.data && event.data.type === 'GET_UNREAD_COUNT') {
|
||||
// 获取未读消息数量
|
||||
getStoredUnreadCount()
|
||||
.then(count => {
|
||||
event.ports[0]?.postMessage({ count })
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to get unread count:', error)
|
||||
event.ports[0]?.postMessage({ count: 0 })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { userState } from '@/stores/types'
|
||||
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: (): userState => ({
|
||||
@@ -8,7 +9,7 @@ export const useUserStore = defineStore('user', {
|
||||
userName: '',
|
||||
avatar: '',
|
||||
level: 1,
|
||||
permissions: {},
|
||||
permissions: DEFAULT_PERMISSIONS,
|
||||
}),
|
||||
|
||||
// 全局持久化
|
||||
@@ -31,7 +32,7 @@ export const useUserStore = defineStore('user', {
|
||||
this.level = level
|
||||
},
|
||||
setPermissions(permissions: object) {
|
||||
this.permissions = permissions
|
||||
this.permissions = { ...DEFAULT_PERMISSIONS, ...permissions }
|
||||
},
|
||||
loginUser(payload: userState) {
|
||||
this.setSuperUser(payload.superUser)
|
||||
@@ -47,7 +48,7 @@ export const useUserStore = defineStore('user', {
|
||||
this.setUserName('')
|
||||
this.setAvatar('')
|
||||
this.setLevel(1)
|
||||
this.setPermissions({})
|
||||
this.setPermissions(DEFAULT_PERMISSIONS)
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -48,14 +53,21 @@ html.v-overlay-scroll-blocked {
|
||||
}
|
||||
}
|
||||
|
||||
.v-toast--bottom {
|
||||
.Vue-Toastification__container {
|
||||
z-index: 2500;
|
||||
margin-block-end: env(safe-area-inset-bottom);
|
||||
margin-block: env(safe-area-inset-top) env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.v-toast--top {
|
||||
z-index: 2500;
|
||||
margin-block-start: env(safe-area-inset-top);
|
||||
@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 {
|
||||
@@ -162,10 +174,6 @@ html.v-overlay-scroll-blocked {
|
||||
background: rgb(var(--v-table-header-background));
|
||||
}
|
||||
|
||||
.v-toast {
|
||||
z-index: 2500 !important;
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
border-color: rgba(var(--v-theme-on-background), var(--v-selected-opacity));
|
||||
opacity:0.75;
|
||||
@@ -353,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);
|
||||
}
|
||||
}
|
||||
|
||||
// 页脚
|
||||
@@ -393,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;
|
||||
|
||||
@@ -411,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
@use '@styles/custom' as custom;
|
||||
|
||||
/* 第三方库纯CSS样式 */
|
||||
@import 'vue-toast-notification/dist/theme-bootstrap.css';
|
||||
@import 'vue-toastification/dist/index.css';
|
||||
@import 'vue3-perfect-scrollbar/style.css';
|
||||
@import '@vue-js-cron/vuetify/dist/vuetify.css';
|
||||
|
||||
17
src/types/global.d.ts
vendored
Normal file
17
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
// PWA Badge API 类型定义
|
||||
declare global {
|
||||
interface Navigator {
|
||||
/**
|
||||
* 设置应用徽章数量
|
||||
* @param contents 要显示的数量,可选
|
||||
*/
|
||||
setAppBadge(contents?: number): Promise<void>
|
||||
|
||||
/**
|
||||
* 清除应用徽章
|
||||
*/
|
||||
clearAppBadge(): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
184
src/utils/badge.ts
Normal file
184
src/utils/badge.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* PWA 徽章管理工具
|
||||
*/
|
||||
|
||||
// 全局事件类型
|
||||
interface UnreadMessageEvent extends CustomEvent {
|
||||
detail: { count: number }
|
||||
}
|
||||
|
||||
// 发送全局未读消息事件
|
||||
export function emitUnreadMessageEvent(count: number) {
|
||||
const event = new CustomEvent('unreadMessage', { detail: { count } }) as UnreadMessageEvent
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
// 监听全局未读消息事件
|
||||
export function onUnreadMessage(callback: (count: number) => void) {
|
||||
const handler = (event: Event) => {
|
||||
const unreadEvent = event as UnreadMessageEvent
|
||||
callback(unreadEvent.detail.count)
|
||||
}
|
||||
window.addEventListener('unreadMessage', handler)
|
||||
return () => window.removeEventListener('unreadMessage', handler)
|
||||
}
|
||||
|
||||
// 等待Service Worker准备就绪
|
||||
export async function waitForServiceWorker(): Promise<ServiceWorker | null> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 如果已经有激活的Service Worker,直接返回
|
||||
if (navigator.serviceWorker.controller) {
|
||||
return navigator.serviceWorker.controller
|
||||
}
|
||||
|
||||
// 等待Service Worker注册和激活,最多等待10秒
|
||||
return new Promise(resolve => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
let resolved = false
|
||||
|
||||
const resolveOnce = (sw: ServiceWorker | null) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
resolve(sw)
|
||||
}
|
||||
|
||||
const checkServiceWorker = () => {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
resolveOnce(navigator.serviceWorker.controller)
|
||||
} else {
|
||||
setTimeout(checkServiceWorker, 200)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听Service Worker变化
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
resolveOnce(navigator.serviceWorker.controller)
|
||||
})
|
||||
|
||||
// 设置超时,10秒后返回null
|
||||
timeoutId = setTimeout(() => {
|
||||
resolveOnce(null)
|
||||
}, 10000)
|
||||
|
||||
checkServiceWorker()
|
||||
})
|
||||
}
|
||||
|
||||
// 应用启动时检查未读消息数量
|
||||
export async function checkUnreadOnStartup(): Promise<number> {
|
||||
try {
|
||||
// 检查Service Worker是否可用
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 获取未读消息数量
|
||||
const unreadCount = await getUnreadCount()
|
||||
return unreadCount
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 应用启动检查并触发事件
|
||||
export async function checkAndEmitUnreadMessages() {
|
||||
try {
|
||||
const count = await checkUnreadOnStartup()
|
||||
if (count > 0) {
|
||||
emitUnreadMessageEvent(count)
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
|
||||
// 清除桌面图标徽章
|
||||
export async function clearAppBadge(): Promise<boolean> {
|
||||
try {
|
||||
// 如果浏览器支持原生Badge API,直接调用
|
||||
if ('clearAppBadge' in navigator) {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
|
||||
// 向service worker发送清除徽章消息
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'CLEAR_BADGE' }, [messageChannel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to clear app badge:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新桌面图标徽章数量
|
||||
export async function updateAppBadge(count: number): Promise<boolean> {
|
||||
try {
|
||||
// 如果浏览器支持原生Badge API,直接调用
|
||||
if ('setAppBadge' in navigator) {
|
||||
if (count > 0) {
|
||||
await navigator.setAppBadge(count)
|
||||
} else {
|
||||
await navigator.clearAppBadge()
|
||||
}
|
||||
}
|
||||
|
||||
// 向service worker发送更新徽章消息
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.success)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'UPDATE_BADGE', count }, [messageChannel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update app badge:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取Service Worker中的未读消息数量
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
try {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
|
||||
const messageChannel = new MessageChannel()
|
||||
|
||||
return new Promise(resolve => {
|
||||
messageChannel.port1.onmessage = event => {
|
||||
resolve(event.data.count || 0)
|
||||
}
|
||||
|
||||
navigator.serviceWorker.controller?.postMessage({ type: 'GET_UNREAD_COUNT' }, [messageChannel.port2])
|
||||
})
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch (error) {
|
||||
console.error('Failed to get unread count:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// 检查浏览器是否支持Badge API
|
||||
export function supportsBadgeAPI(): boolean {
|
||||
return 'setAppBadge' in navigator && 'clearAppBadge' in navigator
|
||||
}
|
||||
63
src/utils/permission.ts
Normal file
63
src/utils/permission.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// 权限类型定义
|
||||
export interface UserPermissions {
|
||||
discovery: boolean // 发现权限
|
||||
search: boolean // 搜索权限
|
||||
subscribe: boolean // 订阅权限
|
||||
manage: boolean // 管理权限
|
||||
}
|
||||
|
||||
// 默认权限配置
|
||||
export const DEFAULT_PERMISSIONS: UserPermissions = {
|
||||
discovery: true,
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: false,
|
||||
}
|
||||
|
||||
// 管理员权限配置
|
||||
export const ADMIN_PERMISSIONS: UserPermissions = {
|
||||
discovery: true,
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: true,
|
||||
}
|
||||
|
||||
// 权限检查函数
|
||||
export function hasPermission(userPermissions: any, permission: keyof UserPermissions): boolean {
|
||||
// 如果用户是超级用户,拥有所有权限
|
||||
if (userPermissions?.is_superuser === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查具体权限
|
||||
const permissions = userPermissions || {}
|
||||
return permissions[permission] === true
|
||||
}
|
||||
|
||||
// 批量权限检查
|
||||
export function hasAnyPermission(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {
|
||||
return permissionList.some(permission => hasPermission(userPermissions, permission))
|
||||
}
|
||||
|
||||
// 检查是否有所有权限
|
||||
export function hasAllPermissions(userPermissions: any, permissionList: (keyof UserPermissions)[]): boolean {
|
||||
return permissionList.every(permission => hasPermission(userPermissions, permission))
|
||||
}
|
||||
|
||||
// 根据权限过滤菜单项
|
||||
export function filterMenusByPermission(menus: any[], userPermissions: any): any[] {
|
||||
return menus.filter(menu => {
|
||||
// 如果是超级用户,拥有所有权限
|
||||
if (userPermissions?.is_superuser) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果菜单没有权限要求,默认显示
|
||||
if (!menu.permission) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查用户是否拥有所需权限
|
||||
return hasPermission(userPermissions, menu.permission)
|
||||
})
|
||||
}
|
||||
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>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user