mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-24 09:03:54 +08:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49385af29 | ||
|
|
b227412c96 | ||
|
|
b3c8faab70 | ||
|
|
9a480dd803 | ||
|
|
847fd13982 | ||
|
|
7fa4f4a2f0 | ||
|
|
4207a70716 | ||
|
|
c97247b92b | ||
|
|
e9bed7ff8a | ||
|
|
f25a619f13 | ||
|
|
2065b05143 | ||
|
|
eec1f2d7b3 | ||
|
|
17a343392c | ||
|
|
a2b2e8cd94 | ||
|
|
9703b2dbee | ||
|
|
310a501380 | ||
|
|
30bf895ae1 | ||
|
|
4f9dce70d3 | ||
|
|
f495e13667 | ||
|
|
f293681588 | ||
|
|
2f1a356e65 | ||
|
|
5909d2423c | ||
|
|
42f7df8f4a | ||
|
|
abaa40d819 | ||
|
|
0d05a104c4 | ||
|
|
e8708f8de7 | ||
|
|
7918b21b5b | ||
|
|
088db67089 | ||
|
|
62e0d8e9dc | ||
|
|
96d655155a | ||
|
|
a475085d7b | ||
|
|
58fdb77b37 | ||
|
|
8a25c6578d | ||
|
|
ef62bd6e98 | ||
|
|
876a46607b | ||
|
|
107f70abde | ||
|
|
090b9d735d | ||
|
|
dbeea6afcc | ||
|
|
2931f5df46 | ||
|
|
e14c81d178 | ||
|
|
a9403c9c34 | ||
|
|
dc4914e3ca | ||
|
|
f3dbc4afad | ||
|
|
e3e22aebd9 | ||
|
|
0ca2f20b24 | ||
|
|
14279c773d | ||
|
|
8372f63eb6 | ||
|
|
b7b62d7922 | ||
|
|
162cce1f50 | ||
|
|
aa49c6ccbc | ||
|
|
a40e52079f | ||
|
|
c29e329548 | ||
|
|
e2d26f6a25 | ||
|
|
1752256868 | ||
|
|
23d7f0dcc1 | ||
|
|
288aeed178 | ||
|
|
9a9a618136 | ||
|
|
723eb319e1 | ||
|
|
96684a8d13 | ||
|
|
fc9fe5e21e | ||
|
|
24b763d808 | ||
|
|
f761cdff00 | ||
|
|
b785769138 | ||
|
|
6d1febd70a | ||
|
|
bdbaf503ca | ||
|
|
f9e74cf436 | ||
|
|
e043669a10 | ||
|
|
78d8fdba9d | ||
|
|
5c0f0386a6 | ||
|
|
30b39283b6 |
193
index.html
193
index.html
@@ -1,11 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" style="
|
||||
overflow: hidden auto;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
<html lang="zh-CN" data-launch-loading="true" style="
|
||||
overflow: hidden;
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
--initial-loader-bg: #0E1116;
|
||||
--initial-loader-color: #9155FD;
|
||||
--initial-loader-height: 100svh;
|
||||
--initial-loader-width: 100vw;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
">
|
||||
|
||||
<head>
|
||||
@@ -92,50 +95,95 @@
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
|
||||
<style>
|
||||
#app {
|
||||
min-block-size: 100%;
|
||||
html,
|
||||
body {
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"],
|
||||
html[data-launch-loading="true"] body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] body {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
}
|
||||
|
||||
html[data-launch-loading="true"] #app {
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
#loading-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: block;
|
||||
background: var(--initial-loader-bg, #fff);
|
||||
block-size: 100vh;
|
||||
inline-size: 100vw;
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
overflow: hidden;
|
||||
background: var(--initial-loader-bg, #0E1116);
|
||||
background-color: var(--initial-loader-bg, #0E1116);
|
||||
}
|
||||
|
||||
.loading-shell {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
block-size: var(--initial-loader-height, 100svh);
|
||||
inline-size: 100%;
|
||||
min-block-size: var(--initial-loader-height, 100svh);
|
||||
transition: opacity 0.12s ease-out, transform 0.12s ease-out;
|
||||
padding:
|
||||
calc(env(safe-area-inset-top, 0px) + 24px)
|
||||
24px
|
||||
calc(env(safe-area-inset-bottom, 0px) + 48px);
|
||||
}
|
||||
|
||||
.loading-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.loading-logo {
|
||||
position: absolute;
|
||||
inset-block-start: 35%;
|
||||
inset-inline-start: calc(50% - 5rem);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease, filter 0.8s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: min(160px, 36vw);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.loading-complete .loading-logo {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
transform: scale(1.5);
|
||||
.loading-logo img {
|
||||
display: block;
|
||||
block-size: auto;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.loading-complete {
|
||||
filter: blur(15px);
|
||||
.loading-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-block-size: clamp(72px, 14vh, 120px);
|
||||
}
|
||||
|
||||
.loading-complete .loading-shell,
|
||||
.loading-complete #loading-timeout {
|
||||
opacity: 0;
|
||||
transform: scale(1.2);
|
||||
transform: translate3d(0, 6px, 0);
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
block-size: 55px;
|
||||
inline-size: 55px;
|
||||
inset-block-start: 80%;
|
||||
inset-inline-start: calc(50% - 27.5px);
|
||||
block-size: 46px;
|
||||
inline-size: 46px;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
@@ -198,7 +246,7 @@
|
||||
position: absolute;
|
||||
z-index: 2500;
|
||||
display: none;
|
||||
inset-block-end: 20px;
|
||||
inset-block-end: calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
@@ -209,7 +257,8 @@
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
white-space: nowrap;
|
||||
max-inline-size: calc(100% - 32px);
|
||||
white-space: normal;
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -233,25 +282,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 主题色彩初始化
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
|
||||
// 检查主题设置
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const isAutoTheme = savedTheme === 'auto'
|
||||
|
||||
// 如果是自动主题或者没有保存的背景色,根据系统主题设置背景色
|
||||
if (isAutoTheme || !loaderColor) {
|
||||
loaderColor = checkPrefersColorSchemeIsDark() ? '#0E1116' : '#FFFFFF'
|
||||
// 根据当前主题提前确定启动屏色彩,避免 iOS PWA 从原生启动图切到网页时露出默认白底。
|
||||
const launchThemeBackgrounds = {
|
||||
light: '#F4F5FA',
|
||||
dark: '#0E1116',
|
||||
purple: '#28243D',
|
||||
transparent: '#1C1C1C',
|
||||
default: '#F4F5FA',
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'auto'
|
||||
const resolvedLaunchTheme = savedTheme === 'auto'
|
||||
? (checkPrefersColorSchemeIsDark() ? 'dark' : 'light')
|
||||
: savedTheme
|
||||
|
||||
let loaderColor = localStorage.getItem('materio-initial-loader-bg')
|
||||
|| launchThemeBackgrounds[resolvedLaunchTheme]
|
||||
|| launchThemeBackgrounds.light
|
||||
|
||||
let primaryColor = localStorage.getItem('materio-initial-loader-color')
|
||||
if (!primaryColor) {
|
||||
primaryColor = '#9155FD'
|
||||
}
|
||||
|
||||
// 在应用脚本接管前锁定一次启动层内容高度,避免 iOS 独立模式首次重算 safe area 时把 logo 顶下去。
|
||||
function syncInitialViewport(force) {
|
||||
const viewport = window.visualViewport
|
||||
const nextHeight = Math.round(viewport?.height || window.innerHeight || document.documentElement.clientHeight || 0)
|
||||
const nextWidth = Math.round(viewport?.width || window.innerWidth || document.documentElement.clientWidth || 0)
|
||||
const currentHeight = parseInt(
|
||||
document.documentElement.style.getPropertyValue('--initial-loader-height') || '0',
|
||||
10,
|
||||
)
|
||||
|
||||
if (!nextHeight || !nextWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && currentHeight && Math.abs(nextHeight - currentHeight) < 120) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-height', `${nextHeight}px`)
|
||||
document.documentElement.style.setProperty('--initial-loader-width', `${nextWidth}px`)
|
||||
}
|
||||
|
||||
// 应用主题色彩
|
||||
document.documentElement.setAttribute('data-launch-theme', resolvedLaunchTheme)
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', loaderColor)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primaryColor)
|
||||
document.documentElement.style.backgroundColor = loaderColor
|
||||
syncInitialViewport(true)
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.body.style.backgroundColor = loaderColor
|
||||
})
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
window.setTimeout(() => syncInitialViewport(true), 160)
|
||||
})
|
||||
|
||||
// 状态栏适配
|
||||
if (window.navigator.standalone) {
|
||||
@@ -343,14 +432,20 @@
|
||||
|
||||
<body style="margin: 0; overflow: hidden; overscroll-behavior: none; -webkit-overflow-scrolling: touch">
|
||||
<div id="loading-bg">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160px" height="160px" />
|
||||
</div>
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
<div class="loading-shell">
|
||||
<div class="loading-main">
|
||||
<div class="loading-logo">
|
||||
<!-- Logo -->
|
||||
<img src="/logo.svg" alt="MoviePilot" width="160" height="160" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="loading-footer">
|
||||
<div class="loading">
|
||||
<div class="effect-1 effects"></div>
|
||||
<div class="effect-2 effects"></div>
|
||||
<div class="effect-3 effects"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 超时提示 - 默认隐藏 -->
|
||||
<div id="loading-timeout"></div>
|
||||
@@ -359,4 +454,4 @@
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.10.4",
|
||||
"version": "2.11.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@iconify-json/lucide": "^1.2.85",
|
||||
"@iconify-json/material-symbols": "^1.2.51",
|
||||
"@iconify-json/mdi": "^1.1.52",
|
||||
"@iconify-json/tabler": "^1.2.23",
|
||||
"@iconify/tools": "^4.0.4",
|
||||
"@iconify/vue": "^4.3.0",
|
||||
"@intlify/unplugin-vue-i18n": "^6.0.3",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { createRequire } from 'node:module'
|
||||
|
||||
// Get current directory
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectSrcDir = join(__dirname, '..')
|
||||
|
||||
// Create require function for importing JSON files in ESM
|
||||
const require = createRequire(import.meta.url)
|
||||
@@ -86,36 +87,12 @@ const sources: BundleScriptConfig = {
|
||||
],
|
||||
|
||||
icons: [
|
||||
// 'mdi:home',
|
||||
// 'mdi:account',
|
||||
// 'mdi:login',
|
||||
// 'mdi:logout',
|
||||
// 'octicon:book-24',
|
||||
// 'octicon:code-square-24',
|
||||
'lucide:sparkles',
|
||||
'material-symbols:passkey',
|
||||
'line-md:loading-twotone-loop',
|
||||
],
|
||||
|
||||
json: [
|
||||
// Custom JSON file
|
||||
// 'json/gg.json',
|
||||
|
||||
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
|
||||
require.resolve('@iconify-json/mdi/icons.json'),
|
||||
|
||||
// Custom file with only few icons
|
||||
// {
|
||||
// filename: require.resolve('@iconify-json/line-md/icons.json'),
|
||||
// icons: [
|
||||
// 'home-twotone-alt',
|
||||
// 'github',
|
||||
// 'document-list',
|
||||
// 'document-code',
|
||||
// 'image-twotone',
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
json: [],
|
||||
}
|
||||
|
||||
// Iconify component (this changes import statement in generated file)
|
||||
@@ -133,6 +110,15 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
(async function () {
|
||||
const scannedIcons = await collectUsedIcons(projectSrcDir)
|
||||
|
||||
if (sources.icons) {
|
||||
sources.icons.push(...scannedIcons)
|
||||
sources.icons = Array.from(new Set(sources.icons)).sort()
|
||||
} else {
|
||||
sources.icons = scannedIcons
|
||||
}
|
||||
|
||||
let bundle = commonJS
|
||||
? `const { addCollection } = require('${component}');\n\n`
|
||||
: `import { addCollection } from '${component}';\n\n`
|
||||
@@ -280,6 +266,56 @@ const target = join(__dirname, 'icons-bundle.js');
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
async function collectUsedIcons(rootDir: string): Promise<string[]> {
|
||||
const icons = new Set<string>()
|
||||
const files = await walkDirectory(rootDir)
|
||||
const sourceFiles = files.filter(file => /\.(vue|ts|js|tsx|jsx)$/.test(file))
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
if (file.includes('/@iconify/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const content = await fs.readFile(file, 'utf8')
|
||||
|
||||
for (const match of content.matchAll(/\b(lucide|material-symbols|line-md|tabler):([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`${match[1]}:${match[2]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi:([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\btabler-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`tabler:${match[1]}`)
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/\bmdi-([a-z0-9-]+)\b/g)) {
|
||||
icons.add(`mdi:${match[1]}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(icons).sort()
|
||||
}
|
||||
|
||||
async function walkDirectory(dir: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkDirectory(fullPath)))
|
||||
continue
|
||||
}
|
||||
|
||||
files.push(fullPath)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove metadata from icon set
|
||||
*/
|
||||
|
||||
@@ -19,6 +19,11 @@ export default defineComponent({
|
||||
const scrollDistance = ref(window.scrollY)
|
||||
const isDialogOpen = ref(false)
|
||||
const wasScrolledBeforeDialog = ref(false)
|
||||
let dialogObserver: MutationObserver | null = null
|
||||
|
||||
const handleScroll = () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
}
|
||||
|
||||
// 监听弹窗状态变化
|
||||
const checkDialogState = () => {
|
||||
@@ -32,21 +37,25 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', () => {
|
||||
scrollDistance.value = window.scrollY
|
||||
})
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
// 初始检查弹窗状态
|
||||
checkDialogState()
|
||||
|
||||
// 监听 DOM 变化以检测弹窗状态
|
||||
const observer = new MutationObserver(checkDialogState)
|
||||
observer.observe(document.documentElement, {
|
||||
dialogObserver = new MutationObserver(checkDialogState)
|
||||
dialogObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
dialogObserver?.disconnect()
|
||||
dialogObserver = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
// 👉 Vertical nav
|
||||
const verticalNav = h(
|
||||
|
||||
73
src/App.vue
73
src/App.vue
@@ -12,6 +12,7 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 生效主题
|
||||
const { global: globalTheme } = useTheme()
|
||||
@@ -19,6 +20,16 @@ let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
const autoTheme = checkPrefersColorSchemeIsDark() ? 'dark' : 'light'
|
||||
globalTheme.name.value = themeValue === 'auto' ? autoTheme : themeValue
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
function syncRootLaunchPalette() {
|
||||
const { background, primary } = globalTheme.current.value.colors
|
||||
|
||||
document.documentElement.style.setProperty('--initial-loader-bg', background)
|
||||
document.documentElement.style.setProperty('--initial-loader-color', primary)
|
||||
document.documentElement.style.backgroundColor = background
|
||||
document.body.style.backgroundColor = background
|
||||
}
|
||||
|
||||
// 生效语言
|
||||
const localeValue = getBrowserLocale()
|
||||
setI18nLanguage(localeValue as SupportedLocale)
|
||||
@@ -41,13 +52,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
@@ -75,48 +79,11 @@ const stopHeartbeat = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置 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)
|
||||
document.body.setAttribute('data-theme', themeName)
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
@@ -170,8 +137,16 @@ function startBackgroundRotation() {
|
||||
function animateAndRemoveLoader() {
|
||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||
if (loadingBg) {
|
||||
removeEl('#loading-bg')
|
||||
document.documentElement.style.removeProperty('background')
|
||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||
loadingBg.classList.add('loading-complete')
|
||||
window.setTimeout(() => {
|
||||
removeEl('#loading-bg')
|
||||
|
||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||
document.documentElement.removeAttribute('data-launch-loading')
|
||||
document.documentElement.style.removeProperty('overflow')
|
||||
document.body.style.removeProperty('overflow')
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +225,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -265,7 +240,7 @@ onMounted(async () => {
|
||||
// 更新HTML主题属性
|
||||
updateHtmlThemeAttribute(newTheme)
|
||||
// 重新配置ApexCharts以适应新主题
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(newTheme)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
|
||||
|
||||
import 'ace-builds/src-noconflict/ext-language_tools'
|
||||
|
||||
const aceModule = ace as typeof ace & {
|
||||
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
function registerJinja2Mode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2HighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaExpressionRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-qstring',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'jinja2-qqstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-statement',
|
||||
},
|
||||
],
|
||||
'jinja2-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaExpressionRules,
|
||||
],
|
||||
'jinja2-qqstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\"ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
'jinja2-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
|
||||
exports.Jinja2HighlightRules = Jinja2HighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2HighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/jinja2'
|
||||
this.blockComment = { start: '{#', end: '#}' }
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
|
||||
exports.snippetText =
|
||||
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
|
||||
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
|
||||
'snippet var\n\t{{ ${1:name} }}\n'
|
||||
exports.scope = 'jinja2'
|
||||
})
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const Jinja2JsonHighlightRules = function (this: any) {
|
||||
const tags =
|
||||
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
|
||||
const filters =
|
||||
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
|
||||
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
|
||||
const tests =
|
||||
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
|
||||
const operators = 'and|in|is|not|or'
|
||||
const contextVariables =
|
||||
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
|
||||
|
||||
const keywordMapper = this.createKeywordMapper(
|
||||
{
|
||||
'keyword.control.jinja2': tags,
|
||||
'keyword.operator.jinja2': operators,
|
||||
'support.function.jinja2': [filters, functions, tests].join('|'),
|
||||
'constant.language.jinja2': 'false|False|none|None|null|true|True',
|
||||
},
|
||||
'identifier',
|
||||
)
|
||||
|
||||
const jinjaRules = [
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
push: 'jinja2-json-qstring',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\|)(\\s*)(${filters})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
|
||||
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
|
||||
},
|
||||
{
|
||||
token: ['support.function.jinja2', 'text', 'paren.lparen'],
|
||||
regex: `\\b(${functions})(\\s*)(\\()`,
|
||||
},
|
||||
{
|
||||
token: 'variable.language.jinja2',
|
||||
regex: `\\b(?:${contextVariables})\\b`,
|
||||
},
|
||||
{
|
||||
token: keywordMapper,
|
||||
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.assignment.jinja2',
|
||||
regex: /=|~/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.comparison.jinja2',
|
||||
regex: /==|!=|<=|>=|<|>/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.arithmetic.jinja2',
|
||||
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
|
||||
},
|
||||
{
|
||||
token: 'keyword.operator.other.jinja2',
|
||||
regex: /\.{2}|\||:/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator.jinja2',
|
||||
regex: /[.,;?]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[\[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
]
|
||||
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'variable',
|
||||
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: '"',
|
||||
push: 'json-string',
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /0[xX][0-9a-fA-F]+\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.numeric',
|
||||
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
|
||||
},
|
||||
{
|
||||
token: 'constant.language.boolean',
|
||||
regex: /(?:true|false|null)\b/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
|
||||
},
|
||||
{
|
||||
token: 'comment',
|
||||
regex: /\/\/.*$/,
|
||||
},
|
||||
{
|
||||
token: 'comment.start',
|
||||
regex: /\/\*/,
|
||||
push: 'comment',
|
||||
},
|
||||
{
|
||||
token: 'paren.lparen',
|
||||
regex: /[[({]/,
|
||||
},
|
||||
{
|
||||
token: 'paren.rparen',
|
||||
regex: /[\])}]/,
|
||||
},
|
||||
{
|
||||
token: 'punctuation.operator',
|
||||
regex: /[:,]/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /\s+/,
|
||||
},
|
||||
],
|
||||
'json-string': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
|
||||
},
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /\{#-?/,
|
||||
push: 'jinja2-json-comment',
|
||||
},
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /\{\{-?/,
|
||||
push: 'jinja2-json-expression',
|
||||
},
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /\{%-?/,
|
||||
push: 'jinja2-json-statement',
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: /"|$/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
comment: [
|
||||
{
|
||||
token: 'comment.end',
|
||||
regex: /\*\//,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment',
|
||||
},
|
||||
],
|
||||
'jinja2-json-comment': [
|
||||
{
|
||||
token: 'comment.block.jinja2',
|
||||
regex: /-?#\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'comment.block.jinja2',
|
||||
},
|
||||
],
|
||||
'jinja2-json-expression': [
|
||||
{
|
||||
token: 'constant.language.jinja2',
|
||||
regex: /-?\}\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-statement': [
|
||||
{
|
||||
token: 'keyword.control.jinja2',
|
||||
regex: /-?%\}/,
|
||||
next: 'pop',
|
||||
},
|
||||
...jinjaRules,
|
||||
],
|
||||
'jinja2-json-qstring': [
|
||||
{
|
||||
token: 'constant.language.escape',
|
||||
regex: /\\[\\'ntr]/,
|
||||
},
|
||||
{
|
||||
token: 'string',
|
||||
regex: "'",
|
||||
next: 'pop',
|
||||
},
|
||||
{
|
||||
defaultToken: 'string',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
|
||||
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/jinja2_json',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = Jinja2JsonHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.lineCommentStart = '//'
|
||||
this.blockComment = { start: '/*', end: '*/' }
|
||||
this.$id = 'ace/mode/jinja2_json'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
|
||||
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
|
||||
@@ -61,9 +543,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
|
||||
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
|
||||
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
|
||||
|
||||
registerJinja2Mode()
|
||||
ace.require('ace/ext/language_tools')
|
||||
|
||||
@@ -68,6 +68,10 @@ export const mediaServerOptions = [
|
||||
value: 'emby',
|
||||
title: i18n.global.t('setting.system.emby'),
|
||||
},
|
||||
{
|
||||
value: 'zspace',
|
||||
title: i18n.global.t('setting.system.zspace'),
|
||||
},
|
||||
{
|
||||
value: 'jellyfin',
|
||||
title: i18n.global.t('setting.system.jellyfin'),
|
||||
|
||||
@@ -1145,7 +1145,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex/trimemedia/ugreen
|
||||
// 类型 emby/zspace/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1311,6 +1311,57 @@ export interface TransferForm {
|
||||
library_category_folder?: boolean
|
||||
// 剧集组编号
|
||||
episode_group?: string
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
export interface ManualTransferPayload extends TransferForm {}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
total: number
|
||||
// 成功数
|
||||
success: number
|
||||
// 失败数
|
||||
failed: number
|
||||
}
|
||||
|
||||
// 手动整理预览项
|
||||
export interface ManualTransferPreviewItem {
|
||||
// 原始路径
|
||||
source?: string
|
||||
// 目标路径
|
||||
target?: string
|
||||
// 目标目录
|
||||
target_dir?: string
|
||||
// 是否成功
|
||||
success?: boolean
|
||||
// 提示信息
|
||||
message?: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
// 媒体标题
|
||||
title?: string
|
||||
// 季
|
||||
season?: number | string
|
||||
// 开始集
|
||||
episode?: number | string
|
||||
// 结束集
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
export interface ManualTransferPreviewData {
|
||||
// 统计信息
|
||||
summary: ManualTransferPreviewSummary
|
||||
// 预览结果
|
||||
items: ManualTransferPreviewItem[]
|
||||
// 额外消息
|
||||
message?: string
|
||||
}
|
||||
|
||||
// 整理队列
|
||||
|
||||
BIN
src/assets/images/logos/clawbot.png
Normal file
BIN
src/assets/images/logos/clawbot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/images/logos/feishu.png
Normal file
BIN
src/assets/images/logos/feishu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/assets/images/logos/zspace.webp
Normal file
BIN
src/assets/images/logos/zspace.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
@@ -346,11 +346,23 @@ onUnmounted(() => {
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
@@ -362,6 +374,7 @@ onUnmounted(() => {
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
|
||||
@@ -40,6 +40,7 @@ function imageErrorHandler() {
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
else if (props.media?.server_type === 'zspace') return getLogoUrl('zspace')
|
||||
else if (props.media?.server_type === 'jellyfin') return jellyfin
|
||||
else if (props.media?.server_type === 'trimemedia') return getLogoUrl('trimemedia')
|
||||
else if (props.media?.server_type === 'ugreen') return getLogoUrl('ugreen')
|
||||
|
||||
@@ -14,6 +14,12 @@ import SubscribeSeasonDialog from '../dialog/SubscribeSeasonDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -123,6 +129,22 @@ function getMediaId() {
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
return `${getMediaId()}::${season ?? 'all'}`
|
||||
}
|
||||
|
||||
function getExistsStatusKey() {
|
||||
return [
|
||||
props.media?.tmdb_id ?? '',
|
||||
props.media?.title ?? '',
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -167,6 +189,7 @@ async function addSubscribe(season: number | null = null, best_version: number =
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -213,6 +236,7 @@ async function removeSubscribe() {
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
@@ -227,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season ?? null)
|
||||
if (result) isSubscribed.value = true
|
||||
const subscribed = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), () =>
|
||||
checkSubscribe(props.media?.season ?? null),
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -237,17 +263,22 @@ async function handleCheckSubscribe() {
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => {
|
||||
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
|
||||
params: {
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
title: props.media?.title,
|
||||
year: props.media?.year,
|
||||
season: props.media?.season,
|
||||
mtype: props.media?.type,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.success)
|
||||
})
|
||||
|
||||
if (result.success) isExists.value = true
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -265,12 +296,14 @@ async function checkSubscribe(season: number | null) {
|
||||
},
|
||||
})
|
||||
|
||||
return result.id || null
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
|
||||
@@ -121,6 +121,8 @@ const getIcon = computed(() => {
|
||||
switch (props.mediaserver.type) {
|
||||
case 'emby':
|
||||
return getLogoUrl('emby')
|
||||
case 'zspace':
|
||||
return getLogoUrl('zspace')
|
||||
case 'jellyfin':
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
@@ -318,6 +320,77 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { NotificationConf } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import QRCode from 'qrcode'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -45,6 +47,8 @@ const notificationInfo = ref<NotificationConf>({
|
||||
// 各通知类型的名称字典
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
feishu: t('notification.feishu.name'),
|
||||
wechatclawbot: t('notification.wechatclawbot.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
@@ -68,6 +72,30 @@ const notificationTypes = [
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
interface WechatClawBotStatus {
|
||||
connected?: boolean
|
||||
account_id?: string | null
|
||||
qrcode?: string | null
|
||||
qrcode_url?: string | null
|
||||
qrcode_status?: string | null
|
||||
qrcode_updated_at?: number | null
|
||||
known_targets?: Array<{ userid: string; username: string; last_active?: number | null }>
|
||||
default_target?: string | null
|
||||
base_url?: string | null
|
||||
}
|
||||
|
||||
interface WechatClawBotStatusFetchOptions {
|
||||
autoGenerateQrcode?: boolean
|
||||
silent?: boolean
|
||||
autoRefreshExpired?: boolean
|
||||
showErrorToast?: boolean
|
||||
}
|
||||
|
||||
interface WechatClawBotRefreshOptions {
|
||||
silent?: boolean
|
||||
showToast?: boolean
|
||||
}
|
||||
|
||||
function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechat') {
|
||||
return
|
||||
@@ -83,6 +111,89 @@ function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWechatClawBotConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechatclawbot') {
|
||||
return
|
||||
}
|
||||
if (!notification.config) {
|
||||
notification.config = {}
|
||||
}
|
||||
if (!notification.config.WECHATCLAWBOT_BASE_URL) {
|
||||
notification.config.WECHATCLAWBOT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||
}
|
||||
if (!notification.config.WECHATCLAWBOT_POLL_TIMEOUT) {
|
||||
notification.config.WECHATCLAWBOT_POLL_TIMEOUT = 25
|
||||
}
|
||||
}
|
||||
|
||||
const wechatClawBotLoading = ref(false)
|
||||
const wechatClawBotActionLoading = ref(false)
|
||||
const wechatClawBotStatus = ref<WechatClawBotStatus | null>(null)
|
||||
const wechatClawBotQrImage = ref('')
|
||||
const wechatClawBotExpiredRefreshAttempted = ref(false)
|
||||
let wechatClawBotTimer: number | null = null
|
||||
|
||||
function isImageSource(value?: string | null) {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const raw = value.trim()
|
||||
if (!raw) {
|
||||
return false
|
||||
}
|
||||
if (raw.toLowerCase().startsWith('data:image/')) {
|
||||
return true
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(raw)
|
||||
}
|
||||
|
||||
function getWechatClawBotQrText(status?: WechatClawBotStatus | null) {
|
||||
const directUrl = status?.qrcode_url?.trim()
|
||||
if (directUrl) {
|
||||
return directUrl
|
||||
}
|
||||
const qrcode = status?.qrcode?.trim()
|
||||
if (!qrcode) {
|
||||
return ''
|
||||
}
|
||||
return `https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=${encodeURIComponent(qrcode)}&bot_type=3`
|
||||
}
|
||||
|
||||
async function updateWechatClawBotQrImage(status?: WechatClawBotStatus | null) {
|
||||
const directUrl = status?.qrcode_url?.trim()
|
||||
if (isImageSource(directUrl)) {
|
||||
wechatClawBotQrImage.value = directUrl || ''
|
||||
return
|
||||
}
|
||||
const qrText = getWechatClawBotQrText(status)
|
||||
if (!qrText) {
|
||||
wechatClawBotQrImage.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
wechatClawBotQrImage.value = await QRCode.toDataURL(qrText, {
|
||||
width: 220,
|
||||
margin: 1,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
wechatClawBotQrImage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getWechatClawBotRequestParams(extraParams: Record<string, any> = {}) {
|
||||
const config = notificationInfo.value.config || {}
|
||||
return {
|
||||
source: notificationInfo.value.name,
|
||||
fallback_source: props.notification.name,
|
||||
WECHATCLAWBOT_BASE_URL: config.WECHATCLAWBOT_BASE_URL,
|
||||
WECHATCLAWBOT_DEFAULT_TARGET: config.WECHATCLAWBOT_DEFAULT_TARGET,
|
||||
WECHATCLAWBOT_ADMINS: config.WECHATCLAWBOT_ADMINS,
|
||||
WECHATCLAWBOT_POLL_TIMEOUT: config.WECHATCLAWBOT_POLL_TIMEOUT,
|
||||
...extraParams,
|
||||
}
|
||||
}
|
||||
|
||||
const isWechatBotMode = computed({
|
||||
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
|
||||
set: value => {
|
||||
@@ -101,7 +212,14 @@ function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = true
|
||||
if (notificationInfo.value.type === 'wechatclawbot') {
|
||||
fetchWechatClawBotStatus({
|
||||
autoGenerateQrcode: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 保存详情数据
|
||||
@@ -117,16 +235,191 @@ function saveNotificationInfo() {
|
||||
return
|
||||
}
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
ensureWechatClawBotConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
function clearWechatClawBotTimer() {
|
||||
if (wechatClawBotTimer) {
|
||||
window.clearTimeout(wechatClawBotTimer)
|
||||
wechatClawBotTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWechatClawBotRefresh() {
|
||||
clearWechatClawBotTimer()
|
||||
if (!notificationInfoDialog.value || notificationInfo.value.type !== 'wechatclawbot') {
|
||||
return
|
||||
}
|
||||
const connected = wechatClawBotStatus.value?.connected
|
||||
const pendingStatus = ['waiting', 'scanned'].includes((wechatClawBotStatus.value?.qrcode_status || '').toLowerCase())
|
||||
if (connected || pendingStatus) {
|
||||
wechatClawBotTimer = window.setTimeout(() => {
|
||||
fetchWechatClawBotStatus({
|
||||
silent: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
}, connected ? 10000 : 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWechatClawBotStatus(options: WechatClawBotStatusFetchOptions = {}) {
|
||||
const {
|
||||
autoGenerateQrcode = false,
|
||||
silent = false,
|
||||
autoRefreshExpired = false,
|
||||
showErrorToast = true,
|
||||
} = options
|
||||
if (notificationInfo.value.type !== 'wechatclawbot' || !notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
wechatClawBotLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('notification/wechatclawbot/status', {
|
||||
params: getWechatClawBotRequestParams({ auto_generate_qrcode: autoGenerateQrcode }),
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
const status = (result.data?.qrcode_status || '').toLowerCase()
|
||||
if (status !== 'expired') {
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
}
|
||||
if (
|
||||
autoRefreshExpired &&
|
||||
!result.data?.connected &&
|
||||
status === 'expired' &&
|
||||
!wechatClawBotExpiredRefreshAttempted.value
|
||||
) {
|
||||
wechatClawBotExpiredRefreshAttempted.value = true
|
||||
await refreshWechatClawBotQrcode({
|
||||
silent: true,
|
||||
showToast: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
scheduleWechatClawBotRefresh()
|
||||
} else {
|
||||
wechatClawBotStatus.value = null
|
||||
wechatClawBotQrImage.value = ''
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
clearWechatClawBotTimer()
|
||||
if (showErrorToast) {
|
||||
$toast.error(t('notification.wechatclawbot.statusLoadFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
wechatClawBotLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWechatClawBotQrcode(options: WechatClawBotRefreshOptions = {}) {
|
||||
const { silent = false, showToast = true } = options
|
||||
if (!notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
if (!silent) {
|
||||
wechatClawBotActionLoading.value = true
|
||||
}
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/refresh', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
})
|
||||
if (result.success) {
|
||||
wechatClawBotStatus.value = result.data
|
||||
await updateWechatClawBotQrImage(result.data)
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
scheduleWechatClawBotRefresh()
|
||||
if (showToast) {
|
||||
$toast.success(t('notification.wechatclawbot.qrcodeRefreshSuccess'))
|
||||
}
|
||||
} else {
|
||||
if (showToast) {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (showToast) {
|
||||
$toast.error(t('notification.wechatclawbot.qrcodeRefreshFailed'))
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
wechatClawBotActionLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutWechatClawBot() {
|
||||
if (!notificationInfo.value.name) {
|
||||
return
|
||||
}
|
||||
wechatClawBotActionLoading.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/logout', null, {
|
||||
params: getWechatClawBotRequestParams(),
|
||||
})
|
||||
if (result.success) {
|
||||
$toast.success(result.message || t('notification.wechatclawbot.logoutSuccess'))
|
||||
await fetchWechatClawBotStatus({
|
||||
autoGenerateQrcode: true,
|
||||
autoRefreshExpired: true,
|
||||
})
|
||||
} else {
|
||||
$toast.error(result.message || t('notification.wechatclawbot.logoutFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('notification.wechatclawbot.logoutFailed'))
|
||||
} finally {
|
||||
wechatClawBotActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatWechatClawBotTime(timestamp?: number | null) {
|
||||
if (!timestamp) {
|
||||
return ''
|
||||
}
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const wechatClawBotStatusText = computed(() => {
|
||||
const status = (wechatClawBotStatus.value?.qrcode_status || '').toLowerCase()
|
||||
if (wechatClawBotStatus.value?.connected) {
|
||||
return t('notification.wechatclawbot.connected')
|
||||
}
|
||||
if (status === 'scanned') {
|
||||
return t('notification.wechatclawbot.scanned')
|
||||
}
|
||||
if (status === 'expired') {
|
||||
return t('notification.wechatclawbot.expired')
|
||||
}
|
||||
if (status === 'confirmed') {
|
||||
return t('notification.wechatclawbot.confirmed')
|
||||
}
|
||||
return t('notification.wechatclawbot.waiting')
|
||||
})
|
||||
|
||||
// 根据存储类型选择图标
|
||||
const getIcon = computed(() => {
|
||||
switch (props.notification.type) {
|
||||
case 'wechat':
|
||||
return getLogoUrl('wechat')
|
||||
case 'wechatclawbot':
|
||||
return getLogoUrl('wechatclawbot')
|
||||
case 'feishu':
|
||||
return getLogoUrl('feishu')
|
||||
case 'telegram':
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
@@ -148,8 +441,17 @@ const getIcon = computed(() => {
|
||||
|
||||
// 按钮点击
|
||||
function onClose() {
|
||||
clearWechatClawBotTimer()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
watch(notificationInfoDialog, value => {
|
||||
if (!value) {
|
||||
clearWechatClawBotTimer()
|
||||
wechatClawBotQrImage.value = ''
|
||||
wechatClawBotExpiredRefreshAttempted.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
@@ -347,6 +649,215 @@ function onClose() {
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'wechatclawbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_BASE_URL"
|
||||
:label="t('notification.wechatclawbot.baseUrl')"
|
||||
:hint="t('notification.wechatclawbot.baseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_DEFAULT_TARGET"
|
||||
:label="t('notification.wechatclawbot.defaultTarget')"
|
||||
:placeholder="t('notification.wechatclawbot.defaultTargetPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.defaultTargetHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-arrow-right"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_ADMINS"
|
||||
:label="t('notification.wechatclawbot.admins')"
|
||||
:placeholder="t('notification.wechatclawbot.adminsPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHATCLAWBOT_POLL_TIMEOUT"
|
||||
:label="t('notification.wechatclawbot.pollTimeout')"
|
||||
:hint="t('notification.wechatclawbot.pollTimeoutHint')"
|
||||
persistent-hint
|
||||
type="number"
|
||||
prepend-inner-icon="mdi-timer-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCard variant="tonal" class="pa-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-3 mb-3">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ t('notification.wechatclawbot.loginStatus') }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">{{ wechatClawBotStatusText }}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotLoading"
|
||||
@click.stop="fetchWechatClawBotStatus({ autoGenerateQrcode: true, autoRefreshExpired: true })"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotActionLoading"
|
||||
@click.stop="refreshWechatClawBotQrcode"
|
||||
>
|
||||
{{ t('notification.wechatclawbot.refreshQrcode') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
:loading="wechatClawBotActionLoading"
|
||||
:disabled="!wechatClawBotStatus?.connected"
|
||||
@click.stop="logoutWechatClawBot"
|
||||
>
|
||||
{{ t('notification.wechatclawbot.logout') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
<VRow>
|
||||
<VCol cols="12" md="5">
|
||||
<div class="rounded text-center p-3 border h-100 d-flex align-center justify-center min-h-[16rem]">
|
||||
<VImg
|
||||
v-if="wechatClawBotQrImage"
|
||||
:src="wechatClawBotQrImage"
|
||||
width="220"
|
||||
height="220"
|
||||
class="mx-auto"
|
||||
/>
|
||||
<VProgressCircular v-else-if="wechatClawBotLoading" indeterminate color="primary" />
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
{{ t('notification.wechatclawbot.noQrcode') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12" md="7">
|
||||
<VAlert variant="tonal" :type="wechatClawBotStatus?.connected ? 'success' : 'info'" class="mb-3">
|
||||
<div class="text-body-2">{{ t('notification.wechatclawbot.scanHint') }}</div>
|
||||
<div v-if="wechatClawBotStatus?.account_id" class="mt-2">
|
||||
{{ t('notification.wechatclawbot.accountId') }}: {{ wechatClawBotStatus.account_id }}
|
||||
</div>
|
||||
<div v-if="wechatClawBotStatus?.qrcode_updated_at" class="mt-2">
|
||||
{{ t('notification.wechatclawbot.qrcodeUpdatedAt') }}:
|
||||
{{ formatWechatClawBotTime(wechatClawBotStatus.qrcode_updated_at) }}
|
||||
</div>
|
||||
</VAlert>
|
||||
<div class="text-subtitle-2 mb-2">{{ t('notification.wechatclawbot.knownTargets') }}</div>
|
||||
<VList v-if="wechatClawBotStatus?.known_targets?.length" density="compact" class="border rounded">
|
||||
<VListItem
|
||||
v-for="item in wechatClawBotStatus.known_targets"
|
||||
:key="item.userid"
|
||||
:title="item.username || item.userid"
|
||||
:subtitle="`${item.userid}${item.last_active ? ` · ${formatWechatClawBotTime(item.last_active)}` : ''}`"
|
||||
/>
|
||||
</VList>
|
||||
<div v-else class="text-body-2 text-medium-emphasis">
|
||||
{{ t('notification.wechatclawbot.noKnownTargets') }}
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'feishu'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_APP_ID"
|
||||
:label="t('notification.feishu.appId')"
|
||||
:hint="t('notification.feishu.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_APP_SECRET"
|
||||
:label="t('notification.feishu.appSecret')"
|
||||
:hint="t('notification.feishu.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_OPEN_ID"
|
||||
:label="t('notification.feishu.openId')"
|
||||
:placeholder="t('notification.feishu.openIdPlaceholder')"
|
||||
:hint="t('notification.feishu.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_CHAT_ID"
|
||||
:label="t('notification.feishu.chatId')"
|
||||
:placeholder="t('notification.feishu.chatIdPlaceholder')"
|
||||
:hint="t('notification.feishu.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_ADMINS"
|
||||
:label="t('notification.feishu.admins')"
|
||||
:placeholder="t('notification.feishu.adminsPlaceholder')"
|
||||
:hint="t('notification.feishu.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_VERIFICATION_TOKEN"
|
||||
:label="t('notification.feishu.verificationToken')"
|
||||
:hint="t('notification.feishu.verificationTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.FEISHU_ENCRYPT_KEY"
|
||||
:label="t('notification.feishu.encryptKey')"
|
||||
:hint="t('notification.feishu.encryptKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
action: Boolean, // 动作标识
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -269,6 +273,14 @@ function openPluginDetail() {
|
||||
else showPluginConfig()
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openPluginDetail()
|
||||
}
|
||||
|
||||
// 配置完成
|
||||
function configDone() {
|
||||
pluginConfigDialog.value = false
|
||||
@@ -420,6 +432,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -433,11 +446,13 @@ watch(
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="openPluginDetail"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
@@ -458,7 +473,7 @@ watch(
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }">
|
||||
<VAvatar size="48">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
@@ -482,7 +497,11 @@ watch(
|
||||
<VIcon v-if="!isAvatarLoaded" size="small" icon="mdi-github" class="me-1" />
|
||||
</template>
|
||||
</VImg>
|
||||
<span v-if="props.sortable" class="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
:href="props.plugin?.author_url"
|
||||
target="_blank"
|
||||
@click.stop
|
||||
@@ -496,7 +515,7 @@ watch(
|
||||
<span class="text-sm">{{ formatDownloadCount(props.count) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute bottom-0 right-0">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu v-model="menuVisible" activator="parent" close-on-content-click>
|
||||
@@ -566,13 +585,13 @@ watch(
|
||||
</VDialog>
|
||||
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="60rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -588,7 +607,7 @@ watch(
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
||||
},
|
||||
width: String,
|
||||
height: String,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -165,6 +169,14 @@ function openFolder() {
|
||||
emit('open', props.folderName)
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openFolder()
|
||||
}
|
||||
|
||||
// 重命名文件夹
|
||||
function showRenameDialog() {
|
||||
newFolderName.value = props.folderName || ''
|
||||
@@ -275,11 +287,12 @@ const dropdownItems = ref([
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="openFolder"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
@@ -302,14 +315,14 @@ const dropdownItems = ref([
|
||||
:icon="folderIcon"
|
||||
:size="display.mobile ? 56 : 72"
|
||||
:color="iconColor"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 文件夹信息 -->
|
||||
<div
|
||||
class="plugin-folder-card__info"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||
>
|
||||
<!-- 文件夹名称 -->
|
||||
<h3 class="plugin-folder-card__name">
|
||||
@@ -321,7 +334,7 @@ const dropdownItems = ref([
|
||||
</div>
|
||||
|
||||
<!-- 更多菜单按钮 - 右下角 -->
|
||||
<div class="absolute top-0 right-0">
|
||||
<div v-if="!props.sortable" class="absolute top-0 right-0">
|
||||
<VMenu v-model="menuVisible" location="top end" :close-on-content-click="true">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<IconBtn v-bind="menuProps" @click.stop>
|
||||
@@ -491,6 +504,10 @@ const dropdownItems = ref([
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ interface Props {
|
||||
pluginStatistics?: { [key: string]: number }
|
||||
pluginActions?: { [key: string]: boolean }
|
||||
showRemoveButton?: boolean
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pluginStatistics: () => ({}),
|
||||
pluginActions: () => ({}),
|
||||
showRemoveButton: false,
|
||||
sortable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
||||
// 拖拽事件处理
|
||||
function handleDragOver(event: DragEvent) {
|
||||
// 只有当拖拽的是插件时才允许放入文件夹
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer!.dropEffect = 'move'
|
||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDragEnter(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
||||
}
|
||||
|
||||
function handleDropToFolder(event: DragEvent) {
|
||||
if (props.item.type === 'folder') {
|
||||
if (props.sortable && props.item.type === 'folder') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:folder-name="item.data.name"
|
||||
:plugin-count="item.data.pluginCount"
|
||||
:folder-config="item.data.config"
|
||||
:sortable="sortable"
|
||||
@open="$emit('openFolder', item.id)"
|
||||
@delete="$emit('deleteFolder', item.id)"
|
||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:count="pluginStatistics[item.id] || 0"
|
||||
:plugin="item.data"
|
||||
:action="pluginActions[item.id] || false"
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
@@ -109,7 +113,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
|
||||
<!-- 移出文件夹按钮(仅在文件夹内显示) -->
|
||||
<VBtn
|
||||
v-if="showRemoveButton"
|
||||
v-if="showRemoveButton && !sortable"
|
||||
icon="mdi-folder-remove"
|
||||
variant="text"
|
||||
color="warning"
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -25,6 +26,10 @@ const cardProps = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
data: Object as PropType<SiteUserData>,
|
||||
stats: Object as PropType<SiteStatistic>,
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
@@ -34,7 +39,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 图标
|
||||
const siteIcon = ref<string>('')
|
||||
const defaultSiteIcon = getLogoUrl('site')
|
||||
const siteIcon = ref<string>(defaultSiteIcon)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -59,12 +65,20 @@ const siteUserDataDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
const siteId = cardProps.site?.id
|
||||
if (!siteId) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
||||
if (!siteIcon.value) {
|
||||
siteIcon.value = getLogoUrl('site')
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||
const response = await api.get(`site/icon/${siteId}`)
|
||||
|
||||
return response?.data?.icon || defaultSiteIcon
|
||||
})
|
||||
} catch (error) {
|
||||
siteIcon.value = defaultSiteIcon
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +123,22 @@ function openSitePage() {
|
||||
window.open(cardProps.site?.url, '_blank')
|
||||
}
|
||||
|
||||
function handleCardClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
handleResourceBrowse()
|
||||
}
|
||||
|
||||
function handleSiteUrlClick() {
|
||||
if (cardProps.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
openSitePage()
|
||||
}
|
||||
|
||||
// 调用API删除站点信息
|
||||
async function deleteSiteInfo() {
|
||||
const isConfirmed = await createConfirm({
|
||||
@@ -196,21 +226,24 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300 cursor-pointer hover:-translate-y-1"
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
rounded="lg"
|
||||
hover
|
||||
@click="handleResourceBrowse"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
@@ -225,7 +258,7 @@ onMounted(() => {
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
@@ -242,17 +275,37 @@ onMounted(() => {
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.limit_interval" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-speedometer"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.proxy" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-network-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.proxy" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-network-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.render" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-apple-safari" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.render" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-apple-safari"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="cardProps.site?.filter" class="hover:bg-primary/8 transition-colors">
|
||||
<VIcon icon="mdi-filter-cog-outline" size="16" color="primary" class="opacity-85 hover:opacity-100" />
|
||||
<div v-if="cardProps.site?.filter" :class="cardProps.sortable ? '' : 'hover:bg-primary/8 transition-colors'">
|
||||
<VIcon
|
||||
icon="mdi-filter-cog-outline"
|
||||
size="16"
|
||||
color="primary"
|
||||
:class="cardProps.sortable ? 'opacity-85' : 'opacity-85 hover:opacity-100'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,10 +313,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
|
||||
{{ cardProps.site?.url }}
|
||||
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="handleSiteUrlClick">
|
||||
{{ cardProps.site?.url }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部:数据统计 -->
|
||||
<div class="flex-1 flex flex-col justify-end">
|
||||
@@ -295,7 +348,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 右侧操作按钮区 -->
|
||||
<VSheet class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<VSheet v-if="!cardProps.sortable" class="site-card-actions absolute inset-y-0 right-0 z-20 flex flex-col py-2 px-1">
|
||||
<!-- 测试按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
@@ -418,7 +471,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:hover .site-status-indicator {
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -266,6 +270,7 @@ watch(
|
||||
(newOpenState, _) => {
|
||||
if (newOpenState) editSubscribeDialog()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听订阅状态
|
||||
@@ -308,6 +313,10 @@ function onSubscribeEditRemove() {
|
||||
|
||||
// 处理卡片点击事件
|
||||
function handleCardClick() {
|
||||
if (props.sortable) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.batchMode) {
|
||||
// 批量模式下触发选择事件
|
||||
emit('select')
|
||||
@@ -325,7 +334,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
@@ -336,13 +345,14 @@ function handleCardClick() {
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div class="me-n3 absolute top-1 right-4">
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
@@ -380,7 +390,7 @@ function handleCardClick() {
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||
<template #placeholder>
|
||||
@@ -400,8 +410,15 @@ function handleCardClick() {
|
||||
</VCardText>
|
||||
<VCardText class="flex justify-space-between align-center flex-wrap px-3">
|
||||
<div class="flex align-center">
|
||||
<VIcon
|
||||
v-if="props.media?.total_episode && props.sortable"
|
||||
icon="mdi-progress-download"
|
||||
size="small"
|
||||
color="white"
|
||||
class="me-1"
|
||||
/>
|
||||
<IconBtn
|
||||
v-if="props.media?.total_episode"
|
||||
v-else-if="props.media?.total_episode"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
icon="mdi-progress-download"
|
||||
@@ -411,7 +428,8 @@ function handleCardClick() {
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
</div>
|
||||
<IconBtn v-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" />
|
||||
<span v-if="props.media?.username" class="text-subtitle-2 text-white">
|
||||
{{ props.media?.username }}
|
||||
</span>
|
||||
|
||||
@@ -5,6 +5,8 @@ import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -32,8 +34,7 @@ const downloadItem = ref(props.torrent)
|
||||
// 站点图标
|
||||
const siteIcons = ref<Record<number, string>>({})
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
@@ -41,8 +42,7 @@ const addDownloadDialog = ref(false)
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -53,10 +53,21 @@ function addDownloadError(error: string) {
|
||||
// 查询站点图标
|
||||
async function getSiteIcon(site: number | undefined) {
|
||||
if (!site) return
|
||||
|
||||
try {
|
||||
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
|
||||
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
siteIcons.value[site] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,20 +120,27 @@ async function openMoreTorrentsDialog() {
|
||||
showMoreTorrents.value = true
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
downloadItem.value = value
|
||||
getSiteIcon(value?.torrent_info?.site)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
|
||||
@@ -4,6 +4,8 @@ import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -22,37 +24,31 @@ const meta = ref(props.torrent?.meta_info)
|
||||
// 站点图标
|
||||
const siteIcon = ref('')
|
||||
|
||||
// 站点图标加载状态
|
||||
const iconLoading = ref(false)
|
||||
const iconError = ref(false)
|
||||
|
||||
// 存储是否已经下载过的记录
|
||||
const downloaded = ref<string[]>([])
|
||||
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 查询站点图标
|
||||
async function getSiteIcon() {
|
||||
if (!torrent?.value?.site || iconLoading.value) {
|
||||
if (!torrent?.value?.site) {
|
||||
return
|
||||
}
|
||||
|
||||
iconLoading.value = true
|
||||
iconError.value = false
|
||||
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
||||
if (response && response.data && response.data.icon) {
|
||||
siteIcon.value = response.data.icon
|
||||
} else {
|
||||
iconError.value = true
|
||||
}
|
||||
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||
try {
|
||||
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||
|
||||
return response?.data?.icon || ''
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load site icon:', error)
|
||||
iconError.value = true
|
||||
} finally {
|
||||
iconLoading.value = false
|
||||
siteIcon.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,8 +79,7 @@ async function handleAddDownload() {
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
addDownloadDialog.value = false
|
||||
// 添加下载成功
|
||||
downloaded.value.push(url)
|
||||
markTorrentDownloaded(url)
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
@@ -97,10 +92,16 @@ function openTorrentDetail() {
|
||||
window.open(torrent.value?.page_url, '_blank')
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
onMounted(() => {
|
||||
getSiteIcon()
|
||||
})
|
||||
watch(
|
||||
() => props.torrent,
|
||||
value => {
|
||||
torrent.value = value?.torrent_info
|
||||
media.value = value?.media_info
|
||||
meta.value = value?.meta_info
|
||||
getSiteIcon()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -108,7 +109,7 @@ onMounted(() => {
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VList lines="two">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
|
||||
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<template v-if="historyList.length > 0">
|
||||
<template v-for="(item, i) in historyList" :key="i">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||
|
||||
@@ -91,6 +91,7 @@ const userForm = ref<ExtendedUser>({
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
wechatclawbot_userid: null,
|
||||
telegram_userid: null,
|
||||
slack_userid: null,
|
||||
discord_userid: null,
|
||||
@@ -503,6 +504,15 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-wechat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.wechatclawbot_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('dialog.userAddEdit.wechatClawBot')"
|
||||
prepend-inner-icon="mdi-robot-happy-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="userForm.settings.telegram_userid"
|
||||
|
||||
@@ -148,7 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -287,6 +292,9 @@ async function download(item: FileItem) {
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +312,7 @@ async function getImgLink(item: FileItem) {
|
||||
const result: Blob = (await inProps.axios.request<Blob, Blob>(config))
|
||||
if (result) {
|
||||
// 创建图片地址
|
||||
revokeCurrentImgLink()
|
||||
currentImgLink.value = URL.createObjectURL(result)
|
||||
}
|
||||
}
|
||||
@@ -314,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -597,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
252
src/components/misc/ProgressiveCardGrid.vue
Normal file
252
src/components/misc/ProgressiveCardGrid.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
estimatedItemHeight?: number
|
||||
scrollToIndex?: number
|
||||
gap?: number
|
||||
initialCount?: number
|
||||
batchSize?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
estimatedItemHeight: undefined,
|
||||
scrollToIndex: undefined,
|
||||
gap: 16,
|
||||
initialCount: 24,
|
||||
batchSize: 24,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const sentinelRef = ref<HTMLElement | null>(null)
|
||||
const renderedCount = ref(0)
|
||||
|
||||
let animationFrameId: number | null = null
|
||||
|
||||
const safeInitialCount = computed(() => Math.max(1, props.initialCount))
|
||||
const safeBatchSize = computed(() => Math.max(1, props.batchSize))
|
||||
const hasMoreItems = computed(() => renderedCount.value < props.items.length)
|
||||
const visibleItems = computed(() => props.items.slice(0, renderedCount.value))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${props.gap}px`,
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${props.minItemWidth}px, 1fr))`,
|
||||
rowGap: `${props.gap}px`,
|
||||
}))
|
||||
|
||||
function getComparableKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
return getComparableKey(item, index)
|
||||
}
|
||||
|
||||
function appendNextBatch() {
|
||||
renderedCount.value = Math.min(props.items.length, renderedCount.value + safeBatchSize.value)
|
||||
}
|
||||
|
||||
function hasPageScroll() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
||||
|
||||
return scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||
}
|
||||
|
||||
async function fillViewport() {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const maxIterations = Math.ceil(props.items.length / safeBatchSize.value)
|
||||
let iterations = 0
|
||||
|
||||
while (!hasPageScroll() && hasMoreItems.value && iterations < maxIterations) {
|
||||
appendNextBatch()
|
||||
iterations += 1
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
function queueFillViewport() {
|
||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameId = window.requestAnimationFrame(() => {
|
||||
animationFrameId = null
|
||||
void fillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
async function revealItem(index: number) {
|
||||
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const minRenderedCount = Math.ceil((index + 1) / safeBatchSize.value) * safeBatchSize.value
|
||||
renderedCount.value = Math.min(props.items.length, Math.max(renderedCount.value, minRenderedCount))
|
||||
|
||||
await nextTick()
|
||||
|
||||
const target = containerRef.value?.querySelector(`[data-progressive-grid-index="${index}"]`)
|
||||
if (target instanceof HTMLElement) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetVisibleItems() {
|
||||
renderedCount.value = Math.min(props.items.length, safeInitialCount.value)
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function didItemsAppend(nextItems: any[], previousItems: any[]) {
|
||||
if (!previousItems.length || nextItems.length < previousItems.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return previousItems.every((item, index) => getComparableKey(item, index) === getComparableKey(nextItems[index], index))
|
||||
}
|
||||
|
||||
function syncVisibleItems(nextItems: any[], previousItems: any[] = []) {
|
||||
if (didItemsAppend(nextItems, previousItems)) {
|
||||
renderedCount.value = Math.min(nextItems.length, Math.max(renderedCount.value, previousItems.length))
|
||||
|
||||
nextTick(() => {
|
||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
||||
void revealItem(props.scrollToIndex)
|
||||
return
|
||||
}
|
||||
|
||||
queueFillViewport()
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resetVisibleItems()
|
||||
}
|
||||
|
||||
const { stop } = useIntersectionObserver(
|
||||
sentinelRef,
|
||||
([entry]) => {
|
||||
if (!entry?.isIntersecting || !hasMoreItems.value) {
|
||||
return
|
||||
}
|
||||
|
||||
appendNextBatch()
|
||||
queueFillViewport()
|
||||
},
|
||||
{
|
||||
rootMargin: '1200px 0px',
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', queueFillViewport, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
window.removeEventListener('resize', queueFillViewport)
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.minItemWidth,
|
||||
() => props.initialCount,
|
||||
() => props.batchSize,
|
||||
],
|
||||
() => {
|
||||
queueFillViewport()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(nextItems, previousItems) => {
|
||||
syncVisibleItems(nextItems, previousItems)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.scrollToIndex, () => props.items.length],
|
||||
([scrollToIndex]) => {
|
||||
if (scrollToIndex === undefined || scrollToIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
void revealItem(scrollToIndex)
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="progressive-card-grid">
|
||||
<div class="grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="(item, index) in visibleItems"
|
||||
:key="resolveItemKey(item, index)"
|
||||
class="progressive-card-grid__item"
|
||||
:data-progressive-grid-index="index"
|
||||
>
|
||||
<slot :item="item" :index="index" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasMoreItems" ref="sentinelRef" class="progressive-card-grid__sentinel" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progressive-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.progressive-card-grid__item {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.progressive-card-grid__sentinel {
|
||||
block-size: 1px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,335 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 判断是否可以触摸
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
|
||||
// 元素
|
||||
const slideview_content = ref<HTMLElement | null>(null)
|
||||
const sliderContainer = ref<HTMLElement | null>(null)
|
||||
// 分页切换状态: 0-左边不可用 1-两边可用 2-右边不可用 3-两边都不可用
|
||||
const disabled = ref(0)
|
||||
// 记录滚动值
|
||||
const slideview_scrollLeft = ref(0)
|
||||
// 所有卡片数量
|
||||
let slide_card_length: number
|
||||
// 卡片间距
|
||||
let slide_gap_px: number
|
||||
// 卡片宽度
|
||||
let card_width: number
|
||||
// 容器最多显示N张卡片
|
||||
let card_max: number
|
||||
// 当前定位
|
||||
let card_current: number
|
||||
// 获取传入的链接地址
|
||||
const props: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
const isScrolling = ref(false)
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const scrollTimeoutDuration = 1500 // 滚动停止后延迟时间 (ms)
|
||||
|
||||
// 分页切换
|
||||
function slideNext(next: boolean) {
|
||||
let run_to_left_px
|
||||
if (next) {
|
||||
const card_index = card_current + card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px >= slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth)
|
||||
run_to_left_px = slideview_content.value!.scrollWidth - slideview_content.value!.clientWidth
|
||||
} else {
|
||||
const card_index = card_current - card_max
|
||||
run_to_left_px = card_index * card_width
|
||||
if (run_to_left_px <= 0) run_to_left_px = 0
|
||||
}
|
||||
slideview_content.value!.scrollTo({
|
||||
top: 0,
|
||||
left: run_to_left_px,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
|
||||
// 点击后强制显示并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
// 计算最大显示数量
|
||||
function countMaxNumber() {
|
||||
if (!slideview_content.value || !slideview_content.value.firstElementChild) return
|
||||
slide_card_length = slideview_content.value.children.length
|
||||
card_width = slideview_content.value.firstElementChild.getBoundingClientRect().width
|
||||
slide_gap_px = slideview_content.value.scrollWidth / slide_card_length - card_width
|
||||
card_width += slide_gap_px
|
||||
card_max = Math.trunc(slideview_content.value.clientWidth / card_width)
|
||||
countDisabled()
|
||||
}
|
||||
|
||||
// 修改分页切换按钮状态 & 处理滚动状态
|
||||
function handleContentScroll() {
|
||||
if (!slideview_content.value) return
|
||||
// 更新按钮禁用状态
|
||||
countDisabled()
|
||||
|
||||
// 更新滚动状态并重置计时器
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration) // 使用常量
|
||||
}
|
||||
|
||||
// 原始的 countDisabled 逻辑,现在由 handleContentScroll 调用
|
||||
function countDisabled() {
|
||||
if (!slideview_content.value) return
|
||||
slideview_scrollLeft.value = slideview_content.value.scrollLeft
|
||||
card_current =
|
||||
slideview_content.value.scrollLeft === 0
|
||||
? 0
|
||||
: Math.trunc((slideview_content.value.scrollLeft + card_width / 2) / card_width)
|
||||
if (slide_card_length * card_width <= slideview_content.value.clientWidth) disabled.value = 3
|
||||
else if (slideview_content.value.scrollLeft === 0) disabled.value = 0
|
||||
else if (
|
||||
slideview_content.value.scrollLeft >=
|
||||
slideview_content.value.scrollWidth - slideview_content.value.clientWidth - 2
|
||||
)
|
||||
disabled.value = 2
|
||||
else disabled.value = 1
|
||||
}
|
||||
|
||||
// 组件加载完成
|
||||
onMounted(() => {
|
||||
// 初次获取元素参数
|
||||
countMaxNumber()
|
||||
// 窗口大小发生改变时
|
||||
window.addEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 卸载事件
|
||||
window.removeEventListener('resize', countMaxNumber)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideview_scrollLeft.value !== 0) {
|
||||
slideview_content.value!.scrollLeft = slideview_scrollLeft.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="sliderContainer" class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<!-- 查看全部按钮 -->
|
||||
<RouterLink v-if="props.linkurl" :to="props.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideview_content" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 左侧导航按钮 -->
|
||||
<VBtn
|
||||
class="nav-button nav-button-left"
|
||||
@click.stop="slideNext(false)"
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<!-- 右侧导航按钮 -->
|
||||
<VBtn
|
||||
class="nav-button nav-button-right"
|
||||
@click.stop="slideNext(true)"
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
display: grid;
|
||||
overflow: scroll hidden !important;
|
||||
justify-content: start;
|
||||
gap: 16px;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: 1fr;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 触摸设备:滚动时显示 (通过 JS 添加的类控制)
|
||||
// 这个规则会在不支持 hover 的设备上生效
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 桌面设备:悬停时显示
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
// 这个规则会覆盖 .is-scrolling 的效果 (如果同时存在)
|
||||
// 或者在非 scrolling 状态下,hover 时也能显示
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
// 在 hover 设备上,即使在滚动,如果鼠标不悬停,按钮也应该隐藏
|
||||
// 因此,基础 .nav-button 的 opacity: 0 规则在这里仍然是必要的
|
||||
// (之前错误地以为 hover 会完全覆盖,但滚动时 class 和 hover 可能同时存在)
|
||||
// .nav-button { opacity: 0; pointer-events: none; } // 这行其实不需要重复,默认就是这样
|
||||
}
|
||||
</style>
|
||||
433
src/components/slide/VirtualSlideView.vue
Normal file
433
src/components/slide/VirtualSlideView.vue
Normal file
@@ -0,0 +1,433 @@
|
||||
<script lang="ts" setup>
|
||||
import SlideViewTitle from '@/components/slide/SlideViewTitle.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
itemWidth?: number
|
||||
itemGap?: number
|
||||
overscanItems?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
itemWidth: 144,
|
||||
itemGap: 16,
|
||||
overscanItems: 4,
|
||||
getItemKey: undefined,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const display = useDisplay()
|
||||
const isTouch = computed(() => display.mobile.value)
|
||||
const injectedProps: any = inject('rankingPropsKey', { linkurl: '', title: '' })
|
||||
|
||||
const slideContentRef = ref<HTMLElement | null>(null)
|
||||
const disabled = ref(0)
|
||||
const slideScrollLeft = ref(0)
|
||||
const isScrolling = ref(false)
|
||||
const startIndex = ref(0)
|
||||
const endIndex = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scrollTimeoutDuration = 1500
|
||||
const itemStep = computed(() => props.itemWidth + props.itemGap)
|
||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||
|
||||
const leadingSpaceWidth = computed(() => startIndex.value * itemStep.value)
|
||||
|
||||
const visibleItemsWidth = computed(() => {
|
||||
if (!visibleItems.value.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return visibleItems.value.length * props.itemWidth + Math.max(visibleItems.value.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const totalContentWidth = computed(() => {
|
||||
if (!props.items.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return props.items.length * props.itemWidth + Math.max(props.items.length - 1, 0) * props.itemGap
|
||||
})
|
||||
|
||||
const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
}
|
||||
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
}
|
||||
|
||||
scrollTimeout = setTimeout(() => {
|
||||
isScrolling.value = false
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const viewportWidth = element.clientWidth
|
||||
if (!viewportWidth || !props.items.length) {
|
||||
startIndex.value = 0
|
||||
endIndex.value = Math.min(props.items.length, props.overscanItems)
|
||||
return
|
||||
}
|
||||
|
||||
const firstVisible = Math.max(0, Math.floor(element.scrollLeft / itemStep.value) - props.overscanItems)
|
||||
const lastVisible = Math.min(
|
||||
props.items.length,
|
||||
Math.ceil((element.scrollLeft + viewportWidth) / itemStep.value) + props.overscanItems,
|
||||
)
|
||||
|
||||
startIndex.value = firstVisible
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
slideScrollLeft.value = element.scrollLeft
|
||||
|
||||
if (!props.items.length || totalContentWidth.value <= element.clientWidth) {
|
||||
disabled.value = 3
|
||||
} else if (element.scrollLeft === 0) {
|
||||
disabled.value = 0
|
||||
} else if (element.scrollLeft >= element.scrollWidth - element.clientWidth - 2) {
|
||||
disabled.value = 2
|
||||
} else {
|
||||
disabled.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
|
||||
const visibleCount = Math.max(1, Math.trunc(element.clientWidth / itemStep.value))
|
||||
const currentIndex = element.scrollLeft === 0 ? 0 : Math.trunc((element.scrollLeft + itemStep.value / 2) / itemStep.value)
|
||||
let targetLeft = 0
|
||||
|
||||
if (next) {
|
||||
targetLeft = Math.min((currentIndex + visibleCount) * itemStep.value, element.scrollWidth - element.clientWidth)
|
||||
} else {
|
||||
targetLeft = Math.max((currentIndex - visibleCount) * itemStep.value, 0)
|
||||
}
|
||||
|
||||
element.scrollTo({
|
||||
behavior: 'smooth',
|
||||
left: targetLeft,
|
||||
top: 0,
|
||||
})
|
||||
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncLayoutState()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncLayoutState()
|
||||
})
|
||||
|
||||
if (slideContentRef.value) {
|
||||
resizeObserver.observe(slideContentRef.value)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', syncLayoutState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout)
|
||||
scrollTimeout = null
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncLayoutState)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
if (slideContentRef.value && slideScrollLeft.value !== 0) {
|
||||
slideContentRef.value.scrollLeft = slideScrollLeft.value
|
||||
}
|
||||
|
||||
nextTick(syncLayoutState)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
() => {
|
||||
nextTick(syncLayoutState)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slider-container" :class="{ 'is-scrolling': isScrolling }">
|
||||
<div class="slider-header">
|
||||
<slot name="title">
|
||||
<SlideViewTitle />
|
||||
</slot>
|
||||
<RouterLink v-if="injectedProps.linkurl" :to="injectedProps.linkurl" class="view-all-button">
|
||||
<span>更多</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" class="arrow-svg">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="slider-content-wrapper">
|
||||
<div class="slider-content-container">
|
||||
<div ref="slideContentRef" class="slider-content" tabindex="0" @scroll="handleContentScroll">
|
||||
<template v-if="loading">
|
||||
<div class="loading-track" :style="{ gap: `${itemGap}px` }">
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="items.length > 0">
|
||||
<div class="virtual-track" :style="{ width: `${totalContentWidth}px` }">
|
||||
<div v-if="leadingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${leadingSpaceWidth}px` }" />
|
||||
|
||||
<template v-for="(item, index) in visibleItems" :key="resolveItemKey(item, index)">
|
||||
<div
|
||||
class="virtual-slide-item"
|
||||
:style="{
|
||||
marginInlineEnd: index === visibleItems.length - 1 ? '0px' : `${itemGap}px`,
|
||||
width: `${itemWidth}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="item" :index="startIndex + index" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="trailingSpaceWidth > 0" class="virtual-spacer" :style="{ width: `${trailingSpaceWidth}px` }" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slider-container {
|
||||
position: relative;
|
||||
margin-block-end: 8px;
|
||||
}
|
||||
|
||||
.slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-block-end: 8px;
|
||||
padding-block: 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
& > :first-child {
|
||||
flex-grow: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-all-button {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding-block: 5px;
|
||||
padding-inline: 12px;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
.arrow-svg {
|
||||
fill: currentcolor;
|
||||
margin-inline-start: 2px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.arrow-svg {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-content-wrapper {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.slider-content {
|
||||
overflow: scroll hidden !important;
|
||||
-ms-overflow-style: none !important;
|
||||
overscroll-behavior-x: contain !important;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.virtual-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
}
|
||||
|
||||
.loading-track {
|
||||
display: flex;
|
||||
inline-size: max-content;
|
||||
min-inline-size: 100%;
|
||||
}
|
||||
|
||||
.virtual-slide-item,
|
||||
.virtual-spacer,
|
||||
.loading-track > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), background-color 0.3s ease,
|
||||
box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button-left {
|
||||
inset-inline-start: 8px;
|
||||
}
|
||||
|
||||
.nav-button-right {
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
|
||||
.slider-container.is-scrolling .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.slider-container:hover .nav-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -28,11 +28,24 @@ export function useBackgroundOptimization() {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
const isConnected = ref(false)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
isConnected.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 延迟建立连接,确保组件完全挂载
|
||||
const connectDelay = options?.connectDelay || 100
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
try {
|
||||
manager.addMessageListener(listenerId, event => {
|
||||
messageHandler(event)
|
||||
@@ -44,15 +57,12 @@ export function useBackgroundOptimization() {
|
||||
}, connectDelay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
isConnected.value = false
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
isConnected,
|
||||
forceReconnect: () => manager.forceReconnect(),
|
||||
}
|
||||
@@ -104,21 +114,31 @@ export function useBackgroundOptimization() {
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, options)
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}, delay)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
manager.removeMessageListener(listenerId)
|
||||
})
|
||||
onUnmounted(cleanup)
|
||||
|
||||
return {
|
||||
manager,
|
||||
readyState: () => manager.readyState,
|
||||
close: () => manager.removeMessageListener(listenerId),
|
||||
close: cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,31 +155,50 @@ export function useBackgroundOptimization() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
// 使用独立的SSE管理器,确保每个监听器都有独立的连接
|
||||
const manager = sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
|
||||
const startProgress = () => {
|
||||
if (isActive.value) {
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
}
|
||||
if (!isActive.value || isListening) return
|
||||
|
||||
manager ??= getManager()
|
||||
manager.addMessageListener(listenerId, messageHandler)
|
||||
isListening = true
|
||||
}
|
||||
|
||||
const stopProgress = () => {
|
||||
const stopProgress = (destroyManager = true) => {
|
||||
if (!manager) {
|
||||
isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
manager = null
|
||||
}
|
||||
|
||||
isListening = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopProgress()
|
||||
stopProgress(true)
|
||||
})
|
||||
|
||||
return {
|
||||
start: startProgress,
|
||||
stop: stopProgress,
|
||||
manager,
|
||||
get manager() {
|
||||
return manager
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
409
src/composables/useLlmProviderDirectory.ts
Normal file
409
src/composables/useLlmProviderDirectory.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export interface LlmProviderAuthMethod {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface LlmProviderAuthStatus {
|
||||
connected: boolean
|
||||
type?: string
|
||||
label?: string
|
||||
expires_at?: number | null
|
||||
updated_at?: number | null
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPreset {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface LlmProviderUrlPresetItem {
|
||||
id: string
|
||||
title: string
|
||||
value: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: string
|
||||
name: string
|
||||
runtime: string
|
||||
default_base_url: string
|
||||
base_url_presets?: LlmProviderUrlPreset[]
|
||||
base_url_editable: boolean
|
||||
requires_base_url: boolean
|
||||
supports_api_key: boolean
|
||||
api_key_label: string
|
||||
api_key_hint: string
|
||||
supports_model_refresh: boolean
|
||||
oauth_methods: LlmProviderAuthMethod[]
|
||||
description?: string
|
||||
auth_status: LlmProviderAuthStatus
|
||||
}
|
||||
|
||||
export interface LlmModel {
|
||||
id: string
|
||||
name: string
|
||||
family?: string
|
||||
context_tokens?: number | null
|
||||
input_tokens?: number | null
|
||||
output_tokens?: number | null
|
||||
context_tokens_k?: number | null
|
||||
supports_reasoning?: boolean
|
||||
supports_tools?: boolean
|
||||
supports_image_input?: boolean
|
||||
supports_audio_input?: boolean
|
||||
transport?: string
|
||||
source?: string
|
||||
release_date?: string | null
|
||||
status?: string | null
|
||||
}
|
||||
|
||||
export interface LlmProviderAuthSession {
|
||||
session_id: string
|
||||
provider_id: string
|
||||
flow_type: string
|
||||
status: string
|
||||
message?: string
|
||||
authorize_url?: string
|
||||
verification_url?: string
|
||||
user_code?: string
|
||||
instructions?: string
|
||||
interval_seconds?: number
|
||||
expires_at?: number
|
||||
}
|
||||
|
||||
interface UseLlmProviderDirectoryOptions {
|
||||
provider: Ref<string>
|
||||
apiKey: Ref<string>
|
||||
baseUrl: Ref<string>
|
||||
baseUrlPreset?: Ref<string>
|
||||
model: Ref<string>
|
||||
maxContextTokens?: Ref<number>
|
||||
authConnected?: Ref<boolean>
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions) {
|
||||
const providers = ref<LlmProvider[]>([])
|
||||
const models = ref<LlmModel[]>([])
|
||||
const loadingProviders = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const authDialogVisible = ref(false)
|
||||
const authPolling = ref(false)
|
||||
const authPopupBlocked = ref(false)
|
||||
const authSession = ref<LlmProviderAuthSession | null>(null)
|
||||
|
||||
let pollTimer: number | null = null
|
||||
|
||||
const selectedProvider = computed(
|
||||
() => providers.value.find(item => item.id === normalizeValue(options.provider.value)) || null,
|
||||
)
|
||||
const selectedModel = computed(
|
||||
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
|
||||
)
|
||||
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
|
||||
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
|
||||
(selectedProvider.value?.base_url_presets || []).map(item => ({
|
||||
id: item.id,
|
||||
title: item.value,
|
||||
value: item.value,
|
||||
subtitle: item.label,
|
||||
})),
|
||||
)
|
||||
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
|
||||
const showBaseUrlField = computed(
|
||||
() => Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
|
||||
)
|
||||
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
|
||||
const hasUsableCredential = computed(() => {
|
||||
if (providerConnected.value) return true
|
||||
return Boolean(normalizeValue(options.apiKey.value))
|
||||
})
|
||||
const canRefreshModels = computed(() => {
|
||||
if (!selectedProvider.value?.supports_model_refresh) return false
|
||||
if (!hasUsableCredential.value) return false
|
||||
if (selectedProvider.value.requires_base_url && !normalizeValue(options.baseUrl.value)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function clearPollTimer() {
|
||||
if (pollTimer !== null) {
|
||||
window.clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function syncAuthConnected() {
|
||||
if (options.authConnected) {
|
||||
options.authConnected.value = providerConnected.value
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBaseUrl(reset = false) {
|
||||
const provider = selectedProvider.value
|
||||
if (!provider) return
|
||||
|
||||
const currentBaseUrl = normalizeValue(options.baseUrl.value)
|
||||
const defaultBaseUrl = provider.default_base_url || ''
|
||||
const defaultPresetId = normalizeValue(provider.base_url_presets?.[0]?.id)
|
||||
if (reset) {
|
||||
options.baseUrl.value = defaultBaseUrl
|
||||
if (options.baseUrlPreset) {
|
||||
options.baseUrlPreset.value = defaultPresetId
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentBaseUrl && defaultBaseUrl) {
|
||||
options.baseUrl.value = defaultBaseUrl
|
||||
}
|
||||
|
||||
if (!options.baseUrlPreset) return
|
||||
|
||||
const currentPresetId = normalizeValue(options.baseUrlPreset.value)
|
||||
if (currentPresetId) return
|
||||
|
||||
const matchedPreset = (provider.base_url_presets || []).find(
|
||||
item => normalizeValue(item.value) === normalizeValue(options.baseUrl.value),
|
||||
)
|
||||
options.baseUrlPreset.value = matchedPreset?.id || defaultPresetId
|
||||
}
|
||||
|
||||
function setBaseUrlPreset(presetId?: string, presetValue?: string) {
|
||||
if (!options.baseUrlPreset) return
|
||||
|
||||
options.baseUrlPreset.value = normalizeValue(presetId)
|
||||
if (presetValue !== undefined) {
|
||||
options.baseUrl.value = presetValue || ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSelection(resetBaseUrl = true) {
|
||||
ensureBaseUrl(resetBaseUrl)
|
||||
options.apiKey.value = ''
|
||||
if (options.maxContextTokens) {
|
||||
options.maxContextTokens.value = 64
|
||||
}
|
||||
models.value = []
|
||||
options.model.value = ''
|
||||
syncAuthConnected()
|
||||
}
|
||||
|
||||
function applyModelMetadata(modelId?: string) {
|
||||
const targetId = normalizeValue(modelId ?? options.model.value)
|
||||
if (!targetId) return null
|
||||
|
||||
const matched = models.value.find(item => item.id === targetId) || null
|
||||
if (matched?.context_tokens_k && options.maxContextTokens) {
|
||||
// models.dev / provider 返回的是精确 token,这里回填到现有的 K 单位配置。
|
||||
options.maxContextTokens.value = matched.context_tokens_k
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
function updateProviderAuthStatus(providerId: string, authStatus?: LlmProviderAuthStatus) {
|
||||
if (!authStatus) return
|
||||
const index = providers.value.findIndex(item => item.id === providerId)
|
||||
if (index === -1) return
|
||||
|
||||
providers.value[index] = {
|
||||
...providers.value[index],
|
||||
auth_status: authStatus,
|
||||
}
|
||||
syncAuthConnected()
|
||||
}
|
||||
|
||||
async function loadProviders(preserveBaseUrl = true) {
|
||||
loadingProviders.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/providers')
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM providers failed')
|
||||
}
|
||||
|
||||
providers.value = Array.isArray(result.data) ? result.data : []
|
||||
if (!selectedProvider.value && providers.value.length > 0) {
|
||||
options.provider.value = providers.value[0].id
|
||||
}
|
||||
ensureBaseUrl(!preserveBaseUrl)
|
||||
syncAuthConnected()
|
||||
return providers.value
|
||||
} finally {
|
||||
loadingProviders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModels(forceRefresh = false) {
|
||||
if (!selectedProvider.value) return []
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('llm/models', {
|
||||
params: {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
api_key: normalizeValue(options.apiKey.value) || undefined,
|
||||
base_url: normalizeValue(options.baseUrl.value) || undefined,
|
||||
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
|
||||
force_refresh: forceRefresh,
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Load LLM models failed')
|
||||
}
|
||||
|
||||
const payload = result.data || {}
|
||||
models.value = Array.isArray(payload.models) ? payload.models : []
|
||||
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
|
||||
|
||||
const currentModelId = normalizeValue(options.model.value)
|
||||
const matchedModel = currentModelId
|
||||
? models.value.find(item => item.id === currentModelId)
|
||||
: null
|
||||
|
||||
if (matchedModel) {
|
||||
applyModelMetadata(matchedModel.id)
|
||||
} else if (models.value.length > 0) {
|
||||
options.model.value = models.value[0].id
|
||||
applyModelMetadata(models.value[0].id)
|
||||
}
|
||||
|
||||
return models.value
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAuthPage() {
|
||||
const session = authSession.value
|
||||
const targetUrl = session?.authorize_url || session?.verification_url
|
||||
if (!targetUrl) return
|
||||
|
||||
const popup = window.open(targetUrl, '_blank', 'noopener,noreferrer,width=960,height=780')
|
||||
authPopupBlocked.value = !popup
|
||||
}
|
||||
|
||||
async function pollAuthSession() {
|
||||
if (!authSession.value) return null
|
||||
|
||||
authPolling.value = true
|
||||
clearPollTimer()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post(
|
||||
`llm/provider-auth/${authSession.value.session_id}/poll`,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Poll LLM auth failed')
|
||||
}
|
||||
|
||||
authSession.value = {
|
||||
...authSession.value,
|
||||
...result.data,
|
||||
}
|
||||
const nextSession = authSession.value
|
||||
if (!nextSession) return null
|
||||
|
||||
if (nextSession.status === 'pending') {
|
||||
pollTimer = window.setTimeout(
|
||||
() => pollAuthSession().catch(() => undefined),
|
||||
Math.max(nextSession.interval_seconds || 5, 1) * 1000,
|
||||
)
|
||||
return nextSession
|
||||
}
|
||||
|
||||
await loadProviders()
|
||||
if (nextSession.status === 'authorized') {
|
||||
await loadModels(true).catch(() => undefined)
|
||||
}
|
||||
return nextSession
|
||||
} finally {
|
||||
authPolling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startAuth(methodId: string) {
|
||||
if (!selectedProvider.value) {
|
||||
throw new Error('LLM provider is required')
|
||||
}
|
||||
|
||||
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
|
||||
provider: normalizeValue(options.provider.value),
|
||||
method: methodId,
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Start LLM auth failed')
|
||||
}
|
||||
|
||||
authSession.value = {
|
||||
status: 'pending',
|
||||
provider_id: normalizeValue(options.provider.value),
|
||||
...result.data,
|
||||
}
|
||||
authDialogVisible.value = true
|
||||
authPopupBlocked.value = false
|
||||
openAuthPage()
|
||||
pollTimer = window.setTimeout(() => pollAuthSession().catch(() => undefined), 1200)
|
||||
return authSession.value
|
||||
}
|
||||
|
||||
async function disconnectAuth() {
|
||||
if (!selectedProvider.value) return false
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(
|
||||
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
|
||||
)
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Disconnect LLM auth failed')
|
||||
}
|
||||
|
||||
await loadProviders()
|
||||
return true
|
||||
}
|
||||
|
||||
function closeAuthDialog() {
|
||||
authDialogVisible.value = false
|
||||
clearPollTimer()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearPollTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
providers,
|
||||
providerItems,
|
||||
baseUrlPresetItems,
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
loadingProviders,
|
||||
loadingModels,
|
||||
providerConnected,
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
hasUsableCredential,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
authSession,
|
||||
handleProviderSelection,
|
||||
applyModelMetadata,
|
||||
loadProviders,
|
||||
loadModels,
|
||||
openAuthPage,
|
||||
startAuth,
|
||||
pollAuthSession,
|
||||
disconnectAuth,
|
||||
closeAuthDialog,
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,22 @@ export interface WizardData {
|
||||
global: boolean
|
||||
verbose: boolean
|
||||
provider: string
|
||||
authConnected: boolean
|
||||
model: string
|
||||
thinkingLevel: string
|
||||
supportImageInput: boolean
|
||||
supportAudioInputOutput: boolean
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
voiceApiKey: string
|
||||
voiceBaseUrl: string
|
||||
voiceSttModel: string
|
||||
voiceTtsModel: string
|
||||
voiceTtsVoice: string
|
||||
voiceLanguage: string
|
||||
voiceReplyWithText: boolean
|
||||
jobInterval: number
|
||||
retryTransfer: boolean
|
||||
recommendEnabled: boolean
|
||||
@@ -97,6 +108,7 @@ export interface ValidationErrorState {
|
||||
downloader: {
|
||||
name: boolean
|
||||
host: boolean
|
||||
apikey: boolean
|
||||
username: boolean
|
||||
password: boolean
|
||||
}
|
||||
@@ -121,6 +133,33 @@ export interface ValidationErrorState {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThinkingLevelValue(value?: unknown) {
|
||||
const normalized = String(value ?? '').trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
|
||||
const aliasMap: Record<string, string> = {
|
||||
none: 'off',
|
||||
disabled: 'off',
|
||||
disable: 'off',
|
||||
enabled: 'auto',
|
||||
enable: 'auto',
|
||||
default: 'auto',
|
||||
dynamic: 'auto',
|
||||
}
|
||||
|
||||
return aliasMap[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveThinkingLevelValue(data?: Record<string, any>) {
|
||||
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
|
||||
if (explicit) return explicit
|
||||
|
||||
const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)
|
||||
if (data?.LLM_DISABLE_THINKING === true) return 'off'
|
||||
if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'
|
||||
return legacyEffort || 'off'
|
||||
}
|
||||
|
||||
// 全局状态,所有组件共享
|
||||
const currentStep = ref(1)
|
||||
const totalSteps = 8
|
||||
@@ -195,11 +234,22 @@ const wizardData = ref<WizardData>({
|
||||
global: false,
|
||||
verbose: false,
|
||||
provider: 'deepseek',
|
||||
authConnected: false,
|
||||
model: 'deepseek-chat',
|
||||
thinkingLevel: 'off',
|
||||
supportImageInput: true,
|
||||
supportAudioInputOutput: false,
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
voiceApiKey: '',
|
||||
voiceBaseUrl: '',
|
||||
voiceSttModel: 'gpt-4o-mini-transcribe',
|
||||
voiceTtsModel: 'gpt-4o-mini-tts',
|
||||
voiceTtsVoice: 'alloy',
|
||||
voiceLanguage: 'zh',
|
||||
voiceReplyWithText: false,
|
||||
jobInterval: 0,
|
||||
retryTransfer: false,
|
||||
recommendEnabled: false,
|
||||
@@ -230,6 +280,7 @@ const validationErrors = ref<ValidationErrorState>({
|
||||
downloader: {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
},
|
||||
@@ -269,6 +320,7 @@ export function useSetupWizard() {
|
||||
// 媒体服务器映射
|
||||
mediaServer: {
|
||||
'emby': 'EmbyModule',
|
||||
'zspace': 'ZSpaceModule',
|
||||
'jellyfin': 'JellyfinModule',
|
||||
'plex': 'PlexModule',
|
||||
'trimemedia': 'TrimeMediaModule',
|
||||
@@ -276,8 +328,10 @@ export function useSetupWizard() {
|
||||
},
|
||||
// 通知映射
|
||||
notification: {
|
||||
'feishu': 'FeishuModule',
|
||||
'telegram': 'TelegramModule',
|
||||
'wechat': 'WechatModule',
|
||||
'wechatclawbot': 'WechatClawBotModule',
|
||||
'slack': 'SlackModule',
|
||||
'synologychat': 'SynologyChatModule',
|
||||
'qqbot': 'QQBotModule',
|
||||
@@ -372,7 +426,18 @@ export function useSetupWizard() {
|
||||
wizardData.value.notification.type = type
|
||||
// 如果名称为空或为默认名称,则设置默认名称
|
||||
if (!wizardData.value.notification.name || wizardData.value.notification.name.includes('通知')) {
|
||||
wizardData.value.notification.name = `${type} 通知`
|
||||
const displayNameMap: Record<string, string> = {
|
||||
wechat: '企业微信',
|
||||
feishu: '飞书',
|
||||
wechatclawbot: '微信 ClawBot',
|
||||
telegram: 'Telegram',
|
||||
slack: 'Slack',
|
||||
synologychat: 'SynologyChat',
|
||||
qqbot: 'QQ',
|
||||
vocechat: 'VoceChat',
|
||||
webpush: 'WebPush',
|
||||
}
|
||||
wizardData.value.notification.name = `${displayNameMap[type] || type} 通知`
|
||||
}
|
||||
wizardData.value.notification.enabled = true
|
||||
// 不清空config和switchs,保留用户已输入的值
|
||||
@@ -419,6 +484,7 @@ export function useSetupWizard() {
|
||||
validationErrors.value.downloader = {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
}
|
||||
@@ -501,9 +567,18 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
// 根据下载器类型验证其他必输项
|
||||
if (
|
||||
wizardData.value.downloader.type === 'qbittorrent'
|
||||
|| wizardData.value.downloader.type === 'transmission'
|
||||
if (wizardData.value.downloader.type === 'qbittorrent') {
|
||||
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
|
||||
if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
|
||||
errors.push(t('downloader.usernameRequired'))
|
||||
validationErrors.value.downloader.username = true
|
||||
}
|
||||
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
|
||||
errors.push(t('downloader.passwordRequired'))
|
||||
validationErrors.value.downloader.password = true
|
||||
}
|
||||
} else if (
|
||||
wizardData.value.downloader.type === 'transmission'
|
||||
|| wizardData.value.downloader.type === 'rtorrent'
|
||||
) {
|
||||
if (!wizardData.value.downloader.config?.username?.trim()) {
|
||||
@@ -545,6 +620,15 @@ export function useSetupWizard() {
|
||||
errors.push(t('mediaserver.apiKeyRequired'))
|
||||
validationErrors.value.mediaServer.apikey = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'zspace') {
|
||||
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||
errors.push(t('mediaserver.usernameRequired'))
|
||||
validationErrors.value.mediaServer.username = true
|
||||
}
|
||||
if (!wizardData.value.mediaServer.config?.password?.trim()) {
|
||||
errors.push(t('mediaserver.passwordRequired'))
|
||||
validationErrors.value.mediaServer.password = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'plex') {
|
||||
if (!wizardData.value.mediaServer.config?.token?.trim()) {
|
||||
errors.push(t('mediaserver.tokenRequired'))
|
||||
@@ -595,6 +679,18 @@ export function useSetupWizard() {
|
||||
validationErrors.value.notification.WECHAT_APP_SECRET = true
|
||||
}
|
||||
break
|
||||
case 'wechatclawbot':
|
||||
break
|
||||
case 'feishu':
|
||||
if (!config.FEISHU_APP_ID?.trim()) {
|
||||
errors.push(t('notification.feishu.appIdRequired'))
|
||||
validationErrors.value.notification.FEISHU_APP_ID = true
|
||||
}
|
||||
if (!config.FEISHU_APP_SECRET?.trim()) {
|
||||
errors.push(t('notification.feishu.appSecretRequired'))
|
||||
validationErrors.value.notification.FEISHU_APP_SECRET = true
|
||||
}
|
||||
break
|
||||
case 'telegram':
|
||||
if (!config.TELEGRAM_TOKEN?.trim()) {
|
||||
errors.push(t('notification.telegram.tokenRequired'))
|
||||
@@ -672,8 +768,8 @@ export function useSetupWizard() {
|
||||
validationErrors.value.agent.provider = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.apiKey?.trim()) {
|
||||
errors.push(t('setupWizard.agent.apiKeyRequired'))
|
||||
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
|
||||
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
|
||||
validationErrors.value.agent.apiKey = true
|
||||
}
|
||||
|
||||
@@ -793,7 +889,7 @@ export function useSetupWizard() {
|
||||
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
|
||||
return !!wizardData.value.mediaServer.type
|
||||
case 6: // 消息通知测试 - 只有选择了通知才测试
|
||||
return !!wizardData.value.notification.type
|
||||
return !!wizardData.value.notification.type && wizardData.value.notification.type !== 'wechatclawbot'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1332,10 +1428,20 @@ export function useSetupWizard() {
|
||||
AI_AGENT_VERBOSE: wizardData.value.agent.enabled ? wizardData.value.agent.verbose : false,
|
||||
LLM_PROVIDER: wizardData.value.agent.provider,
|
||||
LLM_MODEL: wizardData.value.agent.model,
|
||||
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
|
||||
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
|
||||
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
|
||||
LLM_API_KEY: wizardData.value.agent.apiKey,
|
||||
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
|
||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
|
||||
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
|
||||
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
|
||||
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
|
||||
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
|
||||
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
|
||||
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
|
||||
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
|
||||
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
|
||||
AI_RECOMMEND_ENABLED:
|
||||
@@ -1428,11 +1534,22 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
|
||||
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
|
||||
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
|
||||
wizardData.value.agent.authConnected = false
|
||||
wizardData.value.agent.model = result.data.LLM_MODEL || ''
|
||||
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
|
||||
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
|
||||
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
|
||||
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
|
||||
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
|
||||
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
|
||||
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
|
||||
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
|
||||
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
|
||||
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
|
||||
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
|
||||
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
|
||||
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import { isMobileDevice } from '@/@core/utils/navigator'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -65,6 +64,10 @@ const showTabsScrollIndicator = ref(false)
|
||||
const showLeftButton = ref(false)
|
||||
const showRightButton = ref(false)
|
||||
|
||||
const isTouchDevice = () => {
|
||||
return window.matchMedia('(hover: none) and (pointer: coarse)').matches || navigator.maxTouchPoints > 0
|
||||
}
|
||||
|
||||
// Function to scroll the tabs
|
||||
const scrollTabs = (direction: 'left' | 'right') => {
|
||||
const el = tabsContainerRef.value
|
||||
@@ -90,17 +93,17 @@ const updateTabsIndicator = () => {
|
||||
const el = tabsContainerRef.value
|
||||
if (!el) return
|
||||
|
||||
// 在移动端不显示滚动指示器
|
||||
const isMobile = isMobileDevice()
|
||||
// 仅在触摸设备上隐藏按钮,非触摸小屏设备仍需支持横向切换
|
||||
const shouldHideScrollControls = isTouchDevice()
|
||||
|
||||
const tolerance = 1 // Allow 1px tolerance
|
||||
const hasOverflow = el.scrollWidth > el.clientWidth + tolerance
|
||||
const isScrolledToEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - tolerance
|
||||
const isScrolledToStart = el.scrollLeft <= tolerance
|
||||
|
||||
showTabsScrollIndicator.value = hasOverflow && !isScrolledToEnd && !isMobile
|
||||
showLeftButton.value = hasOverflow && !isScrolledToStart && !isMobile
|
||||
showRightButton.value = hasOverflow && !isScrolledToEnd && !isMobile
|
||||
showTabsScrollIndicator.value = hasOverflow && !isScrolledToEnd && !shouldHideScrollControls
|
||||
showLeftButton.value = hasOverflow && !isScrolledToStart && !shouldHideScrollControls
|
||||
showRightButton.value = hasOverflow && !isScrolledToEnd && !shouldHideScrollControls
|
||||
}
|
||||
|
||||
// Debounce resize handler
|
||||
@@ -185,8 +188,8 @@ onUnmounted(() => {
|
||||
margin-inline-start: 6px;
|
||||
}
|
||||
|
||||
// 在移动端隐藏滚动按钮
|
||||
@media (width <= 768px) {
|
||||
// 触摸设备支持手势横向滚动,无需额外按钮
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -231,8 +234,8 @@ onUnmounted(() => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// 在移动端隐藏渐变指示器
|
||||
@media (width <= 768px) {
|
||||
// 触摸设备支持手势横向滚动,无需额外指示器
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
&::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import { getQueryValue } from '@/@core/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { clearAppBadge } from '@/utils/badge'
|
||||
|
||||
type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -63,7 +69,7 @@ const sendButtonDisabled = ref(false)
|
||||
const messageDialogRef = ref<any>(null)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<any>(null)
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
// 滚动容器引用
|
||||
const messageContentRef = ref<any>()
|
||||
@@ -153,9 +159,7 @@ async function openMessageDialog() {
|
||||
}, 600)
|
||||
// 等待对话框打开后恢复SSE连接
|
||||
nextTick(() => {
|
||||
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
|
||||
messageViewRef.value.resumeSSE()
|
||||
}
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,16 +207,23 @@ function allLoggingUrl() {
|
||||
|
||||
// 发送消息
|
||||
async function sendMessage() {
|
||||
if (user_message.value) {
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${user_message.value}`)
|
||||
user_message.value = ''
|
||||
sendButtonDisabled.value = false
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
const messageText = user_message.value.trim()
|
||||
if (!messageText) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
||||
user_message.value = ''
|
||||
|
||||
// 发送成功后主动同步最新一页消息,避免SSE短暂断流时界面停留在旧状态。
|
||||
// await messageViewRef.value?.refreshLatestMessages?.()
|
||||
forceScrollToEnd() // 发送消息后强制滚动到底部
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
sendButtonDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +239,7 @@ defineExpose({
|
||||
|
||||
// 监听消息对话框状态变化
|
||||
watch(messageDialog, newValue => {
|
||||
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
|
||||
if (!newValue && messageViewRef.value?.pauseSSE) {
|
||||
// 对话框关闭时暂停SSE连接
|
||||
messageViewRef.value.pauseSSE()
|
||||
}
|
||||
@@ -350,13 +361,13 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 实时日志弹窗 -->
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="70rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VDialog
|
||||
v-if="loggingDialog"
|
||||
v-model="loggingDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="loggingDialog = false" />
|
||||
<VCardItem>
|
||||
@@ -372,7 +383,7 @@ onMounted(() => {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -12,6 +12,7 @@ const hasNewMessage = ref(false)
|
||||
|
||||
// 通知列表
|
||||
const notificationList = ref<SystemNotification[]>([])
|
||||
const MAX_NOTIFICATIONS = 100
|
||||
|
||||
// 弹窗
|
||||
const appsMenu = ref(false)
|
||||
@@ -31,6 +32,9 @@ function handleMessage(event: MessageEvent) {
|
||||
if (event.data) {
|
||||
const noti: SystemNotification = JSON.parse(event.data)
|
||||
notificationList.value.unshift(noti)
|
||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
||||
notificationList.value.length = MAX_NOTIFICATIONS
|
||||
}
|
||||
hasNewMessage.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const route = useRoute()
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<keep-alive :max="12">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="route.fullPath" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: 'Descending',
|
||||
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
||||
clearCache: 'Clear Cache',
|
||||
sortMode: 'Sort Mode',
|
||||
sortModeHint: 'Drag sorting mode is active',
|
||||
exit: 'Exit',
|
||||
},
|
||||
mediaType: {
|
||||
movie: 'Movie',
|
||||
@@ -318,7 +321,7 @@ export default {
|
||||
system: {
|
||||
title: 'System',
|
||||
description:
|
||||
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
|
||||
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, ZSpace, Jellyfin, Plex, TrimeMedia, Ugreen)',
|
||||
},
|
||||
directory: {
|
||||
title: 'Storage & Directories',
|
||||
@@ -350,7 +353,8 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: 'Notifications',
|
||||
description: 'Notification channels (WeChat, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
|
||||
description:
|
||||
'Notification channels (WeChat Work, WeChat ClawBot, Telegram, Slack, SynologyChat, VoceChat, WebPush), message scope',
|
||||
},
|
||||
about: {
|
||||
title: 'About',
|
||||
@@ -460,7 +464,8 @@ export default {
|
||||
botSecret: 'Bot Secret',
|
||||
botSecretHint: 'WebSocket secret of the WeChat Work AI bot',
|
||||
botChatId: 'Default Target',
|
||||
botChatIdHint: 'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
|
||||
botChatIdHint:
|
||||
'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
|
||||
botChatIdPlaceholder: 'userid or group:chatid',
|
||||
botWsUrl: 'WebSocket URL',
|
||||
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
|
||||
@@ -468,6 +473,60 @@ export default {
|
||||
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: 'WeChat ClawBot',
|
||||
baseUrl: 'iLink Base URL',
|
||||
baseUrlHint: 'iLink service URL for WeChat ClawBot, keep default in most cases',
|
||||
defaultTarget: 'Default Target',
|
||||
defaultTargetHint: 'Optional target userid; leave empty to notify interacted users',
|
||||
defaultTargetPlaceholder: 'userid (optional)',
|
||||
admins: 'Admin Whitelist',
|
||||
adminsHint: 'User IDs allowed to run slash commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
pollTimeout: 'Poll Timeout (seconds)',
|
||||
pollTimeoutHint: 'Long polling timeout, recommended 20-30 seconds',
|
||||
loginStatus: 'Login Status',
|
||||
connected: 'Connected',
|
||||
waiting: 'Waiting for QR scan',
|
||||
scanned: 'Scanned, waiting for confirmation',
|
||||
confirmed: 'Confirmed, establishing connection',
|
||||
expired: 'QR code expired',
|
||||
refreshQrcode: 'Refresh QR Code',
|
||||
logout: 'Logout',
|
||||
noQrcode: 'No QR code yet. Refresh or save config first.',
|
||||
scanHint: 'Scan with WeChat to bind. Save and enable this channel before first use.',
|
||||
accountId: 'Account ID',
|
||||
qrcodeUpdatedAt: 'QR Updated At',
|
||||
knownTargets: 'Recent Interacted Users',
|
||||
noKnownTargets: 'No interaction records yet',
|
||||
statusLoadFailed: 'Failed to load WeChat ClawBot status',
|
||||
qrcodeRefreshSuccess: 'WeChat ClawBot QR code refreshed',
|
||||
qrcodeRefreshFailed: 'Failed to refresh WeChat ClawBot QR code',
|
||||
logoutSuccess: 'WeChat ClawBot logged out',
|
||||
logoutFailed: 'Failed to logout WeChat ClawBot',
|
||||
},
|
||||
feishu: {
|
||||
name: 'Feishu',
|
||||
appId: 'App ID',
|
||||
appIdHint: 'App ID of the Feishu Open Platform application',
|
||||
appIdRequired: 'App ID cannot be empty',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: 'App Secret of the Feishu Open Platform application',
|
||||
appSecretRequired: 'App Secret cannot be empty',
|
||||
openId: 'Default User Open ID',
|
||||
openIdHint: 'Default recipient user Open ID; leave empty to prefer recent interacted users',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: 'Default Group Chat ID',
|
||||
chatIdHint: 'Default recipient group chat ID; either this or Open ID is enough',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: 'Admin Whitelist',
|
||||
adminsHint: 'Open IDs allowed to run commands and admin actions, separated by commas',
|
||||
adminsPlaceholder: 'Open ID list, separated by commas',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: 'Verification Token for Feishu event subscription, required when validation is enabled',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: 'Encrypt Key for Feishu event subscription, required when encryption is enabled',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -883,6 +942,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: 'ZSpace',
|
||||
appLaunchFailed: 'App launch failed, redirecting to web version',
|
||||
appNotInstalled: 'App not detected, redirecting to web version',
|
||||
downloadApp: 'Download App',
|
||||
@@ -997,6 +1057,7 @@ export default {
|
||||
aiRecommend: 'AI Recommendation',
|
||||
reRecommend: 'Regenerate Recommendation',
|
||||
aiRecommendError: 'AI Recommendation Failed',
|
||||
refreshSearch: 'Re-search',
|
||||
},
|
||||
browse: {
|
||||
actor: 'Actor',
|
||||
@@ -1232,6 +1293,17 @@ export default {
|
||||
content: 'Content',
|
||||
refreshing: 'Refreshing',
|
||||
initializing: 'Initializing',
|
||||
searchPlaceholder: 'Search logs',
|
||||
allLevels: 'All Levels',
|
||||
followTail: 'Follow latest logs',
|
||||
wrapLines: 'Wrap lines',
|
||||
pauseStream: 'Pause stream',
|
||||
resumeStream: 'Resume stream',
|
||||
waitingForLogs: 'Waiting for logs...',
|
||||
paused: 'Paused',
|
||||
connected: 'Live',
|
||||
lineCount: 'Showing {visible}/{total} lines',
|
||||
jumpToLatest: 'Jump to latest ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: 'Normal',
|
||||
@@ -1322,10 +1394,31 @@ export default {
|
||||
aiAgent: 'Enable AI Assistant',
|
||||
aiAgentEnable: 'Enable AI Assistant',
|
||||
aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',
|
||||
aiAgentSectionTitle: 'AI Assistant Configuration',
|
||||
aiAgentSectionDesc:
|
||||
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
|
||||
llmProvider: 'LLM Provider',
|
||||
llmProviderHint: 'Select the LLM service provider to use',
|
||||
llmModel: 'LLM Model Name',
|
||||
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
|
||||
llmModelHint: 'Specify the LLM model to use, such as deepseek-v4-flash, gpt-5.4, etc.',
|
||||
llmModelResolvedHint: 'Max context has been auto-filled to {context}K from the model catalog. Source: {source}',
|
||||
llmThinking: 'Thinking Mode / Depth',
|
||||
llmThinkingHint:
|
||||
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
|
||||
llmThinkingLevelOff: 'Off (off)',
|
||||
llmThinkingLevelAuto: 'Auto (auto)',
|
||||
llmThinkingLevelMinimal: 'Minimal (minimal)',
|
||||
llmThinkingLevelLow: 'Low (low)',
|
||||
llmThinkingLevelMedium: 'Medium (medium)',
|
||||
llmThinkingLevelHigh: 'High (high)',
|
||||
llmThinkingLevelMax: 'Max (max)',
|
||||
llmThinkingLevelXhigh: 'XHigh (xhigh)',
|
||||
llmSupportImageInput: 'Model Supports Image Input',
|
||||
llmSupportImageInputHint:
|
||||
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
|
||||
llmSupportAudioInputOutput: 'Support Audio Input and Output',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
|
||||
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
|
||||
@@ -1334,6 +1427,35 @@ export default {
|
||||
llmApiKeyPlaceholder: 'Please enter API key',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||
llmProviderAuth: 'Provider Authorization',
|
||||
llmProviderAuthHint:
|
||||
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
|
||||
llmProviderConnectedAs: 'Connected as: {label}',
|
||||
llmProviderDisconnect: 'Disconnect Authorization',
|
||||
llmProviderDisconnected: 'Provider authorization disconnected',
|
||||
llmProviderAuthDialogTitle: 'Provider Authorization',
|
||||
llmProviderPopupBlocked:
|
||||
'The browser blocked the authorization popup. Use the button below to continue manually.',
|
||||
llmProviderDeviceCode: 'Device Code',
|
||||
llmProviderOpenAuthPage: 'Open Authorization Page',
|
||||
llmProviderCheckAuthStatus: 'Check Authorization Status',
|
||||
aiVoiceApiKey: 'Audio API Key',
|
||||
aiVoiceApiKeyHint:
|
||||
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
|
||||
aiVoiceBaseUrl: 'Audio Base URL',
|
||||
aiVoiceBaseUrlHint:
|
||||
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
|
||||
aiVoiceSttModel: 'Audio Transcription Model',
|
||||
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
|
||||
aiVoiceTtsModel: 'Speech Synthesis Model',
|
||||
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
|
||||
aiVoiceTtsVoice: 'Voice Preset',
|
||||
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
|
||||
aiVoiceLanguage: 'Recognition Language',
|
||||
aiVoiceLanguageHint:
|
||||
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
|
||||
aiVoiceReplyWithText: 'Include Text with Voice Replies',
|
||||
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
|
||||
llmTestAction: 'Test Call',
|
||||
llmTestSuccessToast: 'LLM test call succeeded',
|
||||
llmTestFailedToast: 'LLM test call failed',
|
||||
@@ -1376,6 +1498,7 @@ export default {
|
||||
media: 'Media',
|
||||
network: 'Network',
|
||||
log: 'Log',
|
||||
data: 'Data',
|
||||
lab: 'Lab',
|
||||
downloaderSaveSuccess: 'Downloader settings saved successfully',
|
||||
downloaderSaveFailed: 'Failed to save downloader settings!',
|
||||
@@ -1393,6 +1516,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: 'ZSpace',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: 'Ugreen',
|
||||
@@ -1438,8 +1562,12 @@ export default {
|
||||
fanartEnableHint: 'Use image data from fanart.tv',
|
||||
fanartLang: 'Fanart Language',
|
||||
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
|
||||
recognizePluginFirst: "Prioritize Plugin Recognition",
|
||||
recognizePluginFirstHint: "Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped",
|
||||
recognizePluginFirst: 'Prioritize Plugin Recognition',
|
||||
recognizePluginFirstHint:
|
||||
'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped',
|
||||
mediaRecognizeShare: 'Use Shared Media Recognition',
|
||||
mediaRecognizeShareHint:
|
||||
'Report successful keyword to media ID mappings and reuse shared recognition results when local recognition fails',
|
||||
githubProxy: 'Github Acceleration Proxy',
|
||||
githubProxyPlaceholder: 'Leave empty for no proxy',
|
||||
githubProxyHint: 'Use proxy to accelerate Github access speed',
|
||||
@@ -1469,6 +1597,21 @@ export default {
|
||||
logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',
|
||||
logFileFormat: 'Log File Format',
|
||||
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
|
||||
dataCleanupEnable: 'Enable Data Cleanup',
|
||||
dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped',
|
||||
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
|
||||
dataCleanupDaysMin: 'Cleanup retention period must be greater than or equal to 0',
|
||||
dataCleanupMessageDays: 'Message Retention Days',
|
||||
dataCleanupMessageDaysHint: 'Unit: days. Set to 0 to skip cleanup for the message table',
|
||||
dataCleanupDownloadHistoryDays: 'Download History Retention Days',
|
||||
dataCleanupDownloadHistoryDaysHint:
|
||||
'Unit: days. Set to 0 to skip cleanup for download history and its related orphaned download file records',
|
||||
dataCleanupSiteUserDataDays: 'Site User Data Retention Days',
|
||||
dataCleanupSiteUserDataDaysHint: 'Unit: days. Set to 0 to skip cleanup for the site user data table',
|
||||
dataCleanupTransferHistoryDays: 'Transfer History Retention Days',
|
||||
dataCleanupTransferHistoryDaysHint: 'Unit: days. Set to 0 to skip cleanup for the transfer history table',
|
||||
downloadFilesCleanupNotice:
|
||||
'The download files table has no independent timestamp field. Its orphan record cleanup follows the retention period of download history.',
|
||||
pluginAutoReload: 'Plugin Hot Reload',
|
||||
pluginAutoReloadHint: 'Automatically reload after modifying plugin files, used when developing plugins',
|
||||
pluginLocalRepoPaths: 'Local Plugin Repository Paths',
|
||||
@@ -1515,6 +1658,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: 'hour',
|
||||
day: 'day',
|
||||
customizeWallpaperApi: 'Customize Wallpaper Api',
|
||||
customizeWallpaperApiHint:
|
||||
'It will get the image file extension format images that are allowed in settings in the content returned by the API.',
|
||||
@@ -1561,7 +1705,7 @@ export default {
|
||||
skipDesc: 'Skip scraping, this file will not be generated',
|
||||
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
|
||||
overwriteDesc: 'Always scrape, existing file will be overwritten',
|
||||
}
|
||||
},
|
||||
},
|
||||
site: {
|
||||
siteSync: 'Site Synchronization',
|
||||
@@ -1645,7 +1789,9 @@ export default {
|
||||
timeSaveSuccess: 'Notification send time saved successfully',
|
||||
timeSaveFailed: 'Failed to save notification send time!',
|
||||
channel: 'Notification',
|
||||
wechat: 'WeChat',
|
||||
wechat: 'WeChat Work',
|
||||
wechatClawBot: 'WeChat ClawBot',
|
||||
feishu: 'Feishu',
|
||||
resourceDownload: 'Resource Download',
|
||||
mediaImport: 'Media Import',
|
||||
subscription: 'Subscription',
|
||||
@@ -1729,7 +1875,7 @@ export default {
|
||||
animeCategory: 'Anime',
|
||||
downloadUser: 'Remote Search Auto Download User List',
|
||||
downloadUserHint:
|
||||
'Whether to automatically download when searching with Telegram, WeChat, etc., comma separated, set to all to represent all users auto-download',
|
||||
'Whether to auto-download when searching with Telegram, WeChat Work, etc., comma separated, set to all for all users',
|
||||
multipleNameSearch: 'Multiple Name Resource Search',
|
||||
multipleNameSearchHint:
|
||||
'Search site resources using multiple names (Chinese, English, etc.) and merge search results, will increase site access frequency',
|
||||
@@ -1974,7 +2120,8 @@ export default {
|
||||
resetDefaultAvatar: 'Reset Default Avatar',
|
||||
restoreCurrentAvatar: 'Restore Current Avatar',
|
||||
notifications: 'Notifications',
|
||||
wechat: 'WeChat UserID',
|
||||
wechat: 'WeChat Work UserID',
|
||||
wechatClawBot: 'WeChat ClawBot UserID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
@@ -2370,6 +2517,31 @@ export default {
|
||||
scrapeHint: 'Automatically scrape metadata after organization',
|
||||
fromHistoryOption: 'Reuse Historical Recognition Info',
|
||||
fromHistoryHint: 'Use media info already recognized in historical organization records',
|
||||
previewTitle: 'Preview Result',
|
||||
previewSubtitle: 'Click "Preview" to inspect the expected organization result without changing files.',
|
||||
previewResult: 'Preview',
|
||||
previewLoading: 'Generating preview result...',
|
||||
previewRequestFailed: 'Preview request failed',
|
||||
previewTotal: 'Total {count}',
|
||||
previewSuccess: 'Success {count}',
|
||||
previewFailed: 'Failed {count}',
|
||||
previewSourcePath: 'Source Path',
|
||||
previewTargetPath: 'Target Path',
|
||||
previewMediaInfo: 'Media',
|
||||
previewMediaName: 'Name',
|
||||
previewMediaType: 'Type',
|
||||
previewSeasonInfo: 'Season',
|
||||
previewSeasonLabel: 'Season',
|
||||
previewEpisodeCount: 'Episodes',
|
||||
previewAfterColumn: 'After',
|
||||
previewBeforeColumn: 'Before',
|
||||
previewFileNameColumn: 'Filename',
|
||||
previewEmptyTitle: 'No preview yet',
|
||||
previewEmptyDescription: 'Click "Preview" to inspect the organization result here.',
|
||||
noPreviewData: 'No preview data',
|
||||
noFailedPreviewData: 'No failed items',
|
||||
copySuccess: 'Path copied',
|
||||
copyFailed: 'Copy failed',
|
||||
addToQueue: 'Add to Organization Queue',
|
||||
reorganizeNow: 'Organize Now',
|
||||
auto: 'Auto',
|
||||
@@ -2700,7 +2872,9 @@ export default {
|
||||
nickname: 'Nickname',
|
||||
nicknamePlaceholder: 'Display nickname, takes precedence over username',
|
||||
accountBinding: 'Account Binding',
|
||||
wechatUser: 'WeChat User',
|
||||
wechatUser: 'WeChat Work User',
|
||||
wechatClawBotUser: 'WeChat ClawBot User',
|
||||
feishuUser: 'Feishu User',
|
||||
telegramUser: 'Telegram User',
|
||||
slackUser: 'Slack User',
|
||||
discordUser: 'Discord User',
|
||||
@@ -2805,6 +2979,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: 'Assistant Organize',
|
||||
aiRedoPending: 'Assistant Organizing...',
|
||||
batchAiRedo: 'Assistant Batch Organize',
|
||||
redo: 'Reorganize',
|
||||
delete: 'Delete',
|
||||
batchRedo: 'Batch Reorganize',
|
||||
@@ -2868,8 +3043,10 @@ export default {
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
|
||||
default: 'Default',
|
||||
host: 'Host',
|
||||
apiKey: 'API Key',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
|
||||
category: 'Auto Category Management',
|
||||
sequentail: 'Sequential Download',
|
||||
force_resume: 'Force Resume',
|
||||
@@ -3228,7 +3405,8 @@ export default {
|
||||
infoDesc:
|
||||
'Completing site authentication unlocks site capabilities and some plugin permissions. This step is optional and can also be configured later from the user menu.',
|
||||
selectSiteHint: 'Choose a supported auth site and fill in the required credentials for that site',
|
||||
submitHint: 'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
|
||||
submitHint:
|
||||
'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
|
||||
siteConfigNotExist: 'Authentication site configuration does not exist',
|
||||
fieldRequired: 'Please enter {name}',
|
||||
},
|
||||
@@ -3265,12 +3443,13 @@ export default {
|
||||
description: 'Configure media server',
|
||||
info: 'Media Server Configuration',
|
||||
infoDesc:
|
||||
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
|
||||
'Configure media server for media library management, can choose Emby, ZSpace, Jellyfin, Plex, TrimeMedia or Ugreen.',
|
||||
type: 'Media Server Type',
|
||||
typeHint: 'Select the type of media server to use',
|
||||
name: 'Server Name',
|
||||
nameHint: 'Set a name for the media server',
|
||||
embyConfig: 'Emby Configuration',
|
||||
zspaceConfig: 'ZSpace Configuration',
|
||||
jellyfinConfig: 'Jellyfin Configuration',
|
||||
plexConfig: 'Plex Configuration',
|
||||
host: 'Server Address',
|
||||
@@ -3286,6 +3465,7 @@ export default {
|
||||
typeHint: 'Select the type of notification channel to use',
|
||||
name: 'Notification Name',
|
||||
nameHint: 'Set a name for the notification channel',
|
||||
feishuConfig: 'Feishu Configuration',
|
||||
telegramConfig: 'Telegram Configuration',
|
||||
emailConfig: 'Email Configuration',
|
||||
botToken: 'Bot Token',
|
||||
@@ -3304,6 +3484,7 @@ export default {
|
||||
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
|
||||
providerRequired: 'LLM provider is required',
|
||||
apiKeyRequired: 'LLM API key is required',
|
||||
authOrApiKeyRequired: 'Provide an LLM API key or complete provider authorization first',
|
||||
modelRequired: 'LLM model name is required',
|
||||
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
|
||||
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已进入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -316,7 +319,7 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系统',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、极影视、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
},
|
||||
directory: {
|
||||
title: '存储 & 目录',
|
||||
@@ -348,7 +351,7 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
description: '通知渠道(企业微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息发送范围',
|
||||
},
|
||||
about: {
|
||||
title: '关于',
|
||||
@@ -465,6 +468,60 @@ export default {
|
||||
adminsHint: '可使用管理菜单及命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: '微信 ClawBot',
|
||||
baseUrl: 'iLink 地址',
|
||||
baseUrlHint: '微信 ClawBot iLink 服务地址,通常使用默认值',
|
||||
defaultTarget: '默认通知目标',
|
||||
defaultTargetHint: '可填写用户 userid;不填则默认发给已互动用户',
|
||||
defaultTargetPlaceholder: '用户 userid(可选)',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '允许执行斜杠命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
pollTimeout: '轮询超时(秒)',
|
||||
pollTimeoutHint: '长轮询请求超时时间,建议 20-30 秒',
|
||||
loginStatus: '登录状态',
|
||||
connected: '已连接',
|
||||
waiting: '等待扫码',
|
||||
scanned: '已扫码,待确认',
|
||||
confirmed: '已确认,正在建立连接',
|
||||
expired: '二维码已过期',
|
||||
refreshQrcode: '刷新二维码',
|
||||
logout: '退出登录',
|
||||
noQrcode: '暂无二维码,请先刷新或保存配置后重试',
|
||||
scanHint: '使用微信扫码绑定后,状态会自动刷新。首次使用请先保存并启用该通知渠道。',
|
||||
accountId: '账号ID',
|
||||
qrcodeUpdatedAt: '二维码更新时间',
|
||||
knownTargets: '最近互动用户',
|
||||
noKnownTargets: '暂无互动用户记录',
|
||||
statusLoadFailed: '获取微信 ClawBot 状态失败',
|
||||
qrcodeRefreshSuccess: '微信 ClawBot 二维码已刷新',
|
||||
qrcodeRefreshFailed: '刷新微信 ClawBot 二维码失败',
|
||||
logoutSuccess: '微信 ClawBot 已退出登录',
|
||||
logoutFailed: '微信 ClawBot 退出登录失败',
|
||||
},
|
||||
feishu: {
|
||||
name: '飞书',
|
||||
appId: 'App ID',
|
||||
appIdHint: '飞书开放平台应用的 App ID',
|
||||
appIdRequired: 'App ID 不能为空',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: '飞书开放平台应用的 App Secret',
|
||||
appSecretRequired: 'App Secret 不能为空',
|
||||
openId: '默认用户 Open ID',
|
||||
openIdHint: '默认通知接收用户的 Open ID,留空则优先使用互动用户',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: '默认群聊 Chat ID',
|
||||
chatIdHint: '默认通知接收群聊的 Chat ID,和 Open ID 二选一即可',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '允许执行命令和管理操作的 Open ID 列表,多个使用 , 分隔',
|
||||
adminsPlaceholder: 'Open ID 列表,多个使用 , 分隔',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: '飞书事件订阅的 Verification Token,启用事件校验时填写',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: '飞书事件订阅的 Encrypt Key,启用消息加密时填写',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -880,6 +937,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: '极影视',
|
||||
appLaunchFailed: 'APP启动失败,正在跳转到网页版',
|
||||
appNotInstalled: '未检测到APP,正在跳转到网页版',
|
||||
downloadApp: '下载APP',
|
||||
@@ -993,6 +1051,7 @@ export default {
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
refreshSearch: '重新搜索',
|
||||
},
|
||||
browse: {
|
||||
actor: '演员',
|
||||
@@ -1228,6 +1287,17 @@ export default {
|
||||
content: '内容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日志内容',
|
||||
allLevels: '全部级别',
|
||||
followTail: '跟随最新日志',
|
||||
wrapLines: '自动换行',
|
||||
pauseStream: '暂停日志流',
|
||||
resumeStream: '恢复日志流',
|
||||
waitingForLogs: '等待日志输出...',
|
||||
paused: '已暂停',
|
||||
connected: '实时更新中',
|
||||
lineCount: '显示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1317,13 +1387,30 @@ export default {
|
||||
aiAgent: '启用智能助手',
|
||||
aiAgentEnable: '启用智能助手',
|
||||
aiAgentEnableHint: '启用后可使用智能助手功能,需要配置LLM相关参数',
|
||||
aiAgentSectionTitle: '智能助手配置',
|
||||
aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
llmProvider: 'LLM提供商',
|
||||
llmProviderHint: '选择使用的LLM服务提供商',
|
||||
llmModel: 'LLM模型名称',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmModelHint: '指定使用的LLM模型,如deepseek-v4-flash、gpt-5.4等',
|
||||
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K,来源:{source}',
|
||||
llmThinking: '思考模式 / 深度',
|
||||
llmThinkingHint:
|
||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支持的级别会按 provider 能力自动映射到最近值',
|
||||
llmThinkingLevelOff: '关闭 (off)',
|
||||
llmThinkingLevelAuto: '自动 (auto)',
|
||||
llmThinkingLevelMinimal: '最小 (minimal)',
|
||||
llmThinkingLevelLow: '低 (low)',
|
||||
llmThinkingLevelMedium: '中 (medium)',
|
||||
llmThinkingLevelHigh: '高 (high)',
|
||||
llmThinkingLevelMax: '极高 (max)',
|
||||
llmThinkingLevelXhigh: '超高 (xhigh)',
|
||||
llmSupportImageInput: '模型支持图片输入',
|
||||
llmSupportImageInputHint:
|
||||
'启用后,消息中的图片会按多模态图片发送给 LLM;关闭后图片会作为附件保存到本地,并将文件路径提供给智能助手处理',
|
||||
llmSupportAudioInputOutput: '支持音频输入输出',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -1332,6 +1419,30 @@ export default {
|
||||
llmApiKeyPlaceholder: '请输入API密钥',
|
||||
llmBaseUrl: 'LLM基础URL',
|
||||
llmBaseUrlHint: 'LLM API的基础URL地址,用于自定义API端点',
|
||||
llmProviderAuth: '提供商授权',
|
||||
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
|
||||
llmProviderConnectedAs: '当前已连接:{label}',
|
||||
llmProviderDisconnect: '断开授权',
|
||||
llmProviderDisconnected: '已断开提供商授权',
|
||||
llmProviderAuthDialogTitle: '提供商授权',
|
||||
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
|
||||
llmProviderDeviceCode: '设备码',
|
||||
llmProviderOpenAuthPage: '打开授权页面',
|
||||
llmProviderCheckAuthStatus: '检查授权状态',
|
||||
aiVoiceApiKey: '音频 API密钥',
|
||||
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
|
||||
aiVoiceBaseUrl: '音频基础URL',
|
||||
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL,留空时回退到当前 LLM 基础 URL',
|
||||
aiVoiceSttModel: '音频转写模型',
|
||||
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
aiVoiceTtsModel: '语音合成模型',
|
||||
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
aiVoiceTtsVoice: '语音音色',
|
||||
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
|
||||
aiVoiceLanguage: '识别语言',
|
||||
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
aiVoiceReplyWithText: '语音回复附带文字',
|
||||
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
|
||||
llmTestAction: '测试调用',
|
||||
llmTestSuccessToast: 'LLM 调用测试成功',
|
||||
llmTestFailedToast: 'LLM 调用测试失败',
|
||||
@@ -1372,6 +1483,7 @@ export default {
|
||||
media: '媒体',
|
||||
network: '网络',
|
||||
log: '日志',
|
||||
data: '数据',
|
||||
lab: '实验室',
|
||||
downloaderSaveSuccess: '下载器设置保存成功',
|
||||
downloaderSaveFailed: '下载器设置保存失败!',
|
||||
@@ -1389,6 +1501,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: '极影视',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '绿联影视',
|
||||
@@ -1433,6 +1546,8 @@ export default {
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
recognizePluginFirst: "优先使用插件识别",
|
||||
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
|
||||
mediaRecognizeShare: '共享使用媒体识别数据',
|
||||
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID,识别失败时优先回查共享识别结果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github访问速度',
|
||||
@@ -1461,6 +1576,19 @@ export default {
|
||||
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
|
||||
logFileFormat: '日志文件格式',
|
||||
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
|
||||
dataCleanupEnable: '启用数据清理',
|
||||
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
|
||||
dataCleanupDaysRequired: '请输入清理周期',
|
||||
dataCleanupDaysMin: '清理周期必须大于等于0',
|
||||
dataCleanupMessageDays: '消息表保留天数',
|
||||
dataCleanupMessageDaysHint: '单位:天,0 表示不清理消息表数据',
|
||||
dataCleanupDownloadHistoryDays: '下载历史表保留天数',
|
||||
dataCleanupDownloadHistoryDaysHint: '单位:天,0 表示不清理下载历史及其关联的下载文件孤儿记录',
|
||||
dataCleanupSiteUserDataDays: '站点数据表保留天数',
|
||||
dataCleanupSiteUserDataDaysHint: '单位:天,0 表示不清理站点用户数据表',
|
||||
dataCleanupTransferHistoryDays: '整理历史表保留天数',
|
||||
dataCleanupTransferHistoryDaysHint: '单位:天,0 表示不清理整理历史表',
|
||||
downloadFilesCleanupNotice: '下载文件表没有独立时间字段,会跟随下载历史表的保留周期清理其孤儿记录。',
|
||||
pluginAutoReload: '插件热加载',
|
||||
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
|
||||
pluginLocalRepoPaths: '本地插件仓库路径',
|
||||
@@ -1504,6 +1632,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小时',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定义壁纸API地址',
|
||||
customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片,需要同步设置安全域名地址',
|
||||
customizeWallpaperApiRequired: '必填项;请输入自定义壁纸API',
|
||||
@@ -1630,7 +1759,9 @@ export default {
|
||||
timeSaveSuccess: '通知发送时间保存成功',
|
||||
timeSaveFailed: '通知发送时间保存失败!',
|
||||
channel: '通知',
|
||||
wechat: '微信',
|
||||
wechat: '企业微信',
|
||||
wechatClawBot: '微信 ClawBot',
|
||||
feishu: '飞书',
|
||||
resourceDownload: '资源下载',
|
||||
mediaImport: '整理入库',
|
||||
subscription: '订阅',
|
||||
@@ -1708,7 +1839,7 @@ export default {
|
||||
tvCategory: '电视剧',
|
||||
animeCategory: '动漫',
|
||||
downloadUser: '远程搜索自动下载用户',
|
||||
downloadUserHint: '使用Telegram、微信等搜索时是否自动下载,使用逗号分割,设置为 all 代表所有用户自动择优下载',
|
||||
downloadUserHint: '使用Telegram、企业微信等搜索时是否自动下载,使用逗号分割,设置为 all 代表所有用户自动择优下载',
|
||||
multipleNameSearch: '多名称资源搜索',
|
||||
multipleNameSearchHint: '使用多个名称(中文、英文等)搜索站点资源并合并搜索结果,会增加站点访问频率',
|
||||
downloadSubtitle: '下载站点字幕',
|
||||
@@ -1948,7 +2079,8 @@ export default {
|
||||
resetDefaultAvatar: '重置默认头像',
|
||||
restoreCurrentAvatar: '还原当前头像',
|
||||
notifications: '通知',
|
||||
wechat: '微信ID',
|
||||
wechat: '企业微信ID',
|
||||
wechatClawBot: '微信 ClawBot ID',
|
||||
telegram: 'Telegram ID',
|
||||
slack: 'Slack ID',
|
||||
discord: 'Discord ID',
|
||||
@@ -2340,6 +2472,31 @@ export default {
|
||||
scrapeHint: '整理完成后自动刮削元数据',
|
||||
fromHistoryOption: '复用历史识别信息',
|
||||
fromHistoryHint: '使用历史整理记录中已识别的媒体信息',
|
||||
previewTitle: '整理结果预览',
|
||||
previewSubtitle: '点击“预览”后可查看本次整理的预计入库结果,不会实际改动文件',
|
||||
previewResult: '预览',
|
||||
previewLoading: '正在生成预览结果...',
|
||||
previewRequestFailed: '预览请求失败',
|
||||
previewTotal: '总数 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失败 {count}',
|
||||
previewSourcePath: '原始路径',
|
||||
previewTargetPath: '目的路径',
|
||||
previewMediaInfo: '媒体信息',
|
||||
previewMediaName: '名称',
|
||||
previewMediaType: '类型',
|
||||
previewSeasonInfo: '季信息',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '总集数',
|
||||
previewAfterColumn: '整理后',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
previewEmptyTitle: '尚未生成预览',
|
||||
previewEmptyDescription: '点击“预览”按钮后,在这里查看整理结果预览。',
|
||||
noPreviewData: '暂无预览结果',
|
||||
noFailedPreviewData: '当前没有失败项',
|
||||
copySuccess: '路径已复制',
|
||||
copyFailed: '复制失败',
|
||||
addToQueue: '加入整理队列',
|
||||
reorganizeNow: '立即整理',
|
||||
auto: '自动',
|
||||
@@ -2667,7 +2824,9 @@ export default {
|
||||
nickname: '昵称',
|
||||
nicknamePlaceholder: '显示昵称,优先于用户名显示',
|
||||
accountBinding: '账号绑定',
|
||||
wechatUser: '微信用户',
|
||||
wechatUser: '企业微信用户',
|
||||
wechatClawBotUser: '微信 ClawBot 用户',
|
||||
feishuUser: '飞书用户',
|
||||
telegramUser: 'Telegram用户',
|
||||
slackUser: 'Slack用户',
|
||||
discordUser: 'Discord用户',
|
||||
@@ -2769,6 +2928,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '删除',
|
||||
batchRedo: '批量重新整理',
|
||||
@@ -2832,8 +2992,10 @@ export default {
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
|
||||
default: '默认',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填写后将优先使用 API Key 登录。',
|
||||
category: '自动分类管理',
|
||||
sequentail: '顺序下载',
|
||||
force_resume: '强制继续',
|
||||
@@ -3226,12 +3388,13 @@ export default {
|
||||
title: '媒体服务器',
|
||||
description: '配置媒体服务器',
|
||||
info: '媒体服务器配置说明',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、极影视、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
type: '媒体服务器类型',
|
||||
typeHint: '选择要使用的媒体服务器类型',
|
||||
name: '服务器名称',
|
||||
nameHint: '为媒体服务器设置一个名称',
|
||||
embyConfig: 'Emby 配置',
|
||||
zspaceConfig: '极影视 配置',
|
||||
jellyfinConfig: 'Jellyfin 配置',
|
||||
plexConfig: 'Plex 配置',
|
||||
host: '服务器地址',
|
||||
@@ -3247,6 +3410,7 @@ export default {
|
||||
typeHint: '选择要使用的通知渠道类型',
|
||||
name: '通知名称',
|
||||
nameHint: '为通知渠道设置一个名称',
|
||||
feishuConfig: '飞书配置',
|
||||
telegramConfig: 'Telegram 配置',
|
||||
emailConfig: '邮件配置',
|
||||
botToken: '机器人令牌',
|
||||
@@ -3264,6 +3428,7 @@ export default {
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||
clearCache: '清除快取',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已進入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -317,7 +320,7 @@ export default {
|
||||
system: {
|
||||
title: '系統',
|
||||
description:
|
||||
'基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、Jellyfin、Plex、飛牛影視、綠聯影視)',
|
||||
'基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、極影視、Jellyfin、Plex、飛牛影視、綠聯影視)',
|
||||
},
|
||||
directory: {
|
||||
title: '存儲 & 目錄',
|
||||
@@ -349,7 +352,7 @@ export default {
|
||||
},
|
||||
notification: {
|
||||
title: '通知',
|
||||
description: '通知渠道(微信、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息發送範圍',
|
||||
description: '通知渠道(企業微信、微信 ClawBot、Telegram、Slack、SynologyChat、VoceChat、WebPush)、消息發送範圍',
|
||||
},
|
||||
about: {
|
||||
title: '關於',
|
||||
@@ -466,6 +469,60 @@ export default {
|
||||
adminsHint: '可使用管理菜單及命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
},
|
||||
wechatclawbot: {
|
||||
name: '微信 ClawBot',
|
||||
baseUrl: 'iLink 地址',
|
||||
baseUrlHint: '微信 ClawBot iLink 服務地址,通常使用預設值',
|
||||
defaultTarget: '預設通知目標',
|
||||
defaultTargetHint: '可填寫使用者 userid;不填則預設發給已互動使用者',
|
||||
defaultTargetPlaceholder: '使用者 userid(可選)',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '允許執行斜線命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
pollTimeout: '輪詢超時(秒)',
|
||||
pollTimeoutHint: '長輪詢請求超時時間,建議 20-30 秒',
|
||||
loginStatus: '登入狀態',
|
||||
connected: '已連線',
|
||||
waiting: '等待掃碼',
|
||||
scanned: '已掃碼,待確認',
|
||||
confirmed: '已確認,正在建立連線',
|
||||
expired: '二維碼已過期',
|
||||
refreshQrcode: '刷新二維碼',
|
||||
logout: '退出登入',
|
||||
noQrcode: '暫無二維碼,請先刷新或保存配置後再試',
|
||||
scanHint: '使用微信掃碼綁定後,狀態會自動刷新。首次使用請先保存並啟用該通知渠道。',
|
||||
accountId: '帳號ID',
|
||||
qrcodeUpdatedAt: '二維碼更新時間',
|
||||
knownTargets: '最近互動用戶',
|
||||
noKnownTargets: '暫無互動用戶記錄',
|
||||
statusLoadFailed: '獲取微信 ClawBot 狀態失敗',
|
||||
qrcodeRefreshSuccess: '微信 ClawBot 二維碼已刷新',
|
||||
qrcodeRefreshFailed: '刷新微信 ClawBot 二維碼失敗',
|
||||
logoutSuccess: '微信 ClawBot 已退出登入',
|
||||
logoutFailed: '微信 ClawBot 退出登入失敗',
|
||||
},
|
||||
feishu: {
|
||||
name: '飛書',
|
||||
appId: 'App ID',
|
||||
appIdHint: '飛書開放平台應用的 App ID',
|
||||
appIdRequired: 'App ID 不能為空',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: '飛書開放平台應用的 App Secret',
|
||||
appSecretRequired: 'App Secret 不能為空',
|
||||
openId: '預設用戶 Open ID',
|
||||
openIdHint: '預設通知接收用戶的 Open ID,留空則優先使用互動用戶',
|
||||
openIdPlaceholder: 'ou_xxx',
|
||||
chatId: '預設群聊 Chat ID',
|
||||
chatIdHint: '預設通知接收群聊的 Chat ID,和 Open ID 二選一即可',
|
||||
chatIdPlaceholder: 'oc_xxx',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '允許執行命令與管理操作的 Open ID 列表,多個使用 , 分隔',
|
||||
adminsPlaceholder: 'Open ID 列表,多個使用 , 分隔',
|
||||
verificationToken: 'Verification Token',
|
||||
verificationTokenHint: '飛書事件訂閱的 Verification Token,啟用事件校驗時填寫',
|
||||
encryptKey: 'Encrypt Key',
|
||||
encryptKeyHint: '飛書事件訂閱的 Encrypt Key,啟用消息加密時填寫',
|
||||
},
|
||||
telegram: {
|
||||
name: 'Telegram',
|
||||
token: 'Bot Token',
|
||||
@@ -881,6 +938,7 @@ export default {
|
||||
plex: 'Plex',
|
||||
jellyfin: 'Jellyfin',
|
||||
emby: 'Emby',
|
||||
zspace: '極影視',
|
||||
appLaunchFailed: 'APP啟動失敗,正在跳轉到網頁版',
|
||||
appNotInstalled: '未檢測到APP,正在跳轉到網頁版',
|
||||
downloadApp: '下載APP',
|
||||
@@ -994,6 +1052,7 @@ export default {
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
refreshSearch: '重新搜尋',
|
||||
},
|
||||
browse: {
|
||||
actor: '演員',
|
||||
@@ -1230,6 +1289,17 @@ export default {
|
||||
content: '內容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日誌內容',
|
||||
allLevels: '全部級別',
|
||||
followTail: '跟隨最新日誌',
|
||||
wrapLines: '自動換行',
|
||||
pauseStream: '暫停日誌流',
|
||||
resumeStream: '恢復日誌流',
|
||||
waitingForLogs: '等待日誌輸出...',
|
||||
paused: '已暫停',
|
||||
connected: '實時更新中',
|
||||
lineCount: '顯示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1319,13 +1389,30 @@ export default {
|
||||
aiAgent: '啟用智能助手',
|
||||
aiAgentEnable: '啟用智能助手',
|
||||
aiAgentEnableHint: '啟用後可使用智能助手功能,需要配置LLM相關參數',
|
||||
aiAgentSectionTitle: '智能助手配置',
|
||||
aiAgentSectionDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
llmProvider: 'LLM提供商',
|
||||
llmProviderHint: '選擇使用的LLM服務提供商',
|
||||
llmModel: 'LLM模型名稱',
|
||||
llmModelHint: '指定使用的LLM模型,如gpt-3.5-turbo、deepseek-chat等',
|
||||
llmModelHint: '指定使用的LLM模型,如deepseek-v4-flash、gpt-5.4等',
|
||||
llmModelResolvedHint: '已根據模型目錄自動回填最大上下文為 {context}K,來源:{source}',
|
||||
llmThinking: '思考模式 / 深度',
|
||||
llmThinkingHint:
|
||||
'思考深度:off/auto/minimal/low/medium/high/max/xhigh;不支援的級別會按 provider 能力自動映射到最近值',
|
||||
llmThinkingLevelOff: '關閉 (off)',
|
||||
llmThinkingLevelAuto: '自動 (auto)',
|
||||
llmThinkingLevelMinimal: '最小 (minimal)',
|
||||
llmThinkingLevelLow: '低 (low)',
|
||||
llmThinkingLevelMedium: '中 (medium)',
|
||||
llmThinkingLevelHigh: '高 (high)',
|
||||
llmThinkingLevelMax: '極高 (max)',
|
||||
llmThinkingLevelXhigh: '超高 (xhigh)',
|
||||
llmSupportImageInput: '模型支援圖片輸入',
|
||||
llmSupportImageInputHint:
|
||||
'啟用後,消息中的圖片會按多模態圖片發送給 LLM;關閉後圖片會作為附件保存到本地,並將檔案路徑提供給智能助手處理',
|
||||
llmSupportAudioInputOutput: '支援音頻輸入輸出',
|
||||
llmSupportAudioInputOutputHint:
|
||||
'啟用後,智能助手可以轉寫用戶發送的音頻消息,並在支援的渠道上回覆語音',
|
||||
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
|
||||
llmMaxContextTokensHint:
|
||||
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
|
||||
@@ -1334,6 +1421,30 @@ export default {
|
||||
llmApiKeyPlaceholder: '請輸入API密鑰',
|
||||
llmBaseUrl: 'LLM基礎URL',
|
||||
llmBaseUrlHint: 'LLM API的基礎URL地址,用於自定義API端點',
|
||||
llmProviderAuth: '提供商授權',
|
||||
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
|
||||
llmProviderConnectedAs: '目前已連接:{label}',
|
||||
llmProviderDisconnect: '斷開授權',
|
||||
llmProviderDisconnected: '已斷開提供商授權',
|
||||
llmProviderAuthDialogTitle: '提供商授權',
|
||||
llmProviderPopupBlocked: '瀏覽器攔截了授權視窗,請手動點擊下方按鈕繼續。',
|
||||
llmProviderDeviceCode: '設備碼',
|
||||
llmProviderOpenAuthPage: '開啟授權頁面',
|
||||
llmProviderCheckAuthStatus: '檢查授權狀態',
|
||||
aiVoiceApiKey: '音頻 API密鑰',
|
||||
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
|
||||
aiVoiceBaseUrl: '音頻基礎URL',
|
||||
aiVoiceBaseUrlHint: '音頻轉寫與語音合成接口的基礎URL,留空時回退到當前 LLM 基礎 URL',
|
||||
aiVoiceSttModel: '音頻轉寫模型',
|
||||
aiVoiceSttModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||
aiVoiceTtsModel: '語音合成模型',
|
||||
aiVoiceTtsModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||
aiVoiceTtsVoice: '語音音色',
|
||||
aiVoiceTtsVoiceHint: '語音合成使用的發音人或音色標識',
|
||||
aiVoiceLanguage: '識別語言',
|
||||
aiVoiceLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||
aiVoiceReplyWithText: '語音回覆附帶文字',
|
||||
aiVoiceReplyWithTextHint: '發送語音回覆時,同時附帶一份文字內容',
|
||||
llmTestAction: '測試調用',
|
||||
llmTestSuccessToast: 'LLM 調用測試成功',
|
||||
llmTestFailedToast: 'LLM 調用測試失敗',
|
||||
@@ -1374,6 +1485,7 @@ export default {
|
||||
media: '媒體',
|
||||
network: '網絡',
|
||||
log: '日誌',
|
||||
data: '數據',
|
||||
lab: '實驗室',
|
||||
downloaderSaveSuccess: '下載器設置保存成功',
|
||||
downloaderSaveFailed: '下載器設置保存失敗!',
|
||||
@@ -1391,6 +1503,7 @@ export default {
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
zspace: '極影視',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '綠聯影視',
|
||||
@@ -1435,6 +1548,8 @@ export default {
|
||||
fanartLangHint: '設定Fanart圖片的語言偏好,多選時按優先級順序排列',
|
||||
recognizePluginFirst: '優先使用插件識別',
|
||||
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
|
||||
mediaRecognizeShare: '共享使用媒體識別數據',
|
||||
mediaRecognizeShareHint: '識別成功後上報關鍵字與媒體ID,識別失敗時優先回查共享識別結果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github訪問速度',
|
||||
@@ -1463,6 +1578,19 @@ export default {
|
||||
logBackupCountMin: '日誌文件最大備份數量必須大於等於1',
|
||||
logFileFormat: '日誌文件格式',
|
||||
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
|
||||
dataCleanupEnable: '啟用數據清理',
|
||||
dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務',
|
||||
dataCleanupDaysRequired: '請輸入清理週期',
|
||||
dataCleanupDaysMin: '清理週期必須大於等於0',
|
||||
dataCleanupMessageDays: '消息表保留天數',
|
||||
dataCleanupMessageDaysHint: '單位:天,0 表示不清理消息表數據',
|
||||
dataCleanupDownloadHistoryDays: '下載歷史表保留天數',
|
||||
dataCleanupDownloadHistoryDaysHint: '單位:天,0 表示不清理下載歷史及其關聯的下載文件孤兒記錄',
|
||||
dataCleanupSiteUserDataDays: '站點數據表保留天數',
|
||||
dataCleanupSiteUserDataDaysHint: '單位:天,0 表示不清理站點用戶數據表',
|
||||
dataCleanupTransferHistoryDays: '整理歷史表保留天數',
|
||||
dataCleanupTransferHistoryDaysHint: '單位:天,0 表示不清理整理歷史表',
|
||||
downloadFilesCleanupNotice: '下載文件表沒有獨立時間欄位,會跟隨下載歷史表的保留週期清理其孤兒記錄。',
|
||||
pluginAutoReload: '插件熱加載',
|
||||
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
|
||||
pluginLocalRepoPaths: '本地插件倉庫路徑',
|
||||
@@ -1506,6 +1634,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小時',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定義壁紙API',
|
||||
customizeWallpaperApiHint: '會獲取 API 返回內容中所有安全設置中允許的圖片地址,需要設置安全域名白名單',
|
||||
customizeWallpaperApiRequired: '必填項;請輸出自定義壁紙API',
|
||||
@@ -1632,7 +1761,9 @@ export default {
|
||||
timeSaveSuccess: '通知發送時間保存成功',
|
||||
timeSaveFailed: '通知發送時間保存失敗!',
|
||||
channel: '通知',
|
||||
wechat: '微信',
|
||||
wechat: '企業微信',
|
||||
wechatClawBot: '微信 ClawBot',
|
||||
feishu: '飛書',
|
||||
resourceDownload: '資源下載',
|
||||
mediaImport: '整理入庫',
|
||||
subscription: '訂閱',
|
||||
@@ -1718,7 +1849,7 @@ export default {
|
||||
mediaSourceHint: '搜索媒體信息時使用的數據源以及排序',
|
||||
filterRuleGroupHint: '搜索媒體信息時按選定的過濾規則組對結果進行過濾',
|
||||
downloadUserPlaceholder: '用戶ID1,用戶ID2',
|
||||
downloadUserHint: '使用Telegram、微信等搜索時是否自動下載,使用逗號分割,設置為 all 代表所有用戶自動擇優下載',
|
||||
downloadUserHint: '使用Telegram、企業微信等搜索時是否自動下載,使用逗號分割,設置為 all 代表所有用戶自動擇優下載',
|
||||
downloadLabelPlaceholder: 'MOVIEPILOT',
|
||||
},
|
||||
directory: {
|
||||
@@ -1950,7 +2081,8 @@ export default {
|
||||
resetDefaultAvatar: '重置默認頭像',
|
||||
restoreCurrentAvatar: '還原當前頭像',
|
||||
notifications: '通知',
|
||||
wechat: '微信UserID',
|
||||
wechat: '企業微信 UserID',
|
||||
wechatClawBot: '微信 ClawBot ID',
|
||||
telegram: 'Telegram UserID',
|
||||
slack: 'Slack UserID',
|
||||
discord: 'Discord UserID',
|
||||
@@ -2342,6 +2474,31 @@ export default {
|
||||
scrapeHint: '整理完成後自動刮削元數據',
|
||||
fromHistoryOption: '復用歷史識別資訊',
|
||||
fromHistoryHint: '使用歷史整理記錄中已識別的媒體資訊',
|
||||
previewTitle: '整理結果預覽',
|
||||
previewSubtitle: '點擊「預覽」後可查看本次整理的預計入庫結果,不會實際改動文件',
|
||||
previewResult: '預覽',
|
||||
previewLoading: '正在生成預覽結果...',
|
||||
previewRequestFailed: '預覽請求失敗',
|
||||
previewTotal: '總數 {count}',
|
||||
previewSuccess: '成功 {count}',
|
||||
previewFailed: '失敗 {count}',
|
||||
previewSourcePath: '原始路徑',
|
||||
previewTargetPath: '目的路徑',
|
||||
previewMediaInfo: '媒體資訊',
|
||||
previewMediaName: '名稱',
|
||||
previewMediaType: '類型',
|
||||
previewSeasonInfo: '季資訊',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '總集數',
|
||||
previewAfterColumn: '整理後',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
previewEmptyTitle: '尚未生成預覽',
|
||||
previewEmptyDescription: '點擊「預覽」按鈕後,在這裡查看整理結果預覽。',
|
||||
noPreviewData: '暫無預覽結果',
|
||||
noFailedPreviewData: '目前沒有失敗項',
|
||||
copySuccess: '路徑已複製',
|
||||
copyFailed: '複製失敗',
|
||||
addToQueue: '加入整理隊列',
|
||||
reorganizeNow: '立即整理',
|
||||
auto: '自動',
|
||||
@@ -2669,7 +2826,9 @@ export default {
|
||||
nickname: '暱稱',
|
||||
nicknamePlaceholder: '顯示暱稱,優先於用戶名顯示',
|
||||
accountBinding: '賬號綁定',
|
||||
wechatUser: '微信用戶',
|
||||
wechatUser: '企業微信用戶',
|
||||
wechatClawBotUser: '微信 ClawBot 用戶',
|
||||
feishuUser: '飛書用戶',
|
||||
telegramUser: 'Telegram用戶',
|
||||
slackUser: 'Slack用戶',
|
||||
discordUser: 'Discord用戶',
|
||||
@@ -2771,6 +2930,7 @@ export default {
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '刪除',
|
||||
batchRedo: '批量重新整理',
|
||||
@@ -2834,8 +2994,10 @@ export default {
|
||||
enabled: '啟用',
|
||||
default: '預設',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填寫後將優先使用 API Key 登入。',
|
||||
category: '自動分類管理',
|
||||
sequentail: '順序下載',
|
||||
force_resume: '強制繼續',
|
||||
@@ -3228,12 +3390,13 @@ export default {
|
||||
title: '媒體伺服器',
|
||||
description: '設定媒體伺服器',
|
||||
info: '媒體伺服器設定說明',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、Jellyfin、Plex、飛牛影視或綠聯影視',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、極影視、Jellyfin、Plex、飛牛影視或綠聯影視',
|
||||
type: '媒體伺服器類型',
|
||||
typeHint: '選擇要使用的媒體伺服器類型',
|
||||
name: '伺服器名稱',
|
||||
nameHint: '為媒體伺服器設定一個名稱',
|
||||
embyConfig: 'Emby 設定',
|
||||
zspaceConfig: '極影視 設定',
|
||||
jellyfinConfig: 'Jellyfin 設定',
|
||||
plexConfig: 'Plex 設定',
|
||||
host: '伺服器位址',
|
||||
@@ -3249,6 +3412,7 @@ export default {
|
||||
typeHint: '選擇要使用的通知管道類型',
|
||||
name: '通知名稱',
|
||||
nameHint: '為通知管道設定一個名稱',
|
||||
feishuConfig: '飛書設定',
|
||||
telegramConfig: 'Telegram 設定',
|
||||
emailConfig: '郵件設定',
|
||||
botToken: '機器人權杖',
|
||||
@@ -3266,6 +3430,7 @@ export default {
|
||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
providerRequired: 'LLM 提供商不能為空',
|
||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||
modelRequired: 'LLM 模型名稱不能為空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||
|
||||
68
src/main.ts
68
src/main.ts
@@ -1,11 +1,9 @@
|
||||
// 1. 配置与兼容性
|
||||
import './ace-config'
|
||||
import '@/@core/utils/compatibility'
|
||||
import '@/@iconify/icons-bundle'
|
||||
import '@/plugins/webfontloader'
|
||||
|
||||
// 2. 核心插件和 UI 框架
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, defineAsyncComponent } from 'vue'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import router from '@/router'
|
||||
import pinia from '@/stores/index'
|
||||
@@ -13,9 +11,7 @@ import i18n from '@/plugins/i18n'
|
||||
|
||||
// 3. 全局组件
|
||||
import App from '@/App.vue'
|
||||
import { VAceEditor } from 'vue3-ace-editor'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { CronVuetify } from '@vue-js-cron/vuetify'
|
||||
|
||||
// 4. 工具函数和其他辅助模块
|
||||
import { loadRemoteComponents } from './utils/federationLoader'
|
||||
@@ -23,22 +19,12 @@ import { loadRemoteComponents } from './utils/federationLoader'
|
||||
// 5. 其他插件和功能模块
|
||||
import Toast from 'vue-toastification'
|
||||
import ConfirmDialog from '@/composables/useConfirm'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
|
||||
// 6. 注册自定义组件
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import ScrollToTopBtn from '@/@core/components/ScrollToTopBtn.vue'
|
||||
import PageContentTitle from './@core/components/PageContentTitle.vue'
|
||||
import MediaCard from './components/cards/MediaCard.vue'
|
||||
import PosterCard from './components/cards/PosterCard.vue'
|
||||
import BackdropCard from './components/cards/BackdropCard.vue'
|
||||
import PersonCard from './components/cards/PersonCard.vue'
|
||||
import MediaInfoCard from './components/cards/MediaInfoCard.vue'
|
||||
import TorrentCard from './components/cards/TorrentCard.vue'
|
||||
import MediaIdSelector from './components/misc/MediaIdSelector.vue'
|
||||
import CronField from './components/field/CronField.vue'
|
||||
import PathField from './components/field/PathField.vue'
|
||||
import HeaderTab from './layouts/components/HeaderTab.vue'
|
||||
|
||||
// 7. 样式文件 - 合并为单一导入
|
||||
import '@/styles/main.scss'
|
||||
@@ -50,6 +36,34 @@ import stateRestorePlugin from '@/plugins/stateRestore'
|
||||
import { backgroundManager } from '@/utils/backgroundManager'
|
||||
import { sseManagerSingleton } from '@/utils/sseManager'
|
||||
|
||||
const iconBundlePromise = import('@/@iconify/icons-bundle').catch(error => {
|
||||
console.error('Failed to load icon bundle', error)
|
||||
})
|
||||
|
||||
const AsyncAceEditor = defineAsyncComponent(async () => {
|
||||
await import('./ace-config')
|
||||
return (await import('vue3-ace-editor')).VAceEditor
|
||||
})
|
||||
|
||||
const AsyncApexChart = defineAsyncComponent(async () => {
|
||||
const component = (await import('vue3-apexcharts')).default
|
||||
const themeName = document.documentElement.getAttribute('data-theme') || localStorage.getItem('theme') || 'light'
|
||||
configureApexChartsTheme(themeName)
|
||||
return component
|
||||
})
|
||||
|
||||
const AsyncCronVuetify = defineAsyncComponent(async () => {
|
||||
return (await import('@vue-js-cron/vuetify')).CronVuetify
|
||||
})
|
||||
|
||||
const AsyncCronField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/CronField.vue')).default
|
||||
})
|
||||
|
||||
const AsyncPathField = defineAsyncComponent(async () => {
|
||||
return (await import('./components/field/PathField.vue')).default
|
||||
})
|
||||
|
||||
// 创建Vue实例
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -72,21 +86,13 @@ app.use(stateRestorePlugin)
|
||||
|
||||
// 5. 注册全局组件
|
||||
app
|
||||
.component('VAceEditor', VAceEditor)
|
||||
.component('VApexChart', VueApexCharts)
|
||||
.component('VCronVuetify', CronVuetify)
|
||||
.component('VAceEditor', AsyncAceEditor)
|
||||
.component('VApexChart', AsyncApexChart)
|
||||
.component('VCronVuetify', AsyncCronVuetify)
|
||||
.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('VCronField', AsyncCronField)
|
||||
.component('VPathField', AsyncPathField)
|
||||
.component('VPageContentTitle', PageContentTitle)
|
||||
|
||||
// 6. 注册其他插件
|
||||
@@ -98,7 +104,9 @@ app
|
||||
})
|
||||
.use(ConfirmDialog)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
|
||||
await iconBundlePromise
|
||||
app.mount('#app')
|
||||
|
||||
// 页面卸载时清理后台管理器
|
||||
window.addEventListener('beforeunload', () => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
import type { Context } from '@/api/types'
|
||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores/global'
|
||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
// 国际化
|
||||
@@ -26,27 +27,60 @@ const torrentFilter = useTorrentFilter()
|
||||
|
||||
// 路由参数
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface SearchParams {
|
||||
keyword: string
|
||||
type: string
|
||||
area: string
|
||||
title: string
|
||||
year: string
|
||||
season: string
|
||||
sites: string
|
||||
}
|
||||
|
||||
function createSearchParams(query: LocationQuery): SearchParams {
|
||||
return {
|
||||
keyword: query?.keyword?.toString() ?? '',
|
||||
type: query?.type?.toString() ?? '',
|
||||
area: query?.area?.toString() ?? '',
|
||||
title: query?.title?.toString() ?? '',
|
||||
year: query?.year?.toString() ?? '',
|
||||
season: query?.season?.toString() ?? '',
|
||||
sites: query?.sites?.toString() ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchParamsKey(params: SearchParams): string {
|
||||
return JSON.stringify(params)
|
||||
}
|
||||
|
||||
function createSearchRequestToken(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
const activeSearchParams = ref<SearchParams>(createSearchParams(route.query))
|
||||
|
||||
// 查询TMDBID或标题
|
||||
const keyword = route.query?.keyword?.toString() ?? ''
|
||||
const keyword = computed(() => activeSearchParams.value.keyword)
|
||||
|
||||
// 查询类型
|
||||
const type = route.query?.type?.toString() ?? ''
|
||||
const type = computed(() => activeSearchParams.value.type)
|
||||
|
||||
// 搜索字段
|
||||
const area = route.query?.area?.toString() ?? ''
|
||||
const area = computed(() => activeSearchParams.value.area)
|
||||
|
||||
// 搜索标题
|
||||
const title = route.query?.title?.toString() ?? ''
|
||||
const title = computed(() => activeSearchParams.value.title)
|
||||
|
||||
// 搜索年份
|
||||
const year = route.query?.year
|
||||
const year = computed(() => activeSearchParams.value.year)
|
||||
|
||||
// 搜索季
|
||||
const season = route.query?.season?.toString() ?? ''
|
||||
const season = computed(() => activeSearchParams.value.season)
|
||||
|
||||
// 搜索站点,以,分离多个
|
||||
const sites = route.query?.sites?.toString() ?? ''
|
||||
const sites = computed(() => activeSearchParams.value.sites)
|
||||
|
||||
// 视图类型,从localStorage中读取
|
||||
const viewType = ref<string>(localStorage.getItem('MPTorrentsViewType') ?? 'card')
|
||||
@@ -68,7 +102,7 @@ let aiStatusCheckInterval: ReturnType<typeof setInterval> | null = null // AI状
|
||||
|
||||
// 是否有搜索标签
|
||||
const hasSearchTags = computed(() => {
|
||||
return !!(keyword || title || year || season)
|
||||
return !!(keyword.value || title.value || year.value || season.value)
|
||||
})
|
||||
|
||||
// 是否启用筛选栏动画
|
||||
@@ -86,15 +120,12 @@ interface SearchTorrent extends Context {
|
||||
}
|
||||
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||
|
||||
// 使用无限滚动 composable(行视图)
|
||||
const rowScroll = useInfiniteScroll(filteredRowDataList)
|
||||
|
||||
// 使用无限滚动 composable(卡片视图)
|
||||
const cardScroll = useInfiniteScroll(filteredCardDataList)
|
||||
|
||||
// 是否刷新过
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 是否正在重新搜索
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
// 加载进度文本
|
||||
const progressText = ref(t('common.pleaseWait'))
|
||||
|
||||
@@ -137,13 +168,23 @@ const errorDescription = ref(t('resource.noResourceFound'))
|
||||
let searchEventSource: EventSource | null = null
|
||||
|
||||
const streamPreviewLimit = 24
|
||||
const streamUiFlushDelay = 1000
|
||||
const streamPreviewBufferLimit = streamPreviewLimit * 4
|
||||
|
||||
const streamTotalCount = ref(0)
|
||||
const streamPreviewDataList = ref<Array<Context>>([])
|
||||
|
||||
const displayResourceCount = computed(() =>
|
||||
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
|
||||
)
|
||||
|
||||
let pendingStreamItems: Array<Context> = []
|
||||
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let streamFinalResultApplied = false
|
||||
let pendingProgressText: string | null = null
|
||||
let pendingProgressValue: number | null = null
|
||||
let pendingStreamTotalCount: number | null = null
|
||||
|
||||
// 监听筛选条件变化,重新筛选数据
|
||||
watch(
|
||||
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
|
||||
@@ -228,6 +269,58 @@ function closeSearchEventSource() {
|
||||
}
|
||||
}
|
||||
|
||||
// 渐进式搜索期间只保留有限预览数据,避免每个批次都触发完整筛选和分组计算。
|
||||
function clearStreamFlushTimer() {
|
||||
if (streamFlushTimer) {
|
||||
clearTimeout(streamFlushTimer)
|
||||
streamFlushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearStreamPreviewState(resetFinalState: boolean = false) {
|
||||
clearStreamFlushTimer()
|
||||
pendingStreamItems = []
|
||||
pendingProgressText = null
|
||||
pendingProgressValue = null
|
||||
pendingStreamTotalCount = null
|
||||
streamPreviewDataList.value = []
|
||||
if (resetFinalState) {
|
||||
streamFinalResultApplied = false
|
||||
}
|
||||
}
|
||||
|
||||
// 将进度和预览列表放到同一个节奏刷新,避免 SSE 到来时多处 UI 各自抖动。
|
||||
function flushBufferedStreamState() {
|
||||
clearStreamFlushTimer()
|
||||
|
||||
if (pendingProgressText !== null) {
|
||||
progressText.value = pendingProgressText
|
||||
}
|
||||
if (pendingProgressValue !== null) {
|
||||
progressValue.value = pendingProgressValue
|
||||
}
|
||||
if (pendingStreamTotalCount !== null) {
|
||||
streamTotalCount.value = pendingStreamTotalCount
|
||||
}
|
||||
|
||||
pendingProgressText = null
|
||||
pendingProgressValue = null
|
||||
pendingStreamTotalCount = null
|
||||
|
||||
if (!pendingStreamItems.length) return
|
||||
|
||||
streamPreviewDataList.value = [...pendingStreamItems, ...streamPreviewDataList.value].slice(0, streamPreviewLimit)
|
||||
pendingStreamItems = []
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
function scheduleStreamFlush() {
|
||||
if (streamFlushTimer) return
|
||||
streamFlushTimer = setTimeout(() => {
|
||||
flushBufferedStreamState()
|
||||
}, streamUiFlushDelay)
|
||||
}
|
||||
|
||||
// 获取API URL
|
||||
function getApiUrl(path: string) {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
@@ -246,20 +339,24 @@ function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
|
||||
}
|
||||
|
||||
// 构建搜索流URL
|
||||
function buildSearchStreamUrl() {
|
||||
const isMediaSearch = /^[a-zA-Z]+:/.test(keyword)
|
||||
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(keyword)}/stream` : 'search/title/stream')
|
||||
function buildSearchStreamUrl(params: SearchParams, requestToken?: string) {
|
||||
const isMediaSearch = /^[a-zA-Z]+:/.test(params.keyword)
|
||||
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(params.keyword)}/stream` : 'search/title/stream')
|
||||
|
||||
if (isMediaSearch) {
|
||||
setSearchParam(url.searchParams, 'mtype', type)
|
||||
setSearchParam(url.searchParams, 'area', area)
|
||||
setSearchParam(url.searchParams, 'title', title)
|
||||
setSearchParam(url.searchParams, 'year', year)
|
||||
setSearchParam(url.searchParams, 'season', season)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
setSearchParam(url.searchParams, 'mtype', params.type)
|
||||
setSearchParam(url.searchParams, 'area', params.area)
|
||||
setSearchParam(url.searchParams, 'title', params.title)
|
||||
setSearchParam(url.searchParams, 'year', params.year)
|
||||
setSearchParam(url.searchParams, 'season', params.season)
|
||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||
} else {
|
||||
setSearchParam(url.searchParams, 'keyword', keyword)
|
||||
setSearchParam(url.searchParams, 'sites', sites)
|
||||
setSearchParam(url.searchParams, 'keyword', params.keyword)
|
||||
setSearchParam(url.searchParams, 'sites', params.sites)
|
||||
}
|
||||
|
||||
if (requestToken) {
|
||||
setSearchParam(url.searchParams, '_ts', requestToken)
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
@@ -267,6 +364,7 @@ function buildSearchStreamUrl() {
|
||||
|
||||
// 重置搜索结果
|
||||
function resetSearchResults() {
|
||||
clearStreamPreviewState(true)
|
||||
rawDataList.value = []
|
||||
originalDataList.value = []
|
||||
streamTotalCount.value = 0
|
||||
@@ -280,21 +378,28 @@ function resetSearchResults() {
|
||||
}
|
||||
|
||||
// 更新搜索进度
|
||||
function updateSearchProgress(eventData: { [key: string]: any }) {
|
||||
function updateSearchProgress(eventData: { [key: string]: any }, flushNow: boolean = false) {
|
||||
if (eventData.text) {
|
||||
progressText.value = eventData.text
|
||||
pendingProgressText = eventData.text
|
||||
}
|
||||
if (typeof eventData.value === 'number') {
|
||||
progressValue.value = eventData.value
|
||||
pendingProgressValue = eventData.value
|
||||
}
|
||||
if (typeof eventData.total_items === 'number') {
|
||||
streamTotalCount.value = eventData.total_items
|
||||
pendingStreamTotalCount = eventData.total_items
|
||||
}
|
||||
progressEnabled.value = true
|
||||
|
||||
if (flushNow) {
|
||||
flushBufferedStreamState()
|
||||
} else {
|
||||
scheduleStreamFlush()
|
||||
}
|
||||
}
|
||||
|
||||
// 设置流式搜索结果
|
||||
function setStreamResults(items: Context[]) {
|
||||
clearStreamPreviewState()
|
||||
rawDataList.value = items
|
||||
originalDataList.value = items
|
||||
if (!progressActive.value) {
|
||||
@@ -304,12 +409,21 @@ function setStreamResults(items: Context[]) {
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
// 追加流式搜索结果
|
||||
// 追加流式搜索预览结果
|
||||
function appendStreamResults(items: Context[]) {
|
||||
if (!items.length) return
|
||||
|
||||
const nextItems = [...items, ...rawDataList.value]
|
||||
setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems)
|
||||
pendingStreamItems.unshift(...items)
|
||||
if (pendingStreamItems.length > streamPreviewBufferLimit) {
|
||||
pendingStreamItems = pendingStreamItems.slice(0, streamPreviewBufferLimit)
|
||||
}
|
||||
scheduleStreamFlush()
|
||||
}
|
||||
|
||||
function applyFinalStreamResults(items: Context[]) {
|
||||
streamFinalResultApplied = true
|
||||
flushBufferedStreamState()
|
||||
setStreamResults(items)
|
||||
}
|
||||
|
||||
// 获取磁力链接的key
|
||||
@@ -324,42 +438,50 @@ function getTorrentItemKey(item: Context, index: number) {
|
||||
|
||||
// 处理搜索流消息
|
||||
function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
||||
updateSearchProgress(eventData)
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
updateSearchProgress(eventData, true)
|
||||
errorDescription.value = eventData.message || t('resource.noResourceFound')
|
||||
return
|
||||
}
|
||||
|
||||
const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []
|
||||
if (eventData.type === 'append') {
|
||||
updateSearchProgress(eventData)
|
||||
appendStreamResults(items)
|
||||
} else if (eventData.type === 'replace' || eventData.type === 'done') {
|
||||
setStreamResults(items)
|
||||
} else if (eventData.type === 'replace') {
|
||||
updateSearchProgress(eventData, true)
|
||||
applyFinalStreamResults(items)
|
||||
} else if (eventData.type === 'done' && items.length > 0 && !streamFinalResultApplied) {
|
||||
updateSearchProgress(eventData, true)
|
||||
applyFinalStreamResults(items)
|
||||
} else {
|
||||
updateSearchProgress(eventData)
|
||||
}
|
||||
}
|
||||
|
||||
// 按请求搜索
|
||||
async function searchByRequest() {
|
||||
async function searchByRequest(params: SearchParams, requestToken?: string) {
|
||||
let result: { [key: string]: any }
|
||||
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符,则按照媒体ID格式搜索
|
||||
if (/^[a-zA-Z]+:/.test(keyword)) {
|
||||
result = await api.get(`search/media/${keyword}`, {
|
||||
if (/^[a-zA-Z]+:/.test(params.keyword)) {
|
||||
result = await api.get(`search/media/${params.keyword}`, {
|
||||
params: {
|
||||
mtype: type,
|
||||
area,
|
||||
title,
|
||||
year,
|
||||
season,
|
||||
sites,
|
||||
mtype: params.type,
|
||||
area: params.area,
|
||||
title: params.title,
|
||||
year: params.year,
|
||||
season: params.season,
|
||||
sites: params.sites,
|
||||
_ts: requestToken,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// 按标题模糊查询
|
||||
result = await api.get(`search/title`, {
|
||||
params: {
|
||||
keyword,
|
||||
sites,
|
||||
keyword: params.keyword,
|
||||
sites: params.sites,
|
||||
_ts: requestToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -375,12 +497,12 @@ async function searchByRequest() {
|
||||
}
|
||||
|
||||
// 按流搜索
|
||||
function searchByStream() {
|
||||
function searchByStream(params: SearchParams, requestToken?: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
closeSearchEventSource()
|
||||
|
||||
let settled = false
|
||||
const source = new EventSource(buildSearchStreamUrl())
|
||||
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
|
||||
searchEventSource = source
|
||||
|
||||
source.onmessage = event => {
|
||||
@@ -430,29 +552,33 @@ function changeViewType(newType: string) {
|
||||
}
|
||||
|
||||
// 获取搜索列表数据
|
||||
async function fetchData() {
|
||||
async function fetchData(options: { force?: boolean } = {}) {
|
||||
const currentSearchParams = { ...activeSearchParams.value }
|
||||
const requestToken = options.force || Boolean(currentSearchParams.keyword) ? createSearchRequestToken() : undefined
|
||||
|
||||
try {
|
||||
enableFilterAnimation.value = true
|
||||
if (!keyword) {
|
||||
if (!currentSearchParams.keyword) {
|
||||
// 查询上次搜索结果
|
||||
const results = await api.get('search/last')
|
||||
rawDataList.value = (results as unknown as Context[]) || []
|
||||
originalDataList.value = (results as unknown as Context[]) || []
|
||||
const results = await api.get('search/last', {
|
||||
params: requestToken ? { _ts: requestToken } : undefined,
|
||||
})
|
||||
setStreamResults((results as unknown as Context[]) || [])
|
||||
} else {
|
||||
resetSearchResults()
|
||||
startLoadingProgress()
|
||||
try {
|
||||
await searchByStream()
|
||||
await searchByStream(currentSearchParams, requestToken)
|
||||
} catch (error) {
|
||||
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||
await searchByRequest()
|
||||
await searchByRequest(currentSearchParams, requestToken)
|
||||
}
|
||||
stopLoadingProgress()
|
||||
// 从浏览器历史中删除当前搜索
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
// 搜索完成后移除地址栏参数,避免分享/刷新残留搜索条件
|
||||
if (Object.keys(route.query).length > 0) {
|
||||
await router.replace({ path: route.path, query: {} })
|
||||
}
|
||||
}
|
||||
// 应用筛选
|
||||
applyFilter()
|
||||
// 标记已刷新
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
@@ -464,6 +590,21 @@ async function fetchData() {
|
||||
}
|
||||
}
|
||||
|
||||
// 重新搜索(使用相同参数重新触发搜索)
|
||||
async function refreshSearch() {
|
||||
if (isRefreshing.value || progressActive.value) return
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
|
||||
showingAiResults.value = false
|
||||
await fetchData({ force: true })
|
||||
} catch (error) {
|
||||
console.error('重新搜索失败:', error)
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到智能推荐结果(自动保存筛选条件)
|
||||
async function switchToAiResults() {
|
||||
if (showingAiResults.value) {
|
||||
@@ -713,6 +854,10 @@ async function checkAiRecommendStatus() {
|
||||
|
||||
// 计算当前显示的数据是否有数据
|
||||
const hasData = computed(() => {
|
||||
if (progressActive.value) {
|
||||
return streamPreviewDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
}
|
||||
|
||||
if (viewType.value === 'row') {
|
||||
return filteredRowDataList.value.length > 0 || rawDataList.value.length > 0
|
||||
} else {
|
||||
@@ -730,13 +875,27 @@ watchEffect(() => {
|
||||
!progressActive.value &&
|
||||
!aiStatusChecked.value
|
||||
) {
|
||||
checkAiRecommendStatus()
|
||||
void checkAiRecommendStatus()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
query => {
|
||||
if (Object.keys(query).length === 0) return
|
||||
|
||||
const nextSearchParams = createSearchParams(query)
|
||||
if (getSearchParamsKey(nextSearchParams) === getSearchParamsKey(activeSearchParams.value)) return
|
||||
|
||||
activeSearchParams.value = nextSearchParams
|
||||
void fetchData()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 加载数据
|
||||
onMounted(async () => {
|
||||
fetchData()
|
||||
void fetchData()
|
||||
})
|
||||
|
||||
// 卸载时停止轮询
|
||||
@@ -744,6 +903,7 @@ onUnmounted(() => {
|
||||
closeSearchEventSource()
|
||||
stopLoadingProgress()
|
||||
stopAiRecommendPolling()
|
||||
clearStreamPreviewState()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -751,7 +911,11 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<!-- 搜索加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }">
|
||||
<div
|
||||
v-if="isSearchProgressVisible"
|
||||
class="search-loading-state mb-3"
|
||||
:class="{ 'is-empty-loading': isSearchLoading }"
|
||||
>
|
||||
<VCard elevation="0" class="search-progress-card">
|
||||
<div class="progress-header">
|
||||
<div class="progress-icon-wrap">
|
||||
@@ -808,8 +972,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
|
||||
<!-- 精简标题栏 -->
|
||||
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
|
||||
<!-- 精简标题栏:搜索过后保持挂载,加载中由按钮 :disabled / :loading 表达状态 -->
|
||||
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
|
||||
<div class="search-info-container">
|
||||
<div class="search-title text-moviepilot">
|
||||
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
|
||||
@@ -833,6 +997,22 @@ onUnmounted(() => {
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 重新搜索按钮 -->
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
icon
|
||||
class="me-2 refresh-search-btn"
|
||||
:loading="isRefreshing"
|
||||
:disabled="isRefreshing || progressActive"
|
||||
@click="refreshSearch"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" size="20" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ t('resource.refreshSearch') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
|
||||
<!-- AI操作按钮组 -->
|
||||
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
|
||||
<div class="ai-toggle-buttons">
|
||||
@@ -921,28 +1101,30 @@ onUnmounted(() => {
|
||||
<VFadeTransition mode="out-in">
|
||||
<!-- 卡片视图模式 -->
|
||||
<div v-if="viewType === 'card'" key="card">
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="cardScroll.displayDataList.value"
|
||||
class="overflow-visible"
|
||||
@load="cardScroll.loadMore"
|
||||
<div
|
||||
v-if="progressActive && streamPreviewDataList.length > 0"
|
||||
class="grid gap-4 grid-torrent-card items-start"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-torrent-card items-start">
|
||||
<TorrentCard
|
||||
v-for="(item, index) in cardScroll.displayDataList.value"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
:torrent="item"
|
||||
:more="item.more"
|
||||
class="stream-result-item"
|
||||
/>
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
<TorrentCard
|
||||
v-for="(item, index) in streamPreviewDataList"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
:torrent="item"
|
||||
class="stream-result-item"
|
||||
/>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="filteredCardDataList.length > 0"
|
||||
:items="filteredCardDataList"
|
||||
:get-item-key="getTorrentItemKey"
|
||||
:min-item-width="300"
|
||||
:estimated-item-height="400"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<TorrentCard :torrent="item" :more="item.more" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="cardScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<div v-if="!progressActive && filteredCardDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
@@ -952,30 +1134,30 @@ onUnmounted(() => {
|
||||
<div v-else-if="viewType === 'row'" key="row">
|
||||
<VCard class="resource-list-container">
|
||||
<!-- 无结果时显示 -->
|
||||
<div v-if="rowScroll.displayDataList.value.length === 0" class="no-results">
|
||||
<div v-if="!progressActive && filteredRowDataList.length === 0" class="no-results">
|
||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||
</div>
|
||||
<!-- 资源列表 -->
|
||||
<VInfiniteScroll
|
||||
v-else
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="rowScroll.displayDataList.value"
|
||||
class="resource-list overflow-visible"
|
||||
@load="rowScroll.loadMore"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-else-if="progressActive && streamPreviewDataList.length > 0" class="resource-list overflow-visible">
|
||||
<div
|
||||
v-for="(item, index) in rowScroll.displayDataList.value"
|
||||
v-for="(item, index) in streamPreviewDataList"
|
||||
:key="getTorrentItemKey(item, index)"
|
||||
class="stream-result-item"
|
||||
>
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
|
||||
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</VInfiniteScroll>
|
||||
</div>
|
||||
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
|
||||
<TorrentItem :torrent="item" />
|
||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VFadeTransition>
|
||||
@@ -1180,6 +1362,14 @@ onUnmounted(() => {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
/* 重新搜索按钮 */
|
||||
.refresh-search-btn {
|
||||
border-radius: 8px !important;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
block-size: 44px !important;
|
||||
inline-size: 44px !important;
|
||||
}
|
||||
|
||||
/* AI按钮组样式 */
|
||||
.ai-toggle-container {
|
||||
position: relative;
|
||||
@@ -1267,9 +1457,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 无结果提示 */
|
||||
@@ -1371,6 +1559,11 @@ onUnmounted(() => {
|
||||
inline-size: 36px;
|
||||
}
|
||||
|
||||
.refresh-search-btn {
|
||||
block-size: 36px !important;
|
||||
inline-size: 36px !important;
|
||||
}
|
||||
|
||||
.ai-toggle-buttons {
|
||||
block-size: 36px;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ const searchShareDialog = ref(false)
|
||||
// 订阅分享统计弹窗
|
||||
const shareStatisticsDialog = ref(false)
|
||||
|
||||
// 排序模式
|
||||
const subscribeSortMode = ref(false)
|
||||
|
||||
// 订阅过滤词
|
||||
const subscribeFilter = ref('')
|
||||
|
||||
@@ -122,6 +125,10 @@ function openShareStatisticsDialog() {
|
||||
shareStatisticsDialog.value = true
|
||||
}
|
||||
|
||||
function toggleSubscribeSortMode() {
|
||||
subscribeSortMode.value = !subscribeSortMode.value
|
||||
}
|
||||
|
||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||
shareKeyword.value = keyword.trim()
|
||||
}, 300)
|
||||
@@ -220,6 +227,14 @@ registerHeaderTab({
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-sort-variant',
|
||||
variant: 'text',
|
||||
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
action: toggleSubscribeSortMode,
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-checkbox-multiple-marked-outline',
|
||||
variant: 'text',
|
||||
@@ -267,6 +282,8 @@ onMounted(() => {
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -73,7 +73,6 @@ const router = createRouter({
|
||||
path: '/subscribe-share',
|
||||
component: () => import('../pages/subscribe-share.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -97,6 +96,7 @@ const router = createRouter({
|
||||
path: '/downloading',
|
||||
component: () => import('../pages/downloading.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -104,6 +104,7 @@ const router = createRouter({
|
||||
path: '/history',
|
||||
component: () => import('../pages/history.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
@@ -145,7 +146,6 @@ const router = createRouter({
|
||||
name: 'plugin-app',
|
||||
component: () => import('../pages/plugin-app.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -161,7 +161,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/browse.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -170,7 +169,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/credits.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -179,7 +177,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/person.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -187,7 +184,6 @@ const router = createRouter({
|
||||
path: '/media',
|
||||
component: () => import('../pages/media.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -204,6 +200,7 @@ const router = createRouter({
|
||||
path: '/apps',
|
||||
component: () => import('../pages/appcenter.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -147,6 +147,7 @@ registerRoute(
|
||||
({ url, request }) =>
|
||||
url.pathname.includes('/api/v1/') &&
|
||||
request.method === 'GET' &&
|
||||
!url.pathname.includes('/api/v1/search/') && // 搜索接口结果动态变化,避免缓存导致重复搜索失效
|
||||
!url.pathname.includes('/api/v1/system/message') && // SSE实时消息流
|
||||
!url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流
|
||||
!url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流
|
||||
|
||||
@@ -161,7 +161,6 @@ html.v-overlay-scroll-blocked body {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
filter: brightness(1.35) saturate(1.05);
|
||||
inset-block-end: 0.75rem;
|
||||
inset-inline-end: 1rem;
|
||||
pointer-events: none;
|
||||
@@ -516,6 +515,7 @@ html.v-overlay-scroll-blocked body {
|
||||
.v-overlay__content .v-list{
|
||||
backdrop-filter: blur(6px);
|
||||
background-color: rgb(var(--v-theme-surface), 0.9) !important;
|
||||
padding-inline: 0.5rem !important;
|
||||
}
|
||||
|
||||
.v-overlay__content .v-card:not(.bg-primary){
|
||||
|
||||
1
src/types/iconify-bundle.d.ts
vendored
Normal file
1
src/types/iconify-bundle.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '@/@iconify/icons-bundle'
|
||||
40
src/utils/apexCharts.ts
Normal file
40
src/utils/apexCharts.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
export function configureApexChartsTheme(themeName: string) {
|
||||
if (typeof window === 'undefined' || !window.Apex) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const isDark = themeName === 'dark' || themeName === '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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 通用APP深度链接工具类
|
||||
* 支持媒体服务器(Plex、Jellyfin、Emby)和豆瓣的APP跳转和网页跳转
|
||||
* 支持媒体服务器(Plex、Jellyfin、Emby、极影视、飞牛影视)和豆瓣的APP跳转和网页跳转
|
||||
*
|
||||
* 深度链接格式参考:
|
||||
* - Plex: https://forums.plex.tv/t/plex-mobile-app-deep-linking/123456
|
||||
@@ -12,7 +12,7 @@
|
||||
import { isMobileDevice, isIOSDevice, isAndroidDevice } from '@/@core/utils'
|
||||
|
||||
// APP类型
|
||||
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'trimemedia' | 'douban'
|
||||
export type AppType = 'plex' | 'jellyfin' | 'emby' | 'zspace' | 'trimemedia' | 'douban'
|
||||
|
||||
// 深度链接配置
|
||||
interface DeepLinkConfig {
|
||||
@@ -38,6 +38,11 @@ const DEEP_LINK_CONFIGS: Record<AppType, DeepLinkConfig> = {
|
||||
webUrl: 'https://emby.media',
|
||||
timeout: 2000,
|
||||
},
|
||||
zspace: {
|
||||
appScheme: 'emby://',
|
||||
webUrl: 'https://www.zspace.com.cn',
|
||||
timeout: 2000,
|
||||
},
|
||||
trimemedia: {
|
||||
appScheme: 'trimemedia://',
|
||||
webUrl: 'https://trimemedia.com',
|
||||
@@ -135,6 +140,9 @@ function buildDeepLinkUrl(appType: AppType, params: string | DoubanAppParams): s
|
||||
case 'emby':
|
||||
return buildEmbyDeepLink(params as string)
|
||||
|
||||
case 'zspace':
|
||||
return buildEmbyDeepLink(params as string)
|
||||
|
||||
case 'trimemedia':
|
||||
return buildTrimemediaDeepLink(params as string)
|
||||
|
||||
@@ -634,7 +642,7 @@ export async function openMediaServerWithAutoDetect(
|
||||
// 优先使用传入的 serverType 参数
|
||||
if (serverType) {
|
||||
const type = serverType.toLowerCase()
|
||||
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'trimemedia') {
|
||||
if (type === 'plex' || type === 'jellyfin' || type === 'emby' || type === 'zspace' || type === 'trimemedia') {
|
||||
detectedServerType = type as AppType
|
||||
}
|
||||
}
|
||||
@@ -649,6 +657,8 @@ export async function openMediaServerWithAutoDetect(
|
||||
detectedServerType = 'jellyfin'
|
||||
} else if (url.includes('emby')) {
|
||||
detectedServerType = 'emby'
|
||||
} else if (url.includes('zspace')) {
|
||||
detectedServerType = 'zspace'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,6 +708,8 @@ export function getAppDownloadUrl(appType: AppType): string {
|
||||
return 'https://jellyfin.org/downloads/'
|
||||
case 'emby':
|
||||
return 'https://emby.media/download.html'
|
||||
case 'zspace':
|
||||
return 'https://www.zspace.com.cn/'
|
||||
case 'trimemedia':
|
||||
return 'https://trimemedia.com/download'
|
||||
case 'douban':
|
||||
|
||||
@@ -8,11 +8,14 @@ import qbittorrentLogo from '@/assets/images/logos/qbittorrent.png'
|
||||
import transmissionLogo from '@/assets/images/logos/transmission.png'
|
||||
import rtorrentLogo from '@/assets/images/logos/rtorrent.png'
|
||||
import embyLogo from '@/assets/images/logos/emby.png'
|
||||
import zspaceLogo from '@/assets/images/logos/zspace.webp'
|
||||
import jellyfinLogo from '@/assets/images/logos/jellyfin.png'
|
||||
import plexLogo from '@/assets/images/logos/plex.png'
|
||||
import trimemediaLogo from '@/assets/images/logos/trimemedia.png'
|
||||
import ugreenLogo from '@/assets/images/logos/ugreen.png'
|
||||
import wechatLogo from '@/assets/images/logos/wechat.png'
|
||||
import feishuLogo from '@/assets/images/logos/feishu.png'
|
||||
import clawbotLogo from '@/assets/images/logos/clawbot.png'
|
||||
import telegramLogo from '@/assets/images/logos/telegram.webp'
|
||||
import slackLogo from '@/assets/images/logos/slack.webp'
|
||||
import discordLogo from '@/assets/images/logos/discord.png'
|
||||
@@ -39,11 +42,14 @@ const logoMap: Record<string, string> = {
|
||||
transmission: transmissionLogo,
|
||||
rtorrent: rtorrentLogo,
|
||||
emby: embyLogo,
|
||||
zspace: zspaceLogo,
|
||||
jellyfin: jellyfinLogo,
|
||||
plex: plexLogo,
|
||||
trimemedia: trimemediaLogo,
|
||||
ugreen: ugreenLogo,
|
||||
wechat: wechatLogo,
|
||||
feishu: feishuLogo,
|
||||
wechatclawbot: clawbotLogo,
|
||||
telegram: telegramLogo,
|
||||
slack: slackLogo,
|
||||
discord: discordLogo,
|
||||
|
||||
77
src/utils/mediaStatusCache.ts
Normal file
77
src/utils/mediaStatusCache.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type StatusCacheEntry = {
|
||||
expiresAt: number
|
||||
value: boolean
|
||||
}
|
||||
|
||||
const STATUS_CACHE_TTL = 3 * 60 * 1000
|
||||
|
||||
const existsStatusCache = new Map<string, StatusCacheEntry>()
|
||||
const existsStatusRequests = new Map<string, Promise<boolean>>()
|
||||
const subscribeStatusCache = new Map<string, StatusCacheEntry>()
|
||||
const subscribeStatusRequests = new Map<string, Promise<boolean>>()
|
||||
|
||||
function getCachedValue(cache: Map<string, StatusCacheEntry>, key: string): boolean | undefined {
|
||||
const entry = cache.get(key)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
cache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
|
||||
function setCachedValue(cache: Map<string, StatusCacheEntry>, key: string, value: boolean) {
|
||||
cache.set(key, {
|
||||
expiresAt: Date.now() + STATUS_CACHE_TTL,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveCachedStatus(
|
||||
cache: Map<string, StatusCacheEntry>,
|
||||
requests: Map<string, Promise<boolean>>,
|
||||
key: string,
|
||||
loader: () => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
const cachedValue = getCachedValue(cache, key)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue
|
||||
}
|
||||
|
||||
const currentRequest = requests.get(key)
|
||||
if (currentRequest) {
|
||||
return currentRequest
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then(value => {
|
||||
setCachedValue(cache, key, value)
|
||||
return value
|
||||
})
|
||||
.finally(() => {
|
||||
requests.delete(key)
|
||||
})
|
||||
|
||||
requests.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
export function getCachedMediaExistsStatus(key: string, loader: () => Promise<boolean>) {
|
||||
return resolveCachedStatus(existsStatusCache, existsStatusRequests, key, loader)
|
||||
}
|
||||
|
||||
export function setCachedMediaExistsStatus(key: string, value: boolean) {
|
||||
setCachedValue(existsStatusCache, key, value)
|
||||
}
|
||||
|
||||
export function getCachedMediaSubscribeStatus(key: string, loader: () => Promise<boolean>) {
|
||||
return resolveCachedStatus(subscribeStatusCache, subscribeStatusRequests, key, loader)
|
||||
}
|
||||
|
||||
export function setCachedMediaSubscribeStatus(key: string, value: boolean) {
|
||||
setCachedValue(subscribeStatusCache, key, value)
|
||||
}
|
||||
52
src/utils/siteIconCache.ts
Normal file
52
src/utils/siteIconCache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type SiteIconCacheEntry = {
|
||||
expiresAt: number
|
||||
value: string
|
||||
}
|
||||
|
||||
const SITE_ICON_CACHE_TTL = 10 * 60 * 1000
|
||||
const siteIconCache = new Map<string, SiteIconCacheEntry>()
|
||||
const siteIconRequests = new Map<string, Promise<string>>()
|
||||
|
||||
function readCachedSiteIcon(key: string): string | undefined {
|
||||
const entry = siteIconCache.get(key)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
siteIconCache.delete(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.value
|
||||
}
|
||||
|
||||
export async function getCachedSiteIcon(siteId: string | number, loader: () => Promise<string>): Promise<string> {
|
||||
const cacheKey = String(siteId)
|
||||
const cachedIcon = readCachedSiteIcon(cacheKey)
|
||||
if (cachedIcon !== undefined) {
|
||||
return cachedIcon
|
||||
}
|
||||
|
||||
const currentRequest = siteIconRequests.get(cacheKey)
|
||||
if (currentRequest) {
|
||||
return currentRequest
|
||||
}
|
||||
|
||||
const request = loader()
|
||||
.then(icon => {
|
||||
siteIconCache.set(cacheKey, {
|
||||
expiresAt: Date.now() + SITE_ICON_CACHE_TTL,
|
||||
value: icon,
|
||||
})
|
||||
|
||||
return icon
|
||||
})
|
||||
.finally(() => {
|
||||
siteIconRequests.delete(cacheKey)
|
||||
})
|
||||
|
||||
siteIconRequests.set(cacheKey, request)
|
||||
|
||||
return request
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export class SSEManager {
|
||||
}
|
||||
private reconnectAttempts = 0
|
||||
private isConnecting = false
|
||||
private readonly handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
}
|
||||
private readonly handleBeforeUnload = () => {
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
constructor(url: string, options: Partial<typeof SSEManager.prototype.options> = {}) {
|
||||
this.url = url
|
||||
@@ -30,18 +40,13 @@ export class SSEManager {
|
||||
}
|
||||
|
||||
private setupVisibilityListener() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handleBackground()
|
||||
} else {
|
||||
this.handleForeground()
|
||||
}
|
||||
})
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.addEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
// 页面卸载时关闭连接
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.close()
|
||||
})
|
||||
private removeVisibilityListener() {
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange)
|
||||
window.removeEventListener('beforeunload', this.handleBeforeUnload)
|
||||
}
|
||||
|
||||
private handleBackground() {
|
||||
@@ -172,6 +177,18 @@ export class SSEManager {
|
||||
* 关闭连接
|
||||
*/
|
||||
close() {
|
||||
this.resetConnectionState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器并清理所有引用
|
||||
*/
|
||||
destroy() {
|
||||
this.resetConnectionState(true)
|
||||
this.removeVisibilityListener()
|
||||
}
|
||||
|
||||
private resetConnectionState(clearListeners = false) {
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close()
|
||||
this.eventSource = null
|
||||
@@ -187,7 +204,10 @@ export class SSEManager {
|
||||
this.backgroundCloseTimer = null
|
||||
}
|
||||
|
||||
this.listeners.clear()
|
||||
if (clearListeners) {
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
this.isConnecting = false
|
||||
this.reconnectAttempts = 0
|
||||
}
|
||||
@@ -210,8 +230,9 @@ export class SSEManager {
|
||||
* 强制重新连接
|
||||
*/
|
||||
forceReconnect() {
|
||||
const hasActiveListeners = this.listeners.size > 0
|
||||
this.close()
|
||||
if (!this.isBackground && this.listeners.size > 0) {
|
||||
if (!this.isBackground && hasActiveListeners) {
|
||||
this.reconnectSSE()
|
||||
}
|
||||
}
|
||||
@@ -244,6 +265,10 @@ export class SSEManager {
|
||||
class SSEManagerSingleton {
|
||||
private managers: Map<string, SSEManager> = new Map()
|
||||
|
||||
private getIndependentManagerKey(url: string, listenerId: string): string {
|
||||
return `${url}::${listenerId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建SSE管理器
|
||||
* @param url SSE连接URL
|
||||
@@ -285,16 +310,28 @@ class SSEManagerSingleton {
|
||||
closeManager(url: string) {
|
||||
const manager = this.managers.get(url)
|
||||
if (manager) {
|
||||
manager.close()
|
||||
manager.destroy()
|
||||
this.managers.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭独立管理器
|
||||
*/
|
||||
closeIndependentManager(url: string, listenerId: string) {
|
||||
const managerKey = this.getIndependentManagerKey(url, listenerId)
|
||||
const manager = this.managers.get(managerKey)
|
||||
if (manager) {
|
||||
manager.destroy()
|
||||
this.managers.delete(managerKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有管理器
|
||||
*/
|
||||
closeAllManagers() {
|
||||
this.managers.forEach(manager => manager.close())
|
||||
this.managers.forEach(manager => manager.destroy())
|
||||
this.managers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class ThemeManager {
|
||||
private themes: Map<string, ThemeConfig> = new Map()
|
||||
private currentTheme: string = 'default'
|
||||
private loadedLinks: Map<string, HTMLLinkElement> = new Map()
|
||||
private themeListeners: Map<(theme: string) => void, EventListener> = new Map()
|
||||
|
||||
constructor() {
|
||||
// 注册所有可用主题
|
||||
@@ -190,18 +191,29 @@ class ThemeManager {
|
||||
* 监听主题变更事件
|
||||
*/
|
||||
onThemeChange(callback: (theme: string) => void): void {
|
||||
document.addEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
if (this.themeListeners.has(callback)) {
|
||||
return
|
||||
}
|
||||
|
||||
const listener: EventListener = event => {
|
||||
callback((event as CustomEvent<{ theme: string }>).detail.theme)
|
||||
}
|
||||
|
||||
this.themeListeners.set(callback, listener)
|
||||
document.addEventListener('themechange', listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除主题变更监听器
|
||||
*/
|
||||
offThemeChange(callback: (theme: string) => void): void {
|
||||
document.removeEventListener('themechange', (event: any) => {
|
||||
callback(event.detail.theme)
|
||||
})
|
||||
const listener = this.themeListeners.get(callback)
|
||||
if (!listener) {
|
||||
return
|
||||
}
|
||||
|
||||
document.removeEventListener('themechange', listener)
|
||||
this.themeListeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/utils/torrentDownloadCache.ts
Normal file
13
src/utils/torrentDownloadCache.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const downloadedTorrentMap = reactive<Record<string, boolean>>({})
|
||||
|
||||
export function markTorrentDownloaded(url?: string | null) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
|
||||
downloadedTorrentMap[url] = true
|
||||
}
|
||||
|
||||
export { downloadedTorrentMap }
|
||||
@@ -2,6 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,12 +28,11 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
const currData = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 用于保存已处理过的 key
|
||||
const seenKeys = ref<Set<string>>(new Set<string>())
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -46,27 +46,42 @@ function getParams() {
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
"source",
|
||||
"type",
|
||||
"season",
|
||||
"tmdb_id",
|
||||
"imdb_id",
|
||||
"tvdb_id",
|
||||
"douban_id",
|
||||
"bangumi_id",
|
||||
"mediaid_prefix",
|
||||
"media_id",
|
||||
] as const;
|
||||
'source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
'imdb_id',
|
||||
'tvdb_id',
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
|
||||
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
||||
return items.filter(item => {
|
||||
const key = dedupFields.map(field => String(item[field])).join('~');
|
||||
if (seenKeys.value.has(key)) {
|
||||
return false;
|
||||
const key = dedupFields.map(field => String(item[field])).join('~')
|
||||
if (seenKeys.has(key)) {
|
||||
return false
|
||||
}
|
||||
seenKeys.value.add(key);
|
||||
return true;
|
||||
});
|
||||
seenKeys.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function appendData(items: MediaInfo[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
const rawData: MediaInfo[] = await api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
})
|
||||
|
||||
return {
|
||||
rawCount: rawData.length,
|
||||
uniqueData: deduplicate(rawData),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
@@ -87,22 +102,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
}
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -113,19 +124,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const { rawCount, uniqueData } = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (rawCount === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 去重
|
||||
currData.value = deduplicate(currData.value)
|
||||
// 合并数据
|
||||
dataList.value.push(...currData.value)
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -147,9 +154,16 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<MediaCard v-for="data in dataList" :key="data.tmdb_id || data.douban_id" :media="data" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<MediaCard :media="item" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIntersectionObserver, until } from '@vueuse/core'
|
||||
|
||||
@@ -27,8 +27,8 @@ const componentLoaded = ref(false)
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<MediaInfo[]>([])
|
||||
// 使用 shallowRef 避免横向卡片区的大数组深层代理
|
||||
const dataList = shallowRef<MediaInfo[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
@@ -74,21 +74,21 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<div ref="containerRef">
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.tmdb_id || data.douban_id || data.bangumi_id">
|
||||
<MediaCard :media="data" width="9rem" />
|
||||
</template>
|
||||
<VirtualSlideView
|
||||
:items="dataList"
|
||||
:loading="!componentLoaded"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaCard :media="item" width="9rem" />
|
||||
</template>
|
||||
</SlideView>
|
||||
<SlideView v-else-if="!componentLoaded">
|
||||
<template #content>
|
||||
<template #loading>
|
||||
<div v-for="i in 10" :key="i" style="width: 9rem">
|
||||
<VCard class="outline-none overflow-hidden">
|
||||
<div style="padding-bottom: 150%"></div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</SlideView>
|
||||
</VirtualSlideView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -27,9 +29,18 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const currData = ref<any>([])
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
function appendData(items: Person[]) {
|
||||
dataList.value = dataList.value.concat(items)
|
||||
}
|
||||
|
||||
async function loadPageData() {
|
||||
return api.get(props.apipath!, {
|
||||
params: getParams(),
|
||||
}) as Promise<Person[]>
|
||||
}
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -59,20 +70,18 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
return
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -84,17 +93,15 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
// 请求API
|
||||
currData.value = await api.get(props.apipath, {
|
||||
params: getParams(),
|
||||
})
|
||||
const currentData = await loadPageData()
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
if (currentData.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
} else {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(currentData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -116,9 +123,11 @@ async function fetchData({ done }: { done: any }) {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<PersonCard v-for="data in dataList" :key="data.id" :person="data" />
|
||||
</div>
|
||||
<ProgressiveCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
|
||||
<template #default="{ item }">
|
||||
<PersonCard :person="item" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||
import type { Person } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import SlideView from '@/components/slide/SlideView.vue'
|
||||
import VirtualSlideView from '@/components/slide/VirtualSlideView.vue'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -16,8 +18,14 @@ provide('rankingPropsKey', reactive({ ...props }))
|
||||
// 组件加载完成
|
||||
const componentLoaded = ref(false)
|
||||
|
||||
// 是否已尝试加载
|
||||
const hasTriedLoading = ref(false)
|
||||
|
||||
// 数据列表
|
||||
const dataList = ref<any>([])
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
// 容器引用
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 获取订阅列表数据
|
||||
async function fetchData() {
|
||||
@@ -25,22 +33,47 @@ async function fetchData() {
|
||||
if (!props.apipath) return
|
||||
|
||||
dataList.value = await api.get(props.apipath)
|
||||
if (dataList.value.length > 0) componentLoaded.value = true
|
||||
componentLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
hasTriedLoading.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载时获取数据
|
||||
onMounted(fetchData)
|
||||
const { stop } = useIntersectionObserver(
|
||||
containerRef,
|
||||
([{ isIntersecting }]) => {
|
||||
if (isIntersecting) {
|
||||
fetchData()
|
||||
stop()
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: '300px',
|
||||
},
|
||||
)
|
||||
|
||||
onActivated(() => {
|
||||
if (dataList.value.length === 0 && hasTriedLoading.value) {
|
||||
fetchData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SlideView v-if="componentLoaded">
|
||||
<template #content>
|
||||
<template v-for="data in dataList" :key="data.id">
|
||||
<PersonCard :person="data" width="9rem" />
|
||||
<div ref="containerRef">
|
||||
<VirtualSlideView :items="dataList" :loading="!componentLoaded" :get-item-key="item => item.id">
|
||||
<template #item="{ item }">
|
||||
<PersonCard :person="item" width="9rem" />
|
||||
</template>
|
||||
</template>
|
||||
</SlideView>
|
||||
<template #loading>
|
||||
<div v-for="i in 10" :key="i" style="width: 9rem">
|
||||
<VCard class="outline-none overflow-hidden">
|
||||
<div style="padding-bottom: 150%"></div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualSlideView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
|
||||
@@ -30,6 +31,7 @@ const { appMode } = usePWA()
|
||||
|
||||
// 当前标签
|
||||
const activeTab = ref('installed')
|
||||
const sortMode = ref(false)
|
||||
|
||||
// 获取插件标签页
|
||||
const pluginTabs = computed(() => getPluginTabs(t))
|
||||
@@ -58,6 +60,16 @@ registerHeaderTab({
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-sort-variant',
|
||||
variant: 'text',
|
||||
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
sortMode.value = !sortMode.value
|
||||
},
|
||||
show: computed(() => activeTab.value === 'installed'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
@@ -249,6 +261,41 @@ const newFolderDialog = ref(false)
|
||||
// 新文件夹名称
|
||||
const newFolderName = ref('')
|
||||
|
||||
const pluginByIdMap = computed(() => new Map(dataList.value.map(plugin => [plugin.id, plugin])))
|
||||
const orderValueMap = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
|
||||
orderConfig.value.forEach((item, index) => {
|
||||
map.set(`${item.type || 'plugin'}:${item.id}`, item.order ?? index)
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const folderedPluginIds = computed(() => {
|
||||
const pluginIds = new Set<string>()
|
||||
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((pluginId: string) => pluginIds.add(pluginId))
|
||||
})
|
||||
|
||||
return pluginIds
|
||||
})
|
||||
|
||||
const canDragSort = computed(() => sortMode.value && activeTab.value === 'installed')
|
||||
const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
|
||||
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
||||
const installedScrollToIndex = computed(() => {
|
||||
if (sortMode.value || currentFolder.value || !pluginId.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
|
||||
|
||||
return targetIndex >= 0 ? targetIndex : undefined
|
||||
})
|
||||
|
||||
// 获取文件夹内筛选后的插件
|
||||
const getFilteredFolderPlugins = (folderName: string) => {
|
||||
const folderData = pluginFolders.value[folderName]
|
||||
@@ -257,7 +304,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
||||
// 获取文件夹内的插件并应用筛选条件
|
||||
const folderPlugins: Plugin[] = []
|
||||
folderPluginIds.forEach((pluginId: string) => {
|
||||
const plugin = dataList.value.find(p => p.id === pluginId)
|
||||
const plugin = pluginByIdMap.value.get(pluginId)
|
||||
if (plugin) {
|
||||
folderPlugins.push(plugin)
|
||||
}
|
||||
@@ -288,12 +335,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
||||
const displayedPlugins = computed(() => {
|
||||
if (!currentFolder.value) {
|
||||
// 主列表:显示未归类的插件
|
||||
const folderedPluginIds = new Set()
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((pid: string) => folderedPluginIds.add(pid))
|
||||
})
|
||||
return filteredDataList.value.filter(plugin => !folderedPluginIds.has(plugin.id))
|
||||
return filteredDataList.value.filter(plugin => !folderedPluginIds.value.has(plugin.id))
|
||||
} else {
|
||||
// 文件夹内:返回筛选后的插件
|
||||
return getFilteredFolderPlugins(currentFolder.value)
|
||||
@@ -365,23 +407,21 @@ function updateMixedSortList() {
|
||||
|
||||
// 添加文件夹项目
|
||||
displayedFolders.value.forEach(folder => {
|
||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)
|
||||
allItems.push({
|
||||
type: 'folder',
|
||||
id: folder.name,
|
||||
data: folder,
|
||||
order: orderItem?.order ?? 999,
|
||||
order: orderValueMap.value.get(`folder:${folder.name}`) ?? 999,
|
||||
})
|
||||
})
|
||||
|
||||
// 添加插件项目
|
||||
displayedPlugins.value.forEach(plugin => {
|
||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)
|
||||
allItems.push({
|
||||
type: 'plugin',
|
||||
id: plugin.id || '',
|
||||
data: plugin,
|
||||
order: orderItem?.order ?? 999,
|
||||
order: orderValueMap.value.get(`plugin:${plugin.id}`) ?? 999,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,9 +503,10 @@ function sortPluginOrder() {
|
||||
return
|
||||
}
|
||||
dataList.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
|
||||
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aIndex - bIndex
|
||||
})
|
||||
}
|
||||
|
||||
@@ -505,7 +546,7 @@ async function saveMixedSortOrder() {
|
||||
Object.values(pluginFolders.value).forEach(folderData => {
|
||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||
plugins.forEach((id: string) => {
|
||||
const folderPlugin = dataList.value.find(p => p.id === id)
|
||||
const folderPlugin = pluginByIdMap.value.get(id)
|
||||
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
|
||||
newPluginOrder.push(folderPlugin)
|
||||
}
|
||||
@@ -994,12 +1035,8 @@ async function loadPluginFolders() {
|
||||
// 设置文件夹排序 - 使用全局排序配置
|
||||
const folderNames = Object.keys(processedFolders)
|
||||
folderOrder.value = folderNames.sort((a, b) => {
|
||||
// 从全局排序配置中查找文件夹的order
|
||||
const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)
|
||||
const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)
|
||||
|
||||
const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999
|
||||
const bOrder = bOrderItem?.order ?? processedFolders[b].order ?? 999
|
||||
const aOrder = orderValueMap.value.get(`folder:${a}`) ?? processedFolders[a].order ?? 999
|
||||
const bOrder = orderValueMap.value.get(`folder:${b}`) ?? processedFolders[b].order ?? 999
|
||||
|
||||
return aOrder - bOrder
|
||||
})
|
||||
@@ -1228,7 +1265,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
||||
}
|
||||
|
||||
// 验证插件ID
|
||||
const plugin = filteredDataList.value.find(p => p.id === pluginId)
|
||||
const plugin = pluginByIdMap.value.get(pluginId)
|
||||
|
||||
if (!plugin) {
|
||||
return
|
||||
@@ -1485,6 +1522,14 @@ function onDragStartPlugin(evt: any) {
|
||||
<div>
|
||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<!-- 文件夹和插件网格 -->
|
||||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||||
@@ -1492,10 +1537,10 @@ function onDragStartPlugin(evt: any) {
|
||||
<template v-if="!currentFolder">
|
||||
<!-- 主列表:使用draggable进行混合排序 -->
|
||||
<draggable
|
||||
v-if="canDragSort"
|
||||
v-model="mixedSortList"
|
||||
@end="saveMixedSortOrder"
|
||||
@start="onDragStartPlugin"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
@@ -1506,6 +1551,7 @@ function onDragStartPlugin(evt: any) {
|
||||
:item="element"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@@ -1520,15 +1566,42 @@ function onDragStartPlugin(evt: any) {
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledMainList"
|
||||
:items="mixedSortList"
|
||||
:get-item-key="item => `${item.type}:${item.id}`"
|
||||
:min-item-width="256"
|
||||
:scroll-to-index="installedScrollToIndex"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="item"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
@open-folder="openFolder"
|
||||
@delete-folder="deleteFolder"
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||
<draggable
|
||||
v-if="canDragSort"
|
||||
v-model="draggableFolderPlugins"
|
||||
@end="saveFolderPluginOrder"
|
||||
@start="onDragStartPlugin"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="grid gap-4 grid-plugin-card"
|
||||
@@ -1539,6 +1612,7 @@ function onDragStartPlugin(evt: any) {
|
||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
@@ -1550,6 +1624,29 @@ function onDragStartPlugin(evt: any) {
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||
:items="draggableFolderPlugins"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginMixedSortCard
|
||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||
:plugin-statistics="PluginStatistics"
|
||||
:plugin-actions="pluginActions"
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
}
|
||||
"
|
||||
@remove-from-folder="removeFromFolder"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1580,14 +1677,17 @@ function onDragStartPlugin(evt: any) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div class="grid gap-4 grid-plugin-card">
|
||||
<template
|
||||
v-for="(data, index) in displayUninstalledList"
|
||||
:key="`${data.id}_v${data.plugin_version}_${index}`"
|
||||
>
|
||||
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
||||
<ProgressiveCardGrid
|
||||
v-if="displayUninstalledList.length > 0"
|
||||
:items="displayUninstalledList"
|
||||
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||||
:min-item-width="256"
|
||||
:estimated-item-height="260"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||
</template>
|
||||
</div>
|
||||
</ProgressiveCardGrid>
|
||||
</VInfiniteScroll>
|
||||
<NoDataFound
|
||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import type { DownloadingInfo } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
@@ -67,14 +68,17 @@ const { loading: dataLoading } = useDataRefresh(
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
|
||||
<div v-if="filteredDataList.length > 0" class="grid gap-4 grid-downloading-card">
|
||||
<DownloadingCard
|
||||
v-for="data in filteredDataList"
|
||||
:key="data.hash"
|
||||
:info="data"
|
||||
:downloader-name="props.name"
|
||||
/>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="filteredDataList.length > 0"
|
||||
:items="filteredDataList"
|
||||
:get-item-key="item => item.hash || item.name"
|
||||
:min-item-width="320"
|
||||
:estimated-item-height="230"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<DownloadingCard :info="item" :downloader-name="props.name" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="filteredDataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -59,7 +59,7 @@ const aiRedoProgressDialog = ref(false)
|
||||
const aiRedoProgressActive = ref(false)
|
||||
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
|
||||
const aiRedoProgressSSE = ref<any>(null)
|
||||
const aiRedoProgressHistoryId = ref<number>()
|
||||
const aiRedoProgressHistoryIds = ref<number[]>([])
|
||||
|
||||
// 重新整理IDS
|
||||
const redoIds = ref<number[]>([])
|
||||
@@ -374,6 +374,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
|
||||
|
||||
// 批量删除记录
|
||||
async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
|
||||
if (hasRunningAiRedo.value) return
|
||||
// 关闭弹窗
|
||||
deleteConfirmDialog.value = false
|
||||
// 总条数
|
||||
@@ -409,6 +410,7 @@ async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
|
||||
|
||||
// 批量删除历史记录
|
||||
async function removeHistoryBatch() {
|
||||
if (hasRunningAiRedo.value) return
|
||||
if (selected.value.length === 0) return
|
||||
|
||||
// 清空当前操作记录
|
||||
@@ -421,6 +423,7 @@ async function removeHistoryBatch() {
|
||||
}
|
||||
// 批量重新整理
|
||||
async function retransferBatch() {
|
||||
if (hasRunningAiRedo.value) return
|
||||
if (selected.value.length === 0) return
|
||||
|
||||
// 清空当前操作记录
|
||||
@@ -462,15 +465,14 @@ function stopAiRedoProgress() {
|
||||
|
||||
// AI整理完成
|
||||
async function finishAiRedo(success: boolean, errorMessage?: string) {
|
||||
const historyId = aiRedoProgressHistoryId.value
|
||||
const historyIds = [...aiRedoProgressHistoryIds.value]
|
||||
const historyIdSet = new Set(historyIds)
|
||||
|
||||
stopAiRedoProgress()
|
||||
aiRedoProgressDialog.value = false
|
||||
aiRedoProgressHistoryId.value = undefined
|
||||
|
||||
if (historyId !== undefined) {
|
||||
aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)
|
||||
}
|
||||
aiRedoProgressHistoryIds.value = []
|
||||
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIdSet.has(id))
|
||||
selected.value = selected.value.filter(item => !historyIdSet.has(item.id))
|
||||
|
||||
await fetchData()
|
||||
|
||||
@@ -493,9 +495,14 @@ async function handleAiRedoProgressMessage(event: MessageEvent) {
|
||||
|
||||
// 开始监听整理进度
|
||||
function startAiRedoProgress(historyId: number, progressKey: string) {
|
||||
startAiRedoProgressBatch([historyId], progressKey)
|
||||
}
|
||||
|
||||
// 开始监听批量整理进度
|
||||
function startAiRedoProgressBatch(historyIds: number[], progressKey: string) {
|
||||
stopAiRedoProgress()
|
||||
|
||||
aiRedoProgressHistoryId.value = historyId
|
||||
aiRedoProgressHistoryIds.value = historyIds
|
||||
aiRedoProgressDialog.value = true
|
||||
aiRedoProgressActive.value = true
|
||||
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
|
||||
@@ -543,6 +550,44 @@ async function triggerAiRedo(item: TransferHistory) {
|
||||
}
|
||||
}
|
||||
|
||||
// 批量触发AI整理
|
||||
async function triggerBatchAiRedo() {
|
||||
if (!aiAgentEnabled.value) {
|
||||
$toast.error(t('transferHistory.aiRedoDisabled'))
|
||||
return
|
||||
}
|
||||
if (hasRunningAiRedo.value) return
|
||||
|
||||
const historyIds = [...new Set(selected.value.map(item => item.id))]
|
||||
if (historyIds.length === 0) return
|
||||
|
||||
aiRedoIds.value = [...new Set([...aiRedoIds.value, ...historyIds])]
|
||||
let progressStarted = false
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', {
|
||||
history_ids: historyIds,
|
||||
})
|
||||
|
||||
const progressKey = result.data?.progress_key
|
||||
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
|
||||
|
||||
if (!result.success || !progressKey) {
|
||||
$toast.error(result.message || t('transferHistory.aiRedoFailed'))
|
||||
return
|
||||
}
|
||||
startAiRedoProgressBatch(acceptedIds, progressKey)
|
||||
selected.value = selected.value.filter(item => !acceptedIds.includes(item.id))
|
||||
progressStarted = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
$toast.error(t('transferHistory.aiRedoFailed'))
|
||||
} finally {
|
||||
if (!progressStarted) {
|
||||
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算下拉菜单
|
||||
function getDropdownItems(item: TransferHistory) {
|
||||
return [
|
||||
@@ -645,7 +690,7 @@ const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chev
|
||||
const historyDynamicMenuItems = computed(() => {
|
||||
if (selected.value.length === 0) return undefined
|
||||
|
||||
return [
|
||||
const items: Array<{ titleKey: string; icon: string; action: () => void; color?: string }> = [
|
||||
{
|
||||
titleKey: 'dialog.transferQueue.title',
|
||||
icon: 'mdi-timer-sand-paused',
|
||||
@@ -653,22 +698,36 @@ const historyDynamicMenuItems = computed(() => {
|
||||
transferQueueDialog.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchRedo',
|
||||
icon: 'mdi-redo-variant',
|
||||
action: () => {
|
||||
retransferBatch()
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchDelete',
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
action: () => {
|
||||
removeHistoryBatch()
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!hasRunningAiRedo.value) {
|
||||
items.push(
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchAiRedo',
|
||||
icon: 'mdi-robot-outline',
|
||||
action: () => {
|
||||
triggerBatchAiRedo()
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchRedo',
|
||||
icon: 'mdi-redo-variant',
|
||||
action: () => {
|
||||
retransferBatch()
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: 'transferHistory.actions.batchDelete',
|
||||
icon: 'mdi-trash-can-outline',
|
||||
color: 'error',
|
||||
action: () => {
|
||||
removeHistoryBatch()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
useDynamicButton({
|
||||
@@ -980,7 +1039,7 @@ onUnmounted(() => {
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/history'">
|
||||
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-trash-can-outline"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
@@ -989,7 +1048,7 @@ onUnmounted(() => {
|
||||
@click="removeHistoryBatch"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0"
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-redo-variant"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
@@ -997,6 +1056,15 @@ onUnmounted(() => {
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="retransferBatch"
|
||||
/>
|
||||
<VFab
|
||||
v-if="selected.length > 0 && !hasRunningAiRedo"
|
||||
icon="mdi-robot-outline"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="triggerBatchAiRedo"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-timer-sand-paused"
|
||||
color="primary"
|
||||
|
||||
@@ -10,9 +10,11 @@ import StorageCard from '@/components/cards/StorageCard.vue'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
|
||||
// 所有下载目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
@@ -58,6 +60,30 @@ const SystemSettings = ref<any>({
|
||||
},
|
||||
})
|
||||
|
||||
// 编辑器主题
|
||||
const editorTheme = computed(() => (globalTheme.name.value === 'light' ? 'github' : 'monokai'))
|
||||
|
||||
const renameEditorOptions = {
|
||||
fontSize: 14,
|
||||
tabSize: 2,
|
||||
showLineNumbers: true,
|
||||
showGutter: true,
|
||||
}
|
||||
|
||||
const movieRenameFormat = computed({
|
||||
get: () => SystemSettings.value.Basic.MOVIE_RENAME_FORMAT ?? '',
|
||||
set: (value: string) => {
|
||||
SystemSettings.value.Basic.MOVIE_RENAME_FORMAT = value || null
|
||||
},
|
||||
})
|
||||
|
||||
const tvRenameFormat = computed({
|
||||
get: () => SystemSettings.value.Basic.TV_RENAME_FORMAT ?? '',
|
||||
set: (value: string) => {
|
||||
SystemSettings.value.Basic.TV_RENAME_FORMAT = value || null
|
||||
},
|
||||
})
|
||||
|
||||
// 加载系统设置
|
||||
async function loadSystemSettings() {
|
||||
try {
|
||||
@@ -346,26 +372,48 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="SystemSettings.Basic.MOVIE_RENAME_FORMAT"
|
||||
:label="t('setting.directory.movieRenameFormat')"
|
||||
:hint="t('setting.directory.movieRenameFormatHint')"
|
||||
persistent-hint
|
||||
clearable
|
||||
active
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
<div class="rename-format-editor">
|
||||
<div class="rename-format-editor__label">
|
||||
<VIcon icon="mdi-movie-open" size="20" class="me-2" />
|
||||
<span>{{ t('setting.directory.movieRenameFormat') }}</span>
|
||||
</div>
|
||||
<VAceEditor
|
||||
v-model:value="movieRenameFormat"
|
||||
lang="jinja2"
|
||||
:theme="editorTheme"
|
||||
:options="renameEditorOptions"
|
||||
:print-margin="false"
|
||||
:min-lines="4"
|
||||
:max-lines="12"
|
||||
wrap
|
||||
class="rename-format-editor__ace rounded"
|
||||
/>
|
||||
<div class="rename-format-editor__hint">
|
||||
{{ t('setting.directory.movieRenameFormatHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="SystemSettings.Basic.TV_RENAME_FORMAT"
|
||||
:label="t('setting.directory.tvRenameFormat')"
|
||||
:hint="t('setting.directory.tvRenameFormatHint')"
|
||||
persistent-hint
|
||||
clearable
|
||||
active
|
||||
prepend-inner-icon="mdi-television"
|
||||
/>
|
||||
<div class="rename-format-editor">
|
||||
<div class="rename-format-editor__label">
|
||||
<VIcon icon="mdi-television" size="20" class="me-2" />
|
||||
<span>{{ t('setting.directory.tvRenameFormat') }}</span>
|
||||
</div>
|
||||
<VAceEditor
|
||||
v-model:value="tvRenameFormat"
|
||||
lang="jinja2"
|
||||
:theme="editorTheme"
|
||||
:options="renameEditorOptions"
|
||||
:print-margin="false"
|
||||
:min-lines="4"
|
||||
:max-lines="12"
|
||||
wrap
|
||||
class="rename-format-editor__ace rounded"
|
||||
/>
|
||||
<div class="rename-format-editor__hint">
|
||||
{{ t('setting.directory.tvRenameFormatHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
@@ -392,3 +440,28 @@ onMounted(() => {
|
||||
@done="loadMediaCategories"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rename-format-editor__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.78);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.375rem;
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.rename-format-editor__ace {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
min-block-size: 8rem;
|
||||
}
|
||||
|
||||
.rename-format-editor__hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.25rem;
|
||||
margin-block-start: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -107,6 +107,8 @@ const notificationTime = ref({
|
||||
end: '23:59',
|
||||
})
|
||||
|
||||
const wechatClawBotRenameMap = ref<Record<string, string>>({})
|
||||
|
||||
// 添加通知渠道
|
||||
function addNotification(notification: string) {
|
||||
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`
|
||||
@@ -127,11 +129,52 @@ function removeNotification(notification: NotificationConf) {
|
||||
if (index > -1) notifications.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function trackWechatClawBotRename(oldName: string, newName: string) {
|
||||
if (!oldName || !newName || oldName === newName) {
|
||||
return
|
||||
}
|
||||
const renameMap = { ...wechatClawBotRenameMap.value }
|
||||
for (const [source, target] of Object.entries(renameMap)) {
|
||||
if (target === oldName) {
|
||||
renameMap[source] = newName
|
||||
}
|
||||
}
|
||||
if (renameMap[oldName]) {
|
||||
renameMap[oldName] = newName
|
||||
} else {
|
||||
renameMap[oldName] = newName
|
||||
}
|
||||
wechatClawBotRenameMap.value = Object.fromEntries(
|
||||
Object.entries(renameMap).filter(([source, target]) => source && target && source !== target),
|
||||
)
|
||||
}
|
||||
|
||||
async function migrateWechatClawBotRenames() {
|
||||
const activeWechatClawBotNames = new Set(
|
||||
notifications.value.filter(item => item.type === 'wechatclawbot').map(item => item.name),
|
||||
)
|
||||
const renameEntries = Object.entries(wechatClawBotRenameMap.value).filter(
|
||||
([oldName, newName]) => oldName && newName && oldName !== newName && activeWechatClawBotNames.has(newName),
|
||||
)
|
||||
for (const [oldName, newName] of renameEntries) {
|
||||
const result: { [key: string]: any } = await api.post('notification/wechatclawbot/migrate', null, {
|
||||
params: {
|
||||
old_source: oldName,
|
||||
new_source: newName,
|
||||
},
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || `failed to migrate ${oldName} -> ${newName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询通知渠道设置
|
||||
async function loadNotificationSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||
notifications.value = result.data?.value ?? []
|
||||
wechatClawBotRenameMap.value = {}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
@@ -187,12 +230,15 @@ async function loadNotificationTime() {
|
||||
// 调用API保存通知设置
|
||||
async function saveNotificationSetting() {
|
||||
try {
|
||||
await migrateWechatClawBotRenames()
|
||||
const result: { [key: string]: any } = await api.post('system/setting/Notifications', notifications.value)
|
||||
if (result.success) {
|
||||
wechatClawBotRenameMap.value = {}
|
||||
$toast.success(t('setting.notification.saveSuccess'))
|
||||
} else $toast.error(t('setting.notification.saveFailed'))
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
$toast.error(t('setting.notification.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,7 +257,13 @@ async function saveNotificationTime() {
|
||||
// 通知渠道设置变化时赋值
|
||||
function changNotificationSetting(notification: NotificationConf, name: string) {
|
||||
const index = notifications.value.findIndex(item => item.name === name)
|
||||
if (index !== -1) notifications.value[index] = notification
|
||||
if (index !== -1) {
|
||||
const previous = notifications.value[index]
|
||||
notifications.value[index] = notification
|
||||
if (previous?.type === 'wechatclawbot' && previous.name !== notification.name) {
|
||||
trackWechatClawBotRename(previous.name, notification.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载消息类型开关
|
||||
@@ -299,12 +351,18 @@ onMounted(() => {
|
||||
<VIcon icon="mdi-plus" />
|
||||
<VMenu :activator="'parent'" :close-on-content-click="true">
|
||||
<VList>
|
||||
<VListItem @click="addNotification('wechat')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('telegram')">
|
||||
<VListItemTitle>{{ t('setting.notification.telegram') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('wechat')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechat') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('wechatclawbot')">
|
||||
<VListItemTitle>{{ t('setting.notification.wechatClawBot') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('feishu')">
|
||||
<VListItemTitle>{{ t('setting.notification.feishu') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('telegram')">
|
||||
<VListItemTitle>{{ t('setting.notification.telegram') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem @click="addNotification('slack')">
|
||||
<VListItemTitle>{{ t('setting.notification.slack') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
@@ -480,8 +538,9 @@ onMounted(() => {
|
||||
</VCardItem>
|
||||
<VCardText class="py-0">
|
||||
<VAceEditor
|
||||
:key="`${currentTemplate}-jinja2-json`"
|
||||
v-model:value="editorContent"
|
||||
lang="json"
|
||||
lang="jinja2_json"
|
||||
:theme="editorTheme"
|
||||
class="w-full h-full min-h-[30rem] rounded"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,98 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { useSetupWizard } from '@/composables/useSetupWizard'
|
||||
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
|
||||
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const { wizardData, validationErrors } = useSetupWizard()
|
||||
|
||||
const llmModels = ref<string[]>([])
|
||||
const loadingModels = ref(false)
|
||||
const providerRef = computed({
|
||||
get: () => wizardData.value.agent.provider,
|
||||
set: value => {
|
||||
wizardData.value.agent.provider = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const providerItems = [
|
||||
{ title: 'OpenAI', value: 'openai' },
|
||||
{ title: 'Google', value: 'google' },
|
||||
{ title: 'DeepSeek', value: 'deepseek' },
|
||||
]
|
||||
const apiKeyRef = computed({
|
||||
get: () => wizardData.value.agent.apiKey,
|
||||
set: value => {
|
||||
wizardData.value.agent.apiKey = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const baseUrlRef = computed({
|
||||
get: () => wizardData.value.agent.baseUrl,
|
||||
set: value => {
|
||||
wizardData.value.agent.baseUrl = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const baseUrlPresetRef = computed({
|
||||
get: () => wizardData.value.agent.baseUrlPreset,
|
||||
set: value => {
|
||||
wizardData.value.agent.baseUrlPreset = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const modelRef = computed({
|
||||
get: () => wizardData.value.agent.model,
|
||||
set: value => {
|
||||
wizardData.value.agent.model = value || ''
|
||||
},
|
||||
})
|
||||
|
||||
const maxContextTokensRef = computed({
|
||||
get: () => wizardData.value.agent.maxContextTokens,
|
||||
set: value => {
|
||||
wizardData.value.agent.maxContextTokens = value || 0
|
||||
},
|
||||
})
|
||||
|
||||
const authConnectedRef = computed({
|
||||
get: () => wizardData.value.agent.authConnected,
|
||||
set: value => {
|
||||
wizardData.value.agent.authConnected = Boolean(value)
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
providerItems,
|
||||
baseUrlPresetItems,
|
||||
models: llmModels,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
loadingProviders,
|
||||
loadingModels,
|
||||
providerConnected,
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
authPolling,
|
||||
authPopupBlocked,
|
||||
authSession,
|
||||
handleProviderSelection,
|
||||
applyModelMetadata,
|
||||
loadProviders,
|
||||
loadModels,
|
||||
openAuthPage,
|
||||
startAuth,
|
||||
pollAuthSession,
|
||||
disconnectAuth,
|
||||
closeAuthDialog,
|
||||
} = useLlmProviderDirectory({
|
||||
provider: providerRef,
|
||||
apiKey: apiKeyRef,
|
||||
baseUrl: baseUrlRef,
|
||||
baseUrlPreset: baseUrlPresetRef,
|
||||
model: modelRef,
|
||||
maxContextTokens: maxContextTokensRef,
|
||||
authConnected: authConnectedRef,
|
||||
})
|
||||
|
||||
const jobIntervalItems = computed(() => [
|
||||
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
|
||||
@@ -27,37 +105,72 @@ const jobIntervalItems = computed(() => [
|
||||
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
|
||||
])
|
||||
|
||||
async function loadLlmModels() {
|
||||
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
|
||||
return
|
||||
}
|
||||
const thinkingLevelItems = computed(() => [
|
||||
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
|
||||
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
|
||||
{ title: t('setting.system.llmThinkingLevelMinimal'), value: 'minimal' },
|
||||
{ title: t('setting.system.llmThinkingLevelLow'), value: 'low' },
|
||||
{ title: t('setting.system.llmThinkingLevelMedium'), value: 'medium' },
|
||||
{ title: t('setting.system.llmThinkingLevelHigh'), value: 'high' },
|
||||
{ title: t('setting.system.llmThinkingLevelMax'), value: 'max' },
|
||||
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
|
||||
])
|
||||
|
||||
loadingModels.value = true
|
||||
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
|
||||
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
|
||||
const selectedModelInfo = computed(() => {
|
||||
if (!selectedModel.value?.context_tokens_k) return ''
|
||||
return t('setting.system.llmModelResolvedHint', {
|
||||
context: selectedModel.value.context_tokens_k,
|
||||
source: selectedModel.value.source || 'models.dev',
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshModels(forceRefresh = true) {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/llm-models', {
|
||||
params: {
|
||||
provider: wizardData.value.agent.provider,
|
||||
api_key: wizardData.value.agent.apiKey,
|
||||
base_url: wizardData.value.agent.baseUrl,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
llmModels.value = result.data || []
|
||||
if (!wizardData.value.agent.model && llmModels.value.length > 0) {
|
||||
wizardData.value.agent.model = llmModels.value[0]
|
||||
}
|
||||
}
|
||||
await loadModels(forceRefresh)
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(error))
|
||||
console.log('Load LLM models failed:', error)
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
|
||||
loadLlmModels()
|
||||
async function handleProviderChanged() {
|
||||
handleProviderSelection(true)
|
||||
if (canRefreshModels.value) {
|
||||
await refreshModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelChanged() {
|
||||
applyModelMetadata()
|
||||
}
|
||||
|
||||
async function startProviderAuth(methodId: string) {
|
||||
try {
|
||||
await startAuth(methodId)
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectProviderAuth() {
|
||||
try {
|
||||
await disconnectAuth()
|
||||
$toast.success(t('setting.system.llmProviderDisconnected'))
|
||||
} catch (error) {
|
||||
$toast.error(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadProviders()
|
||||
if (wizardData.value.agent.enabled && canRefreshModels.value) {
|
||||
await refreshModels(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Load LLM providers failed:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -89,7 +202,7 @@ onMounted(() => {
|
||||
</VCol>
|
||||
|
||||
<template v-if="wizardData.agent.enabled">
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="3">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.global"
|
||||
:label="t('setting.system.aiAgentGlobal')"
|
||||
@@ -99,7 +212,7 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<VCol cols="12" md="3">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.verbose"
|
||||
:label="t('setting.system.aiAgentVerbose')"
|
||||
@@ -109,60 +222,113 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="4">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportImageInput"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
<VAutocomplete
|
||||
v-model="wizardData.agent.provider"
|
||||
:label="t('setting.system.llmProvider')"
|
||||
:hint="t('setting.system.llmProviderHint')"
|
||||
:items="providerItems"
|
||||
:loading="loadingProviders"
|
||||
:error="validationErrors.agent.provider"
|
||||
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-robot-outline"
|
||||
@update:model-value="handleProviderChanged"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.baseUrl"
|
||||
<VCol v-if="showBaseUrlField" cols="12" md="6">
|
||||
<VCombobox
|
||||
:model-value="wizardData.agent.baseUrl"
|
||||
@update:model-value="(value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
setBaseUrlPreset(value.id, value.value);
|
||||
} else {
|
||||
setBaseUrlPreset('', value || '');
|
||||
}
|
||||
}"
|
||||
:label="t('setting.system.llmBaseUrl')"
|
||||
:hint="t('setting.system.llmBaseUrlHint')"
|
||||
placeholder="https://api.deepseek.com"
|
||||
:placeholder="selectedProvider?.default_base_url || 'https://api.deepseek.com'"
|
||||
:items="baseUrlPresetItems"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
|
||||
</template>
|
||||
</VCombobox>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCol v-if="showApiKeyField" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.apiKey"
|
||||
:label="t('setting.system.llmApiKey')"
|
||||
:hint="t('setting.system.llmApiKeyHint')"
|
||||
:label="selectedProvider?.api_key_label || t('setting.system.llmApiKey')"
|
||||
:hint="selectedProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
|
||||
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
|
||||
:error="validationErrors.agent.apiKey"
|
||||
:error-messages="validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []"
|
||||
:error-messages="
|
||||
validationErrors.agent.apiKey ? [t('setupWizard.agent.authOrApiKeyRequired')] : []
|
||||
"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="providerAuthMethods.length > 0" cols="12">
|
||||
<VAlert type="info" variant="tonal">
|
||||
<div class="d-flex flex-column ga-3">
|
||||
<div>
|
||||
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
|
||||
<div class="text-body-2">
|
||||
{{ selectedProvider?.description || t('setting.system.llmProviderAuthHint') }}
|
||||
</div>
|
||||
<div v-if="providerConnected" class="text-body-2 mt-2">
|
||||
{{ t('setting.system.llmProviderConnectedAs', { label: providerAuthLabel || selectedProvider?.name }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn
|
||||
v-for="method in providerAuthMethods"
|
||||
:key="method.id"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-account-arrow-right-outline"
|
||||
@click="startProviderAuth(method.id)"
|
||||
>
|
||||
{{ method.label }}
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="providerConnected"
|
||||
color="error"
|
||||
variant="text"
|
||||
prepend-icon="mdi-link-off"
|
||||
@click="disconnectProviderAuth"
|
||||
>
|
||||
{{ t('setting.system.llmProviderDisconnect') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VCombobox
|
||||
v-model="wizardData.agent.model"
|
||||
:model-value="wizardData.agent.model"
|
||||
@update:model-value="(val: any) => {
|
||||
wizardData.agent.model = typeof val === 'object' && val !== null ? val.id : val;
|
||||
handleModelChanged();
|
||||
}"
|
||||
:label="t('setting.system.llmModel')"
|
||||
:hint="t('setting.system.llmModelHint')"
|
||||
:items="llmModels"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:loading="loadingModels"
|
||||
:error="validationErrors.agent.model"
|
||||
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
|
||||
@@ -174,11 +340,15 @@ onMounted(() => {
|
||||
variant="text"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
|
||||
@click="loadLlmModels"
|
||||
:disabled="!canRefreshModels"
|
||||
@click="refreshModels(true)"
|
||||
/>
|
||||
</template>
|
||||
</VCombobox>
|
||||
|
||||
<VAlert v-if="selectedModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
|
||||
{{ selectedModelInfo }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
@@ -198,6 +368,110 @@ onMounted(() => {
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="wizardData.agent.thinkingLevel"
|
||||
:label="t('setting.system.llmThinking')"
|
||||
:hint="t('setting.system.llmThinkingHint')"
|
||||
:items="thinkingLevelItems"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportImageInput"
|
||||
:label="t('setting.system.llmSupportImageInput')"
|
||||
:hint="t('setting.system.llmSupportImageInputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.supportAudioInputOutput"
|
||||
:label="t('setting.system.llmSupportAudioInputOutput')"
|
||||
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<template v-if="wizardData.agent.supportAudioInputOutput">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceApiKey"
|
||||
:label="t('setting.system.aiVoiceApiKey')"
|
||||
:hint="t('setting.system.aiVoiceApiKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
type="password"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceBaseUrl"
|
||||
:label="t('setting.system.aiVoiceBaseUrl')"
|
||||
:hint="t('setting.system.aiVoiceBaseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-link-variant"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceSttModel"
|
||||
:label="t('setting.system.aiVoiceSttModel')"
|
||||
:hint="t('setting.system.aiVoiceSttModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceTtsModel"
|
||||
:label="t('setting.system.aiVoiceTtsModel')"
|
||||
:hint="t('setting.system.aiVoiceTtsModelHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-waveform"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceTtsVoice"
|
||||
:label="t('setting.system.aiVoiceTtsVoice')"
|
||||
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-voice"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.agent.voiceLanguage"
|
||||
:label="t('setting.system.aiVoiceLanguage')"
|
||||
:hint="t('setting.system.aiVoiceLanguageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VSwitch
|
||||
v-model="wizardData.agent.voiceReplyWithText"
|
||||
:label="t('setting.system.aiVoiceReplyWithText')"
|
||||
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="wizardData.agent.jobInterval"
|
||||
:label="t('setting.system.aiAgentJobInterval')"
|
||||
@@ -259,4 +533,48 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VDialog v-model="authDialogVisible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="authSession?.instructions" type="info" variant="tonal">
|
||||
{{ authSession.instructions }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert v-if="authPopupBlocked" type="warning" variant="tonal">
|
||||
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||
</VAlert>
|
||||
|
||||
<div v-if="authSession?.user_code">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ authSession.user_code }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="authSession?.message" class="text-body-2">
|
||||
{{ authSession.message }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
|
||||
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="authPolling"
|
||||
@click="pollAuthSession"
|
||||
>
|
||||
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeAuthDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.downloader.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.downloader.config.username"
|
||||
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
:hint="t('downloader.username')"
|
||||
:error="validationErrors.downloader.username"
|
||||
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
|
||||
:disabled="!!wizardData.downloader.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
required
|
||||
:required="!wizardData.downloader.config.apikey"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
|
||||
:hint="t('downloader.password')"
|
||||
:error="validationErrors.downloader.password"
|
||||
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
|
||||
:disabled="!!wizardData.downloader.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
required
|
||||
:required="!wizardData.downloader.config.apikey"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
|
||||
@@ -122,6 +122,19 @@ watch(
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.mediaServer.type === 'zspace' ? 'primary' : 'default'"
|
||||
:variant="wizardData.mediaServer.type === 'zspace' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectMediaServerWithLibrary('zspace')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('zspace')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">极影视</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.mediaServer.type === 'jellyfin' ? 'primary' : 'default'"
|
||||
@@ -263,6 +276,89 @@ watch(
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.mediaServer.type === 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
:error="validationErrors.mediaServer.name"
|
||||
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
:error="validationErrors.mediaServer.host"
|
||||
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.mediaServer.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
:error="validationErrors.mediaServer.username"
|
||||
:error-messages="validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="wizardData.mediaServer.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
:error="validationErrors.mediaServer.password"
|
||||
:error-messages="validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="wizardData.mediaServer.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.mediaServer.type === 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -49,7 +49,33 @@ const notificationTypes = [
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('wechat')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">微信</div>
|
||||
<div class="text-h6">企业微信</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.notification.type === 'wechatclawbot' ? 'primary' : 'default'"
|
||||
:variant="wizardData.notification.type === 'wechatclawbot' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectNotification('wechatclawbot')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('wechatclawbot')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">WeChat ClawBot</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3">
|
||||
<VCard
|
||||
:color="wizardData.notification.type === 'feishu' ? 'primary' : 'default'"
|
||||
:variant="wizardData.notification.type === 'feishu' ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer"
|
||||
@click="selectNotification('feishu')"
|
||||
>
|
||||
<VCardText class="text-center">
|
||||
<VImg :src="getLogoUrl('feishu')" height="48" width="48" class="mx-auto mb-2" />
|
||||
<div class="text-h6">飞书</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -251,6 +277,137 @@ const notificationTypes = [
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'wechatclawbot'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
:error="validationErrors.notification.name"
|
||||
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_BASE_URL"
|
||||
:label="t('notification.wechatclawbot.baseUrl')"
|
||||
:hint="t('notification.wechatclawbot.baseUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_DEFAULT_TARGET"
|
||||
:label="t('notification.wechatclawbot.defaultTarget')"
|
||||
:placeholder="t('notification.wechatclawbot.defaultTargetPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.defaultTargetHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-arrow-right"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.WECHATCLAWBOT_ADMINS"
|
||||
:label="t('notification.wechatclawbot.admins')"
|
||||
:placeholder="t('notification.wechatclawbot.adminsPlaceholder')"
|
||||
:hint="t('notification.wechatclawbot.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'feishu'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.name"
|
||||
:label="t('notification.name')"
|
||||
:placeholder="t('notification.name')"
|
||||
:hint="t('notification.nameHint')"
|
||||
:error="validationErrors.notification.name"
|
||||
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_APP_ID"
|
||||
:label="t('notification.feishu.appId')"
|
||||
:hint="t('notification.feishu.appIdHint')"
|
||||
:error="validationErrors.notification.FEISHU_APP_ID"
|
||||
:error-messages="validationErrors.notification.FEISHU_APP_ID ? [t('notification.feishu.appIdRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_APP_SECRET"
|
||||
:label="t('notification.feishu.appSecret')"
|
||||
:hint="t('notification.feishu.appSecretHint')"
|
||||
:error="validationErrors.notification.FEISHU_APP_SECRET"
|
||||
:error-messages="validationErrors.notification.FEISHU_APP_SECRET ? [t('notification.feishu.appSecretRequired')] : []"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
required
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_OPEN_ID"
|
||||
:label="t('notification.feishu.openId')"
|
||||
:placeholder="t('notification.feishu.openIdPlaceholder')"
|
||||
:hint="t('notification.feishu.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_CHAT_ID"
|
||||
:label="t('notification.feishu.chatId')"
|
||||
:placeholder="t('notification.feishu.chatIdPlaceholder')"
|
||||
:hint="t('notification.feishu.chatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_ADMINS"
|
||||
:label="t('notification.feishu.admins')"
|
||||
:placeholder="t('notification.feishu.adminsPlaceholder')"
|
||||
:hint="t('notification.feishu.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_VERIFICATION_TOKEN"
|
||||
:label="t('notification.feishu.verificationToken')"
|
||||
:hint="t('notification.feishu.verificationTokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="wizardData.notification.config.FEISHU_ENCRYPT_KEY"
|
||||
:label="t('notification.feishu.encryptKey')"
|
||||
:hint="t('notification.feishu.encryptKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="wizardData.notification.type === 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -7,7 +7,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
|
||||
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
@@ -22,9 +22,7 @@ const $toast = useToast()
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
|
||||
// APP
|
||||
const display = useDisplay()
|
||||
// PWA模式检测
|
||||
// APP 模式检测
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 站点列表
|
||||
@@ -50,6 +48,7 @@ const siteStatsDialog = ref(false)
|
||||
|
||||
// 导入站点对话框
|
||||
const siteImportDialog = ref(false)
|
||||
const sortMode = ref(false)
|
||||
|
||||
// 筛选相关
|
||||
const filterMenu = ref(false)
|
||||
@@ -96,6 +95,21 @@ const draggableSiteList = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const siteUserDataMap = computed<Record<string, SiteUserData | undefined>>(() => {
|
||||
const map: Record<string, SiteUserData | undefined> = {}
|
||||
|
||||
userDataList.value.forEach(userData => {
|
||||
if (userData.domain) {
|
||||
map[userData.domain] = userData
|
||||
}
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const canDragSort = computed(() => sortMode.value && filterOption.value === 'all')
|
||||
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||
|
||||
// 当前筛选选项的显示信息
|
||||
const currentFilter = computed(() => {
|
||||
return filterOptions.value.find(option => option.value === filterOption.value)
|
||||
@@ -106,12 +120,13 @@ async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
siteList.value = await api.get('site/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
// 获取站点列表后,获取统计数据
|
||||
await fetchSiteStats()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,16 +197,6 @@ async function savaSitesPriority() {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据站点ID获取站点数据
|
||||
function getUserData(domain: string) {
|
||||
return userDataList.value.find(userData => userData.domain === domain)
|
||||
}
|
||||
|
||||
// 根据站点域名获取统计数据
|
||||
function getSiteStats(domain: string) {
|
||||
return siteStatsList.value[domain] || {}
|
||||
}
|
||||
|
||||
// 处理站点统计数据刷新请求
|
||||
async function handleRefreshStats(domain?: string) {
|
||||
if (domain) {
|
||||
@@ -220,6 +225,22 @@ function selectFilter(value: string) {
|
||||
filterMenu.value = false
|
||||
}
|
||||
|
||||
function toggleSortMode() {
|
||||
sortMode.value = !sortMode.value
|
||||
}
|
||||
|
||||
function openSiteAddDialog() {
|
||||
siteAddDialog.value = true
|
||||
}
|
||||
|
||||
function openSiteImportDialog() {
|
||||
siteImportDialog.value = true
|
||||
}
|
||||
|
||||
function openSiteStatisticsDialog() {
|
||||
siteStatsDialog.value = true
|
||||
}
|
||||
|
||||
// 导出站点数据
|
||||
async function exportSites() {
|
||||
try {
|
||||
@@ -284,60 +305,65 @@ onActivated(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filterOption.value,
|
||||
value => {
|
||||
if (value !== 'all' && sortMode.value) {
|
||||
sortMode.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value)
|
||||
|
||||
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
|
||||
const siteDynamicMenuItems = computed(() => [
|
||||
{
|
||||
titleKey: 'site.actions.add',
|
||||
icon: 'mdi-web-plus',
|
||||
action: openSiteAddDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.import',
|
||||
icon: 'mdi-import',
|
||||
action: openSiteImportDialog,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.actions.export',
|
||||
icon: 'mdi-export',
|
||||
action: exportSites,
|
||||
},
|
||||
{
|
||||
titleKey: 'site.statistics',
|
||||
icon: 'mdi-chart-line',
|
||||
action: openSiteStatisticsDialog,
|
||||
},
|
||||
])
|
||||
|
||||
// 使用动态按钮钩子
|
||||
useDynamicButton({
|
||||
icon: 'mdi-web-plus',
|
||||
onClick: () => {
|
||||
siteAddDialog.value = true
|
||||
},
|
||||
onClick: openSiteAddDialog,
|
||||
menuItems: siteDynamicMenuItems,
|
||||
show: computed(() => appMode.value && shouldShowFloatingActions.value),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-list-container">
|
||||
<!-- 页面标题和筛选按钮 -->
|
||||
<!-- 页面标题和筛选/排序按钮 -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" class="mb-0" />
|
||||
<!-- 右侧按钮组 -->
|
||||
<div class="d-flex align-center gap-2">
|
||||
<!-- 导入按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="success" @click="siteImportDialog = true">
|
||||
<VIcon icon="mdi-import" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.actions.import') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<!-- 导出按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="warning" @click="exportSites">
|
||||
<VIcon icon="mdi-export" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.actions.export') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<!-- 统计信息按钮 -->
|
||||
<VBtn :icon="display.smAndDown.value" variant="text" color="info" @click="siteStatsDialog = true">
|
||||
<VIcon icon="mdi-chart-line" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ t('site.statistics') }}
|
||||
</span>
|
||||
</VBtn>
|
||||
<VPageContentTitle :title="t('navItems.siteManager')" class="my-0" style="margin-block: 0" />
|
||||
<!-- 右侧按钮组:保留筛选和排序,其他页面动作移到 FAB -->
|
||||
<div class="d-flex align-center gap-1">
|
||||
<!-- 筛选按钮 -->
|
||||
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props }">
|
||||
<VBtn
|
||||
v-bind="props"
|
||||
:icon="display.smAndDown.value"
|
||||
:variant="filterOption === 'all' ? 'text' : 'tonal'"
|
||||
:color="currentFilter?.color"
|
||||
>
|
||||
<IconBtn v-bind="props" :variant="filterOption === 'all' ? 'text' : 'tonal'" :color="currentFilter?.color">
|
||||
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
|
||||
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||
{{ currentFilter?.label }}
|
||||
</span>
|
||||
<VIcon v-if="!display.smAndDown.value" icon="mdi-chevron-down" class="ml-1" />
|
||||
</VBtn>
|
||||
</IconBtn>
|
||||
</template>
|
||||
|
||||
<!-- 筛选菜单 -->
|
||||
<VCard min-width="200">
|
||||
<VList class="px-2">
|
||||
@@ -359,31 +385,62 @@ useDynamicButton({
|
||||
</VList>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
<!-- 排序按钮 -->
|
||||
<IconBtn variant="text" :color="sortMode ? 'warning' : 'gray'" @click="toggleSortMode">
|
||||
<VIcon icon="mdi-sort-variant" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<draggable
|
||||
v-if="draggableSiteList.length > 0"
|
||||
v-if="draggableSiteList.length > 0 && canDragSort"
|
||||
v-model="draggableSiteList"
|
||||
@end="savaSitesPriority"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
|
||||
:disabled="filterOption !== 'all'"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SiteCard
|
||||
:site="element"
|
||||
:data="getUserData(element.domain)"
|
||||
:stats="getSiteStats(element.domain)"
|
||||
:data="siteUserDataMap[element.domain]"
|
||||
:stats="siteStatsList[element.domain] || {}"
|
||||
:sortable="true"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
||||
:items="draggableSiteList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="256"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SiteCard
|
||||
:site="item"
|
||||
:data="siteUserDataMap[item.domain]"
|
||||
:stats="siteStatsList[item.domain] || {}"
|
||||
:sortable="false"
|
||||
@remove="fetchData"
|
||||
@update="fetchData"
|
||||
@refresh-stats="handleRefreshStats"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
<NoDataFound
|
||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||
@@ -393,13 +450,37 @@ useDynamicButton({
|
||||
/>
|
||||
<!-- 新增站点按钮 -->
|
||||
<Teleport to="body" v-if="route.path === '/site'">
|
||||
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
|
||||
<div v-if="shouldShowFloatingActions && !appMode" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-chart-line"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="openSiteStatisticsDialog"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-export"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="exportSites"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-import"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="openSiteImportDialog"
|
||||
/>
|
||||
<VFab
|
||||
icon="mdi-web-plus"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="siteAddDialog = true"
|
||||
@click="openSiteAddDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Subscribe } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -32,8 +33,16 @@ const props = defineProps({
|
||||
subid: String,
|
||||
keyword: String,
|
||||
statusFilter: String,
|
||||
sortMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sortMode': [value: boolean]
|
||||
}>()
|
||||
|
||||
// 是否刷新过
|
||||
let isRefreshed = ref(false)
|
||||
|
||||
@@ -56,6 +65,27 @@ const displayList = ref<Subscribe[]>([])
|
||||
const isBatchMode = ref(false)
|
||||
const selectedSubscribes = ref<number[]>([])
|
||||
|
||||
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
|
||||
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
|
||||
const canSortContext = computed(
|
||||
() => !normalizedKeyword.value && (!props.statusFilter || props.statusFilter === 'all') && !isBatchMode.value,
|
||||
)
|
||||
const sortMode = computed({
|
||||
get: () => props.sortMode,
|
||||
set: value => emit('update:sortMode', value),
|
||||
})
|
||||
const canDragSort = computed(() => sortMode.value && canSortContext.value)
|
||||
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||
const scrollToIndex = computed(() => {
|
||||
if (!props.subid || sortMode.value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const targetIndex = displayList.value.findIndex(item => item.id.toString() === props.subid?.toString())
|
||||
|
||||
return targetIndex >= 0 ? targetIndex : undefined
|
||||
})
|
||||
|
||||
// 根据订阅数据判断订阅状态
|
||||
function getSubscribeStatus(subscribe: Subscribe) {
|
||||
// 洗版中
|
||||
@@ -95,26 +125,52 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
||||
// API请求键值(计算属性)
|
||||
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
|
||||
|
||||
// 监听dataList变化,同步更新displayList
|
||||
watch([dataList, () => props.keyword, () => props.statusFilter], () => {
|
||||
if (superUser)
|
||||
displayList.value = dataList.value.filter(
|
||||
data =>
|
||||
data.type === props.type &&
|
||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
||||
)
|
||||
else
|
||||
displayList.value = dataList.value.filter(
|
||||
data =>
|
||||
data.type === props.type &&
|
||||
data.username === userName &&
|
||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
||||
)
|
||||
// 排序
|
||||
sortSubscribeOrder()
|
||||
})
|
||||
// 监听数据和筛选变化,同步更新显示列表
|
||||
watch(
|
||||
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig],
|
||||
() => {
|
||||
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
|
||||
const nextDisplayList = dataList.value.filter(data => {
|
||||
if (data.type !== props.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!superUser && data.username !== userName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (normalizedKeyword.value && !data.name?.toLowerCase().includes(normalizedKeyword.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (props.statusFilter && props.statusFilter !== 'all' && getSubscribeStatus(data) !== props.statusFilter) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
nextDisplayList.sort((a, b) => {
|
||||
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aIndex - bIndex
|
||||
})
|
||||
|
||||
displayList.value = nextDisplayList
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
canSortContext,
|
||||
canSort => {
|
||||
if (!canSort && sortMode.value) {
|
||||
sortMode.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 加载顺序
|
||||
async function loadSubscribeOrderConfig() {
|
||||
@@ -129,22 +185,6 @@ async function loadSubscribeOrderConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// 按order的顺序排序
|
||||
async function sortSubscribeOrder() {
|
||||
if (!orderConfig.value) {
|
||||
return
|
||||
}
|
||||
if (displayList.value.length === 0) {
|
||||
return
|
||||
}
|
||||
await loadSubscribeOrderConfig()
|
||||
displayList.value.sort((a, b) => {
|
||||
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
|
||||
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
|
||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
||||
})
|
||||
}
|
||||
|
||||
// 保存顺序设置
|
||||
async function saveSubscribeOrder() {
|
||||
// 顺序配置
|
||||
@@ -164,10 +204,11 @@ async function fetchData() {
|
||||
try {
|
||||
loading.value = true
|
||||
dataList.value = await api.get('subscribe/')
|
||||
loading.value = false
|
||||
isRefreshed.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +393,7 @@ const errorTitle = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSubscribeOrderConfig()
|
||||
await fetchData()
|
||||
if (props.subid) {
|
||||
// 找到这个订阅
|
||||
@@ -441,28 +483,57 @@ defineExpose({
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
|
||||
<span>{{ t('common.sortModeHint') }}</span>
|
||||
<VBtn variant="tonal" color="error" @click="sortMode = false">
|
||||
{{ t('common.exit') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<draggable
|
||||
v-if="displayList.length > 0"
|
||||
v-if="displayList.length > 0 && canDragSort"
|
||||
v-model="displayList"
|
||||
@end="saveSubscribeOrder"
|
||||
handle=".cursor-move"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
:component-data="{ class: 'grid gap-4 grid-subscribe-card px-2' }"
|
||||
:disabled="props.keyword || (props.statusFilter && props.statusFilter !== 'all') || isBatchMode"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<SubscribeCard
|
||||
:key="element.id"
|
||||
:media="element"
|
||||
:batch-mode="isBatchMode"
|
||||
:selected="selectedSubscribes.includes(element.id)"
|
||||
:selected="selectedSubscribesSet.has(element.id)"
|
||||
:sortable="true"
|
||||
@remove="fetchData"
|
||||
@save="fetchData"
|
||||
@select="toggleSelectSubscribe(element.id)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
<ProgressiveCardGrid
|
||||
v-else-if="displayList.length > 0 && shouldVirtualizeList"
|
||||
:items="displayList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="240"
|
||||
:scroll-to-index="scrollToIndex"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SubscribeCard
|
||||
:key="item.id"
|
||||
:media="item"
|
||||
:batch-mode="isBatchMode"
|
||||
:selected="selectedSubscribesSet.has(item.id)"
|
||||
:sortable="false"
|
||||
@remove="fetchData"
|
||||
@save="fetchData"
|
||||
@select="toggleSelectSubscribe(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="displayList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -274,15 +275,24 @@ async function fetchData({ done }: { done: any }) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.tmdb_id || data.douban_id">
|
||||
<MediaCard :media="data" />
|
||||
<div v-if="data.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
||||
<VIcon icon="mdi-fire" color="error" />
|
||||
<span> {{ data.popularity.toLocaleString() }}</span>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||
:min-item-width="144"
|
||||
:estimated-item-height="320"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div>
|
||||
<MediaCard :media="item" />
|
||||
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
||||
<VIcon icon="mdi-fire" color="error" />
|
||||
<span> {{ item.popularity.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -294,11 +295,18 @@ function removeData(id: number) {
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.id">
|
||||
<SubscribeShareCard :media="data" @delete="removeData(data.id || 0)" />
|
||||
</div>
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="260"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,10 @@ const messages = ref<Message[]>([])
|
||||
// 当前页数据
|
||||
const currData = ref<Message[]>([])
|
||||
|
||||
// 已加载消息的签名集合
|
||||
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
|
||||
const messageKeys = new Set<string>()
|
||||
|
||||
// 是否完成加载
|
||||
const isLoaded = ref(false)
|
||||
|
||||
@@ -29,18 +33,72 @@ const page = ref(1)
|
||||
// 存量消息最新时间
|
||||
const lastTime = ref('')
|
||||
|
||||
// 获取消息时间
|
||||
function getMessageTime(message: Message) {
|
||||
return message.reg_time || message.date || ''
|
||||
}
|
||||
|
||||
// 生成消息签名
|
||||
function getMessageKey(message: Message) {
|
||||
return [
|
||||
message.action ?? '',
|
||||
message.userid ?? '',
|
||||
message.reg_time ?? '',
|
||||
message.date ?? '',
|
||||
message.title ?? '',
|
||||
message.text ?? '',
|
||||
message.image ?? '',
|
||||
message.link ?? '',
|
||||
message.note ?? '',
|
||||
].join('::')
|
||||
}
|
||||
|
||||
// 排序消息列表,确保最新消息始终位于底部
|
||||
function sortMessages(items: Message[]) {
|
||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
||||
}
|
||||
|
||||
// 记录最新消息时间
|
||||
function updateLastTime(message: Message) {
|
||||
const messageTime = getMessageTime(message)
|
||||
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
||||
lastTime.value = messageTime
|
||||
}
|
||||
}
|
||||
|
||||
// 合并消息到当前列表
|
||||
function mergeMessages(items: Message[]) {
|
||||
let hasNewMessage = false
|
||||
|
||||
for (const item of sortMessages(items)) {
|
||||
const messageKey = getMessageKey(item)
|
||||
if (messageKeys.has(messageKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
messageKeys.add(messageKey)
|
||||
messages.value.push(item)
|
||||
updateLastTime(item)
|
||||
hasNewMessage = true
|
||||
}
|
||||
|
||||
if (hasNewMessage) {
|
||||
messages.value = sortMessages(messages.value)
|
||||
}
|
||||
|
||||
return hasNewMessage
|
||||
}
|
||||
|
||||
// SSE消息处理函数
|
||||
function handleSSEMessage(event: MessageEvent) {
|
||||
const message = event.data
|
||||
if (message) {
|
||||
const object = JSON.parse(message)
|
||||
// 使用reg_time或date字段进行比较
|
||||
const messageTime = object.reg_time || object.date
|
||||
if (compareTime(messageTime, lastTime.value) <= 0) return
|
||||
messages.value.push(object)
|
||||
nextTick(() => {
|
||||
emit('scroll') // 新消息到达时触发智能滚动
|
||||
})
|
||||
if (mergeMessages([object])) {
|
||||
nextTick(() => {
|
||||
emit('scroll') // 新消息到达时触发智能滚动
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,28 +133,10 @@ async function loadMessages({ done }: { done: any }) {
|
||||
// 已加载过
|
||||
isLoaded.value = true
|
||||
if (currData.value.length > 0) {
|
||||
// 按时间排序,确保最新的消息在最后
|
||||
currData.value.sort((a, b) => {
|
||||
const timeA = a.reg_time || a.date || ''
|
||||
const timeB = b.reg_time || b.date || ''
|
||||
return compareTime(timeA, timeB)
|
||||
})
|
||||
|
||||
// 取最后一条时间为存量消息最新时间
|
||||
const lastMessage = currData.value[currData.value.length - 1]
|
||||
lastTime.value = lastMessage.reg_time || lastMessage.date || ''
|
||||
|
||||
// 合并数据并重新排序
|
||||
const allMessages = [...currData.value, ...messages.value]
|
||||
allMessages.sort((a, b) => {
|
||||
const timeA = a.reg_time || a.date || ''
|
||||
const timeB = b.reg_time || b.date || ''
|
||||
return compareTime(timeA, timeB)
|
||||
})
|
||||
messages.value = allMessages
|
||||
const hasNewMessage = mergeMessages(currData.value)
|
||||
|
||||
// 首次加载时滚动到底部
|
||||
if (page.value === 1) {
|
||||
if (page.value === 1 && hasNewMessage) {
|
||||
nextTick(() => {
|
||||
emit('scroll')
|
||||
})
|
||||
@@ -109,12 +149,31 @@ async function loadMessages({ done }: { done: any }) {
|
||||
// 没有新数据
|
||||
done('empty')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
} catch (error) {
|
||||
console.error('加载消息失败:', error)
|
||||
loading.value = false
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 主动刷新最新一页消息,作为SSE偶发丢流时的兜底
|
||||
async function refreshLatestMessages() {
|
||||
try {
|
||||
const latestMessages = (await api.get('message/web', {
|
||||
params: {
|
||||
page: 1,
|
||||
size: 20,
|
||||
},
|
||||
})) as Message[]
|
||||
|
||||
if (mergeMessages(latestMessages)) {
|
||||
nextTick(() => {
|
||||
emit('scroll')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新最新消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,14 +219,19 @@ function pauseSSE() {
|
||||
// 恢复SSE连接
|
||||
function resumeSSE() {
|
||||
if (manager) {
|
||||
// 先移除再重建监听,确保恢复时拿到一条新的SSE连接。
|
||||
manager.removeMessageListener('message-view')
|
||||
manager.addMessageListener('message-view', handleSSEMessage)
|
||||
}
|
||||
|
||||
refreshLatestMessages()
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
pauseSSE,
|
||||
resumeSSE,
|
||||
refreshLatestMessages,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -191,17 +255,19 @@ onMounted(() => {
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||
<div>
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<div class="d-inline-flex flex-column" :class="msg.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="msg" @imageload="handleImageLoad" />
|
||||
<VVirtualScroll renderless :items="messages" :item-height="160">
|
||||
<template #default="{ item, index, itemRef }">
|
||||
<div
|
||||
:ref="itemRef"
|
||||
:key="getMessageKey(item) || index"
|
||||
class="chat-group d-flex mt-5 mb-8"
|
||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||
>
|
||||
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</template>
|
||||
|
||||
@@ -436,6 +436,24 @@ watch(
|
||||
prepend-inner-icon="mdi-wechat"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.wechatclawbot_userid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('profile.wechatClawBotUser')"
|
||||
prepend-inner-icon="mdi-robot-happy-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.feishu_openid"
|
||||
density="comfortable"
|
||||
clearable
|
||||
:label="t('profile.feishuUser')"
|
||||
prepend-inner-icon="mdi-message-badge-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="accountInfo.settings.telegram_userid"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Workflow } from '@/api/types'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -66,9 +67,18 @@ defineExpose({
|
||||
<template>
|
||||
<div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<div v-if="workflowList.length > 0 && isRefreshed" class="grid gap-4 grid-workflow-card px-2">
|
||||
<WorkflowTaskCard v-for="item in workflowList" :key="item.id" :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
||||
</div>
|
||||
<ProgressiveCardGrid
|
||||
v-if="workflowList.length > 0 && isRefreshed"
|
||||
:items="workflowList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="288"
|
||||
:estimated-item-height="420"
|
||||
class="px-2"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="workflowList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
@@ -3,6 +3,7 @@ import api from '@/api'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -156,16 +157,23 @@ onActivated(() => {
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">
|
||||
<div v-for="data in dataList" :key="data.id">
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
:get-item-key="item => item.id"
|
||||
:min-item-width="288"
|
||||
:estimated-item-height="220"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<WorkflowShareCard
|
||||
:workflow="data"
|
||||
:workflow="item"
|
||||
:event-types="eventTypes"
|
||||
@delete="removeData(data.id || '')"
|
||||
@delete="removeData(item.id || '')"
|
||||
@update="emit('update')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
<NoDataFound
|
||||
v-if="dataList.length === 0 && isRefreshed"
|
||||
error-code="404"
|
||||
|
||||
27
yarn.lock
27
yarn.lock
@@ -1173,6 +1173,13 @@
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify-json/tabler@^1.2.23":
|
||||
version "1.2.34"
|
||||
resolved "https://registry.yarnpkg.com/@iconify-json/tabler/-/tabler-1.2.34.tgz#5c61bf336911c289aaaf218e0c6b78a34a27bc88"
|
||||
integrity sha512-WSlE5QrptidM57sCnXkpxZKcrk+oue6OlSJD5+gw8rIjuovOeNlejL/zABBM5kASsxLjoSy738Q8hmKrVzODuA==
|
||||
dependencies:
|
||||
"@iconify/types" "*"
|
||||
|
||||
"@iconify/tools@^4.0.4":
|
||||
version "4.1.2"
|
||||
resolved "https://registry.npmjs.org/@iconify/tools/-/tools-4.1.2.tgz"
|
||||
@@ -6995,7 +7002,16 @@ std-env@^3.9.0:
|
||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -7080,7 +7096,14 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
||||
Reference in New Issue
Block a user