mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-11 01:50:10 +08:00
Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
de84c39d2f | ||
|
|
65152e7e37 | ||
|
|
ba343ce5fa | ||
|
|
60495668a6 | ||
|
|
f2ac624dbb | ||
|
|
6238849d3f | ||
|
|
82cb903c1f | ||
|
|
5e5eb95b55 | ||
|
|
74e6f8b03e | ||
|
|
a2bf0d2b16 | ||
|
|
7532d39978 | ||
|
|
5cc9bf7418 | ||
|
|
20bdb940cd | ||
|
|
e9b214cff8 | ||
|
|
54f5fb2877 | ||
|
|
e86cb9e1cc | ||
|
|
3f258b9016 | ||
|
|
b54e144d0e | ||
|
|
7b20a7b775 | ||
|
|
df66b3e917 | ||
|
|
a919622d08 | ||
|
|
2a9ce950b7 | ||
|
|
48c12b765d | ||
|
|
1120055eed | ||
|
|
c66b6649e2 | ||
|
|
8479099926 | ||
|
|
cab65be1c9 | ||
|
|
6689e976c2 | ||
|
|
712dfa3fe1 | ||
|
|
346121f3c2 | ||
|
|
61c073ad6c | ||
|
|
4b3733bc19 | ||
|
|
b29c6bd83f | ||
|
|
b40fc4bd30 | ||
|
|
a225ba6075 | ||
|
|
303fe39c01 | ||
|
|
d343cbcf71 | ||
|
|
0eef8c5174 | ||
|
|
46fe257585 | ||
|
|
f69a57863e | ||
|
|
8876aadcfa | ||
|
|
485e9691a0 | ||
|
|
a0e7283ae6 | ||
|
|
b44c0647f1 | ||
|
|
7e60ab9064 | ||
|
|
f05c1f42b5 | ||
|
|
672bbb4265 | ||
|
|
10c1041b06 | ||
|
|
59c73facfe | ||
|
|
ba7d4cd392 | ||
|
|
d76a50c216 | ||
|
|
617223777b | ||
|
|
6ef047050d | ||
|
|
942ecc4c04 | ||
|
|
e72f9a8374 | ||
|
|
9cf782eb5b | ||
|
|
660338688a | ||
|
|
2d50bd7536 | ||
|
|
b02a4f1347 | ||
|
|
1748fdea34 | ||
|
|
6bbaf43671 | ||
|
|
4a66aaadad | ||
|
|
e2e239f6d9 | ||
|
|
fe22403e66 | ||
|
|
3313c71805 | ||
|
|
1e60e83514 | ||
|
|
9c893abcdf | ||
|
|
ead891ca2f | ||
|
|
8713e3cc86 | ||
|
|
3cc83d10d3 | ||
|
|
192ded374a | ||
|
|
13997c7e74 | ||
|
|
71b0dd4cc2 | ||
|
|
a58a0cdffe | ||
|
|
6aeb040db4 | ||
|
|
fef20e361e | ||
|
|
a63a07701d | ||
|
|
5dd56f2db3 | ||
|
|
275b095574 | ||
|
|
05eae71fba | ||
|
|
777b3c9445 | ||
|
|
a214168b1e | ||
|
|
9d55d02557 | ||
|
|
16c084ba80 | ||
|
|
b0f4ccc186 | ||
|
|
96d0606b4d | ||
|
|
450b9ec28a | ||
|
|
2ccf03fc1b | ||
|
|
38dfb3af07 | ||
|
|
ae4c59bfdb | ||
|
|
c9f4fdbee8 | ||
|
|
d21f461dda | ||
|
|
28a5a83315 | ||
|
|
11d11b88bf | ||
|
|
ff7658b5ba | ||
|
|
351faf2891 | ||
|
|
7d66229bad | ||
|
|
2b08be1e7d | ||
|
|
8255cfd479 | ||
|
|
f356bb4407 | ||
|
|
07e60291a2 | ||
|
|
2dbe8e6685 | ||
|
|
40f36b2afd | ||
|
|
d4260d5103 | ||
|
|
45f68bc936 | ||
|
|
9469074837 | ||
|
|
193807bb6f | ||
|
|
d4548db5b9 | ||
|
|
29aaea6fe6 | ||
|
|
369cc6438f | ||
|
|
d80b39c77b | ||
|
|
626725a8ca | ||
|
|
8be96358ae | ||
|
|
f2bfbfa3c5 | ||
|
|
7c9ffd6abc | ||
|
|
b370354287 | ||
|
|
145d71e283 | ||
|
|
eeea82d815 | ||
|
|
babd267bc4 | ||
|
|
e136c931ac | ||
|
|
ae00602345 | ||
|
|
5382108ee7 | ||
|
|
514063d3fb | ||
|
|
b08f396fec | ||
|
|
d37a7f06f1 | ||
|
|
ad7bca3aae | ||
|
|
4fb70ba80e | ||
|
|
1225b2eb9e | ||
|
|
24b2f103b9 | ||
|
|
0d304b58ca | ||
|
|
f419dbd794 | ||
|
|
7854cc81a8 | ||
|
|
9ad1bd29bd | ||
|
|
b88d4f0ecb | ||
|
|
44168b62d2 | ||
|
|
1dab013436 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
*.local
|
||||
package-lock.json
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
@@ -16,13 +16,17 @@ MoviePilot前端采用模块联邦(Module Federation)技术实现插件的动态
|
||||
|
||||
## 3. 核心概念
|
||||
|
||||
每个插件需要提供三个标准组件:
|
||||
每个 Vue 联邦插件需要提供下列标准组件(`AppPage` 为可选,用于主界面侧栏全页入口):
|
||||
|
||||
| 组件名称 | 文件名 | 用途 |
|
||||
|---------|-------|------|
|
||||
| Page | Page.vue | 插件详情页面 |
|
||||
| Config | Config.vue | 插件配置页面 |
|
||||
| Dashboard | Dashboard.vue | 仪表板组件 |
|
||||
| 组件名称 | 暴露名 | 文件名 | 用途 |
|
||||
|---------|--------|--------|------|
|
||||
| Page | `./Page` | Page.vue | 插件管理中的详情弹窗 |
|
||||
| Config | `./Config` | Config.vue | 插件配置页面 |
|
||||
| Dashboard | `./Dashboard` | Dashboard.vue | 仪表盘小组件 |
|
||||
| AppPage | `./AppPage` | AppPage.vue | 主界面侧栏独立全页(主内容区由插件完全绘制) |
|
||||
| (可选) | `./AppPage{Xxx}` | 如 AppPageSettings.vue | 多 `nav_key` 时按名优先加载,见下文「多界面」 |
|
||||
|
||||
主应用在侧栏全页路由中按 `nav_key` 解析暴露名(如 `AppPageSettings`),再回退 `AppPage` → `Page`;`nav_key` 为 `main` 时仅尝试 `AppPage` → `Page`。
|
||||
|
||||
## 4. 快速开始
|
||||
|
||||
@@ -56,6 +60,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
@@ -264,6 +270,91 @@ const props = defineProps({
|
||||
</template>
|
||||
```
|
||||
|
||||
### 5.4 AppPage 组件(侧栏全页)
|
||||
|
||||
用于主应用左侧导航中的独立页面(路由 `#/plugin-app/:pluginId/:navKey?`),占据默认布局下的主内容区;与 `Page` 不同,不嵌在插件管理弹窗中。
|
||||
|
||||
主应用传入的 props:
|
||||
|
||||
| 属性 | 说明 |
|
||||
|------|------|
|
||||
| `api` | 与 `Page` 相同,用于 `bear` 认证的插件 HTTP 调用 |
|
||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||
| `pluginId` | 当前插件 ID |
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-h6 mb-2">侧栏全页示例({{ pluginId }} / {{ navKey }})</div>
|
||||
<v-btn size="small" @click="emit('action')">通知主应用</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### 后端:注册侧栏入口
|
||||
|
||||
插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `nav_key` | URL 路径段,唯一标识本入口(同一插件可多入口) |
|
||||
| `title` | 侧栏显示标题 |
|
||||
| `icon` | MDI 图标名,如 `mdi-rss` |
|
||||
| `section` | 分组:`start` / `discovery` / `subscribe` / `organize` / `system` |
|
||||
| `permission` | 可选:`subscribe` / `discovery` / `search` / `manage` / `admin`,与主应用菜单权限一致 |
|
||||
| `order` | 可选:同组内排序,数值越小越靠前 |
|
||||
|
||||
```python
|
||||
def get_sidebar_nav(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"nav_key": "main",
|
||||
"title": "示例订阅页",
|
||||
"icon": "mdi-rss",
|
||||
"section": "subscribe",
|
||||
"permission": "subscribe",
|
||||
"order": 10,
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### 同一插件多个全页界面(多 `nav_key`)
|
||||
|
||||
在 `get_sidebar_nav` 中**返回多条**记录,每条使用不同的 `nav_key` / `title` / `section` 等,侧栏与「更多」中会出现多个入口,路由形如 `#/plugin-app/<插件ID>/<nav_key>`。
|
||||
|
||||
前端加载远程组件的顺序为:
|
||||
|
||||
| `nav_key` | 依次尝试的联邦暴露名 |
|
||||
|-----------|----------------------|
|
||||
| `main` 或省略 | `./AppPage` → `./Page` |
|
||||
| 其它(如 `settings`、`my_tool`) | `./AppPage{PascalCase}` → `./AppPage` → `./Page` |
|
||||
|
||||
`PascalCase` 规则:按 `-`、`_`、空格分段后首字母大写并拼接。例如 `nav_key=settings` → 先试 `./AppPageSettings`;`my_tool` → `./AppPageMyTool`。
|
||||
|
||||
**两种实现方式(二选一或混用):**
|
||||
|
||||
1. **单文件分支**:只暴露 `./AppPage`,在组件内根据 `navKey` prop 用 `v-if` / `<component>` 切换子界面。
|
||||
2. **多文件**:为某个入口单独暴露 `./AppPageSettings.vue` 等,主应用会优先加载对应模块,失败再回退到 `AppPage`。
|
||||
|
||||
`vite.config` 多暴露示例:
|
||||
|
||||
```typescript
|
||||
exposes: {
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 构建和部署
|
||||
|
||||
### 构建项目
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MoviePilot 插件远程组件示例
|
||||
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例实现了三个标准组件:Page(详情页面)、Config(配置页面)和Dashboard(仪表板组件)。
|
||||
这是 MoviePilot 插件远程组件的示例项目,展示了如何正确配置和开发与主应用兼容的远程组件。本示例包含 Page、Config、Dashboard、AppPage,以及可选的 `AppPageSettings`(`nav_key=settings` 时由主应用优先加载,用于演示「一插件多全页界面」)。
|
||||
|
||||
## 1. 开发环境准备
|
||||
|
||||
@@ -28,7 +28,9 @@ plugin-component/
|
||||
│ ├── components/
|
||||
│ │ ├── Page.vue # 插件详情页面组件
|
||||
│ │ ├── Config.vue # 插件配置页面组件
|
||||
│ │ └── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── Dashboard.vue # 插件仪表板组件
|
||||
│ │ ├── AppPage.vue # 侧栏全页(主内容区,nav_key=main)
|
||||
│ │ └── AppPageSettings.vue # 可选第二全页(nav_key=settings)
|
||||
│ ├── App.vue # 本地开发入口组件
|
||||
│ └── main.js # 本地开发入口文件
|
||||
├── vite.config.js # Vite和模块联邦配置
|
||||
|
||||
32
examples/plugin-component/src/components/AppPage.vue
Normal file
32
examples/plugin-component/src/components/AppPage.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧栏全页:在主应用 #/plugin-app/:pluginId/:navKey 中渲染,占据主内容区。
|
||||
* 需在插件后端实现 get_sidebar_nav 才会出现在侧栏。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
navKey: {
|
||||
type: String,
|
||||
default: 'main',
|
||||
},
|
||||
pluginId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page pa-4">
|
||||
<div class="text-h6 mb-2">AppPage(侧栏全页)</div>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
pluginId: {{ pluginId }} · navKey: {{ navKey }}
|
||||
</div>
|
||||
<v-btn size="small" variant="tonal" @click="emit('action')">action</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
17
examples/plugin-component/src/components/AppPageSettings.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 示例:nav_key=settings 时主应用会优先加载 AppPageSettings,再回退 AppPage。
|
||||
*/
|
||||
const props = defineProps({
|
||||
api: { type: Object, default: () => ({}) },
|
||||
navKey: { type: String, default: 'settings' },
|
||||
pluginId: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-4">
|
||||
<div class="text-subtitle-1">Settings 子界面(AppPageSettings)</div>
|
||||
<div class="text-caption text-medium-emphasis">navKey={{ navKey }} · pluginId={{ pluginId }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ export default defineConfig({
|
||||
'./Page': './src/components/Page.vue',
|
||||
'./Config': './src/components/Config.vue',
|
||||
'./Dashboard': './src/components/Dashboard.vue',
|
||||
'./AppPage': './src/components/AppPage.vue',
|
||||
'./AppPageSettings': './src/components/AppPageSettings.vue',
|
||||
},
|
||||
shared: {
|
||||
vue: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.9.5",
|
||||
"version": "2.11.0",
|
||||
"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",
|
||||
|
||||
@@ -15,7 +15,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
<VIcon icon="mdi-close" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
50
src/App.vue
50
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()
|
||||
@@ -41,13 +42,6 @@ const isTransparentTheme = computed(() => globalTheme.name.value === 'transparen
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
|
||||
// ApexCharts 全局配置
|
||||
declare global {
|
||||
interface Window {
|
||||
Apex: any
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
@@ -75,44 +69,6 @@ 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)
|
||||
@@ -250,7 +206,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
// 配置 ApexCharts
|
||||
configureApexCharts()
|
||||
configureApexChartsTheme(globalTheme.name.value)
|
||||
|
||||
// 初始化data-theme属性
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
@@ -265,7 +221,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')
|
||||
|
||||
@@ -52,6 +52,10 @@ export const downloaderOptions = [
|
||||
value: 'transmission',
|
||||
title: i18n.global.t('setting.system.transmission'),
|
||||
},
|
||||
{
|
||||
value: 'rtorrent',
|
||||
title: i18n.global.t('setting.system.rtorrent'),
|
||||
},
|
||||
]
|
||||
|
||||
export const downloaderDict = downloaderOptions.reduce((dict, item) => {
|
||||
@@ -76,6 +80,10 @@ export const mediaServerOptions = [
|
||||
value: 'trimemedia',
|
||||
title: i18n.global.t('setting.system.trimeMedia'),
|
||||
},
|
||||
{
|
||||
value: 'ugreen',
|
||||
title: i18n.global.t('setting.system.ugreen'),
|
||||
},
|
||||
]
|
||||
|
||||
export const mediaServerDict = mediaServerOptions.reduce((dict, item) => {
|
||||
@@ -274,6 +282,10 @@ export const notificationSwitchOptions = [
|
||||
title: i18n.global.t('notificationSwitch.plugin'),
|
||||
value: '插件',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.agent'),
|
||||
value: '智能体',
|
||||
},
|
||||
{
|
||||
title: i18n.global.t('notificationSwitch.other'),
|
||||
value: '其它',
|
||||
|
||||
@@ -656,6 +656,17 @@ export interface Plugin {
|
||||
page_open?: boolean
|
||||
}
|
||||
|
||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
nav_key: string
|
||||
title: string
|
||||
icon: string
|
||||
section: 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
|
||||
permission?: 'subscribe' | 'discovery' | 'search' | 'manage' | 'admin' | null
|
||||
order: number
|
||||
}
|
||||
|
||||
// 渲染结构
|
||||
export interface RenderProps {
|
||||
component: string
|
||||
@@ -885,8 +896,8 @@ export interface MediaStatistic {
|
||||
movie_count: number
|
||||
// 电视剧总数
|
||||
tv_count: number
|
||||
// 电视剧总集数
|
||||
episode_count: number
|
||||
// 电视剧总集数,未获取时为 null
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
}
|
||||
@@ -1134,7 +1145,7 @@ export interface StorageConf {
|
||||
export interface MediaServerConf {
|
||||
// 名称
|
||||
name: string
|
||||
// 类型 emby/jellyfin/plex
|
||||
// 类型 emby/jellyfin/plex/trimemedia/ugreen
|
||||
type: string
|
||||
// 配置
|
||||
config: { [key: string]: any }
|
||||
@@ -1445,4 +1456,19 @@ export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
}
|
||||
|
||||
// 分类规则
|
||||
export interface CategoryRule {
|
||||
genre_ids?: string
|
||||
original_language?: string
|
||||
production_countries?: string
|
||||
origin_country?: string
|
||||
release_year?: string
|
||||
}
|
||||
|
||||
// 分类配置
|
||||
export interface CategoryConfig {
|
||||
movie?: { [key: string]: CategoryRule }
|
||||
tv?: { [key: string]: CategoryRule }
|
||||
}
|
||||
|
||||
BIN
src/assets/images/logos/qq.png
Normal file
BIN
src/assets/images/logos/qq.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
src/assets/images/logos/rtorrent.png
Normal file
BIN
src/assets/images/logos/rtorrent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/images/logos/ugreen.png
Normal file
BIN
src/assets/images/logos/ugreen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -5,6 +5,8 @@ import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
// LocalStorage keys
|
||||
const SORT_KEY = 'fileBrowser.sort'
|
||||
@@ -33,6 +35,9 @@ const props = defineProps({
|
||||
|
||||
// 对外事件
|
||||
const emit = defineEmits(['pathchanged'])
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||
|
||||
const fileIcons = {
|
||||
// 压缩包
|
||||
@@ -123,6 +128,18 @@ const fileIcons = {
|
||||
other: 'mdi-file-outline',
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
toolbarRef.value?.openNewFolderDialog()
|
||||
}
|
||||
|
||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-folder-plus-outline',
|
||||
onClick: openNewFolderDialog,
|
||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||
})
|
||||
|
||||
// 加载次数
|
||||
const loading = ref(0)
|
||||
|
||||
@@ -254,12 +271,14 @@ function stopDrag() {
|
||||
<div class="mx-auto" :loading="loading > 0">
|
||||
<div v-if="item">
|
||||
<FileToolbar
|
||||
ref="toolbarRef"
|
||||
:sort="sort"
|
||||
:item="item"
|
||||
:itemstack="itemstack"
|
||||
:storages="storagesArray"
|
||||
:endpoints="endpoints"
|
||||
:axios="axios"
|
||||
:show-new-folder-button="!showFloatingNewFolderAction"
|
||||
@storagechanged="storageChanged"
|
||||
@pathchanged="pathChanged"
|
||||
@foldercreated="refreshPending = true"
|
||||
@@ -301,6 +320,18 @@ function stopDrag() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && showFloatingNewFolderAction">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-folder-plus-outline"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openNewFolderDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerWithAutoDetect } from '@/utils/appDeepLink'
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -10,12 +11,18 @@ const props = defineProps({
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载失败响应
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
async function goPlay() {
|
||||
if (props.media?.link) {
|
||||
@@ -26,6 +33,7 @@ async function goPlay() {
|
||||
// 计算图片地址
|
||||
const getImgUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const use_cookies = props.media?.use_cookies
|
||||
if (use_cookies) {
|
||||
@@ -50,7 +58,7 @@ const getImgUrl = computed(() => {
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler">
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
|
||||
@@ -101,19 +101,21 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openRuleInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
|
||||
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -195,7 +195,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal" :width="props.width" :height="props.height">
|
||||
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VTextField
|
||||
@@ -204,8 +204,8 @@ watch(
|
||||
:label="t('directory.alias')"
|
||||
class="me-20 text-high-emphasis font-weight-bold"
|
||||
/>
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
|
||||
@@ -206,6 +206,8 @@ const getIcon = computed(() => {
|
||||
return getLogoUrl('qbittorrent')
|
||||
case 'transmission':
|
||||
return getLogoUrl('transmission')
|
||||
case 'rtorrent':
|
||||
return getLogoUrl('rtorrent')
|
||||
default:
|
||||
return getLogoUrl('downloader')
|
||||
}
|
||||
@@ -250,18 +252,19 @@ onUnmounted(() => {
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
variant="tonal"
|
||||
class="app-card-shell"
|
||||
@click="openDownloaderInfoDialog"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VCardText class="flex justify-space-between align-center gap-4">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge
|
||||
v-if="props.downloader.default && props.downloader.enabled"
|
||||
dot
|
||||
@@ -269,18 +272,21 @@ onUnmounted(() => {
|
||||
color="success"
|
||||
class="me-1"
|
||||
/>
|
||||
<span class="text-h6">{{ downloader.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
|
||||
</div>
|
||||
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">{{ `↑ ${formatFileSize(upload_rate, 1)}/s ` }}</span>
|
||||
<span>{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
<div
|
||||
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
|
||||
class="app-card-summary__meta text-sm"
|
||||
>
|
||||
<span class="app-card-summary__meta-item">{{ `↑ ${formatFileSize(upload_rate, 1)}/s` }}</span>
|
||||
<span class="app-card-summary__meta-item">{{ `↓ ${formatFileSize(download_rate, 1)}/s` }}</span>
|
||||
</div>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
|
||||
<span class="me-2">自定义下载器</span>
|
||||
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义下载器
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-20">
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -340,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"
|
||||
@@ -356,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"
|
||||
@@ -443,6 +462,51 @@ onUnmounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port/RPC2"
|
||||
:hint="t('downloader.rtorrentHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -45,15 +45,15 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard variant="tonal">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VAutocomplete
|
||||
|
||||
@@ -205,22 +205,24 @@ function onClose() {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="opengroupInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
|
||||
<div class="text-body-1 mb-3">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
|
||||
<div class="app-card-summary__subtitle text-body-1">
|
||||
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
|
||||
<span v-else>{{ props.group.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<VDialog
|
||||
|
||||
@@ -33,6 +33,7 @@ function imageLoadHandler() {
|
||||
// 图片加载错误
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
@@ -41,6 +42,7 @@ function getDefaultImage() {
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
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')
|
||||
else return plex
|
||||
}
|
||||
|
||||
@@ -53,7 +55,7 @@ async function goPlay() {
|
||||
|
||||
// 生成图片代理路径
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url) return getDefaultImage()
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
if (use_cookies) {
|
||||
imgurl += `&use_cookies=${encodeURIComponent(use_cookies)}`
|
||||
@@ -64,7 +66,7 @@ function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
// 根据多张图片生成媒体库封面
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = imageList
|
||||
const IMAGES = [...imageList]
|
||||
if (IMAGES.length === 0) return getDefaultImage()
|
||||
|
||||
// 为所有图片添加system/img前缀
|
||||
|
||||
@@ -14,13 +14,24 @@ 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()
|
||||
|
||||
interface MediaCardMedia extends MediaInfo {
|
||||
total_episode?: number
|
||||
episode_count?: number
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
media: Object as PropType<MediaCardMedia>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
@@ -118,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'
|
||||
@@ -138,7 +165,7 @@ async function handleAddSubscribe() {
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -153,7 +180,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season,
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
@@ -162,6 +189,7 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
@@ -183,8 +211,8 @@ async function addSubscribe(season: number = 0, best_version: number = 0) {
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
|
||||
if (season) title = `${title} ${formatSeason(season.toString())}`
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
@@ -208,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 })}`)
|
||||
@@ -222,8 +251,10 @@ async function removeSubscribe() {
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
const result = await checkSubscribe(props.media?.season)
|
||||
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)
|
||||
}
|
||||
@@ -232,24 +263,29 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season = 0) {
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
@@ -260,12 +296,14 @@ async function checkSubscribe(season = 0) {
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
@@ -300,7 +338,7 @@ function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number, best_version)
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
},
|
||||
])
|
||||
|
||||
const ugreenScanModeOptions = computed(() => [
|
||||
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
|
||||
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
|
||||
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
|
||||
])
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = ref(false)
|
||||
|
||||
@@ -77,6 +83,15 @@ function openMediaServerInfoDialog() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
// 深复制
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
if (mediaServerInfo.value.type === 'ugreen') {
|
||||
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
|
||||
if (!mediaServerInfo.value.config.scan_mode) {
|
||||
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
|
||||
}
|
||||
if (mediaServerInfo.value.config.verify_ssl === undefined) {
|
||||
mediaServerInfo.value.config.verify_ssl = true
|
||||
}
|
||||
}
|
||||
mediaServerInfoDialog.value = true
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
@@ -110,6 +125,8 @@ const getIcon = computed(() => {
|
||||
return getLogoUrl('jellyfin')
|
||||
case 'trimemedia':
|
||||
return getLogoUrl('trimemedia')
|
||||
case 'ugreen':
|
||||
return getLogoUrl('ugreen')
|
||||
case 'plex':
|
||||
return getLogoUrl('plex')
|
||||
default:
|
||||
@@ -182,21 +199,27 @@ onMounted(() => {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openMediaServerInfoDialog">
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start flex-1">
|
||||
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
|
||||
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
|
||||
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
|
||||
<VCardText class="app-card-summary app-card-summary--single-action">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
|
||||
<div
|
||||
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
|
||||
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
|
||||
>
|
||||
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
|
||||
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
|
||||
<span class="truncate">{{ item.amount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
|
||||
<span class="me-2 mb-1">自定义媒体服务器</span>
|
||||
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
|
||||
自定义媒体服务器
|
||||
</div>
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -424,6 +447,95 @@ onMounted(() => {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
|
||||
<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')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
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>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.config.scan_mode"
|
||||
:label="t('mediaserver.scanMode')"
|
||||
:items="ugreenScanModeOptions"
|
||||
:hint="t('mediaserver.scanModeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-radar"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaServerInfo.config.verify_ssl"
|
||||
:label="t('mediaserver.verifySsl')"
|
||||
:hint="t('mediaserver.verifySslHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -24,6 +24,7 @@ const imageLoadError = ref(false)
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
@@ -102,7 +103,7 @@ function renderMarkdown(value: string) {
|
||||
</VCardTitle>
|
||||
<div
|
||||
v-if="props.message?.text && props.message?.action === 0"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
||||
>
|
||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
||||
</div>
|
||||
@@ -155,12 +156,23 @@ function renderMarkdown(value: string) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
margin-block-end: 0.5rem;
|
||||
padding-inline-start: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: list-item;
|
||||
margin-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--v-border-color), 0.1);
|
||||
|
||||
@@ -46,6 +46,7 @@ const notificationInfo = ref<NotificationConf>({
|
||||
const notificationTypeNames: { [key: string]: string } = {
|
||||
wechat: t('notification.wechat.name'),
|
||||
telegram: t('notification.telegram.name'),
|
||||
qqbot: t('notification.qqbot.name'),
|
||||
vocechat: t('notification.vocechat.name'),
|
||||
synologychat: t('notification.synologychat.name'),
|
||||
slack: t('notification.slack.name'),
|
||||
@@ -63,13 +64,43 @@ const notificationTypes = [
|
||||
{ value: '媒体服务器', title: t('notificationSwitch.mediaServer') },
|
||||
{ value: '手动处理', title: t('notificationSwitch.manual') },
|
||||
{ value: '插件', title: t('notificationSwitch.plugin') },
|
||||
{ value: '智能体', title: t('notificationSwitch.agent') },
|
||||
{ value: '其它', title: t('notificationSwitch.other') },
|
||||
]
|
||||
|
||||
function ensureWechatConfigDefaults(notification: NotificationConf) {
|
||||
if (notification.type !== 'wechat') {
|
||||
return
|
||||
}
|
||||
if (!notification.config) {
|
||||
notification.config = {}
|
||||
}
|
||||
if (!notification.config.WECHAT_MODE) {
|
||||
notification.config.WECHAT_MODE = 'app'
|
||||
}
|
||||
if (!notification.config.WECHAT_BOT_WS_URL) {
|
||||
notification.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
}
|
||||
|
||||
const isWechatBotMode = computed({
|
||||
get: () => notificationInfo.value.config?.WECHAT_MODE === 'bot',
|
||||
set: value => {
|
||||
if (!notificationInfo.value.config) {
|
||||
notificationInfo.value.config = {}
|
||||
}
|
||||
notificationInfo.value.config.WECHAT_MODE = value ? 'bot' : 'app'
|
||||
if (value && !notificationInfo.value.config.WECHAT_BOT_WS_URL) {
|
||||
notificationInfo.value.config.WECHAT_BOT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 打开详情弹窗
|
||||
function openNotificationInfoDialog() {
|
||||
// 替换成深复制,避免修改时影响原数据
|
||||
notificationInfo.value = cloneDeep(props.notification)
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = true
|
||||
}
|
||||
|
||||
@@ -85,6 +116,7 @@ function saveNotificationInfo() {
|
||||
$toast.error(t('notification.channel') + `【${notificationInfo.value.name}】` + t('common.exists'))
|
||||
return
|
||||
}
|
||||
ensureWechatConfigDefaults(notificationInfo.value)
|
||||
notificationInfoDialog.value = false
|
||||
emit('change', notificationInfo.value, props.notification.name)
|
||||
emit('done')
|
||||
@@ -97,6 +129,8 @@ const getIcon = computed(() => {
|
||||
return getLogoUrl('wechat')
|
||||
case 'telegram':
|
||||
return getLogoUrl('telegram')
|
||||
case 'qqbot':
|
||||
return getLogoUrl('qq')
|
||||
case 'vocechat':
|
||||
return getLogoUrl('vocechat')
|
||||
case 'synologychat':
|
||||
@@ -119,22 +153,24 @@ function onClose() {
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VCard variant="tonal" @click="openNotificationInfoDialog">
|
||||
<span class="absolute top-3 right-12">
|
||||
<IconBtn>
|
||||
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
|
||||
<span class="app-card-top-action absolute top-3 right-12">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon class="cursor-move" icon="mdi-drag" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<VDialogCloseBtn @click="onClose" />
|
||||
<VCardText class="flex justify-space-between align-center gap-3">
|
||||
<div class="align-self-start">
|
||||
<div class="flex items-center">
|
||||
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
|
||||
<div class="app-card-summary__content">
|
||||
<div class="app-card-summary__title-row">
|
||||
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
|
||||
<span class="text-h6">{{ props.notification.name }}</span>
|
||||
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
|
||||
</div>
|
||||
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
|
||||
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
|
||||
</div>
|
||||
<div class="app-card-summary__media" aria-hidden="true">
|
||||
<VImg :src="getIcon" contain class="app-card-summary__image" />
|
||||
</div>
|
||||
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -187,69 +223,129 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
<VSwitch
|
||||
v-model="isWechatBotMode"
|
||||
:label="t('notification.wechat.useBotMode')"
|
||||
:hint="t('notification.wechat.useBotModeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
<template v-if="isWechatBotMode">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_ID"
|
||||
:label="t('notification.wechat.botId')"
|
||||
:hint="t('notification.wechat.botIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-robot"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_SECRET"
|
||||
:label="t('notification.wechat.botSecret')"
|
||||
:hint="t('notification.wechat.botSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_CHAT_ID"
|
||||
:label="t('notification.wechat.botChatId')"
|
||||
:placeholder="t('notification.wechat.botChatIdPlaceholder')"
|
||||
:hint="t('notification.wechat.botChatIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-chat-processing"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_BOT_WS_URL"
|
||||
:label="t('notification.wechat.botWsUrl')"
|
||||
:hint="t('notification.wechat.botWsUrlHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lan-connect"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_CORPID"
|
||||
:label="t('notification.wechat.corpId')"
|
||||
:hint="t('notification.wechat.corpIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-domain"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_ID"
|
||||
:label="t('notification.wechat.appId')"
|
||||
:hint="t('notification.wechat.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_APP_SECRET"
|
||||
:label="t('notification.wechat.appSecret')"
|
||||
:hint="t('notification.wechat.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_PROXY"
|
||||
:label="t('notification.wechat.proxy')"
|
||||
:hint="t('notification.wechat.proxyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_TOKEN"
|
||||
:label="t('notification.wechat.token')"
|
||||
:hint="t('notification.wechat.tokenHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ENCODING_AESKEY"
|
||||
:label="t('notification.wechat.encodingAesKey')"
|
||||
:hint="t('notification.wechat.encodingAesKeyHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.WECHAT_ADMINS"
|
||||
:label="t('notification.wechat.admins')"
|
||||
:placeholder="t('notification.wechat.adminsPlaceholder')"
|
||||
:hint="t('notification.wechat.adminsHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-supervisor"
|
||||
/>
|
||||
</VCol>
|
||||
</template>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'telegram'">
|
||||
<VCol cols="12" md="6">
|
||||
@@ -464,6 +560,56 @@ function onClose() {
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'qqbot'">
|
||||
<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.QQ_APP_ID"
|
||||
:label="t('notification.qqbot.appId')"
|
||||
:hint="t('notification.qqbot.appIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-application"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_APP_SECRET"
|
||||
:label="t('notification.qqbot.appSecret')"
|
||||
:hint="t('notification.qqbot.appSecretHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_OPENID"
|
||||
:label="t('notification.qqbot.openId')"
|
||||
:placeholder="t('notification.qqbot.openIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.openIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="notificationInfo.config.QQ_GROUP_OPENID"
|
||||
:label="t('notification.qqbot.groupOpenId')"
|
||||
:placeholder="t('notification.qqbot.groupOpenIdPlaceholder')"
|
||||
:hint="t('notification.qqbot.groupOpenIdHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="notificationInfo.type == 'webpush'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
|
||||
@@ -118,6 +118,9 @@ const iconPath: Ref<string> = computed(() => {
|
||||
function visitPluginPage() {
|
||||
// 将raw.githubusercontent.com转换为项目地址
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
@@ -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,31 +226,40 @@ 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>
|
||||
|
||||
<!-- 主体部分 -->
|
||||
<div class="relative flex-1 flex flex-col p-3 z-1">
|
||||
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
|
||||
<!-- 顶部:图标和站点名称 -->
|
||||
<div class="flex items-center mb-1">
|
||||
<div class="mb-1 flex min-w-0 items-center gap-2">
|
||||
<!-- 站点图标 -->
|
||||
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
|
||||
<VAvatar
|
||||
tile
|
||||
rounded="lg"
|
||||
size="32"
|
||||
class="shrink-0"
|
||||
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
@@ -231,22 +270,42 @@ onMounted(() => {
|
||||
</VAvatar>
|
||||
|
||||
<!-- 站点名称和特性图标 -->
|
||||
<div class="flex-1 min-w-0 flex items-center">
|
||||
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
|
||||
|
||||
<!-- 站点特性图标 -->
|
||||
<div class="flex items-center gap-2 ml-auto mr-10">
|
||||
<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 class="ml-auto flex shrink-0 items-center gap-2">
|
||||
<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>
|
||||
@@ -254,10 +313,10 @@ onMounted(() => {
|
||||
|
||||
<!-- 中间部分:网址 -->
|
||||
<div class="my-3">
|
||||
<div class="text-sm text-medium-emphasis truncate" @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">
|
||||
@@ -289,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
|
||||
@@ -412,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"
|
||||
>
|
||||
<!-- 优惠标签 -->
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import api from '@/api'
|
||||
import { clearCachesAndServiceWorker, reloadWithTimestamp } from '@/composables/useVersionChecker'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
@@ -17,6 +19,21 @@ const emit = defineEmits(['close'])
|
||||
// 显示器
|
||||
const display = useDisplay()
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 系统环境变量
|
||||
const systemEnv = ref<any>({})
|
||||
|
||||
@@ -70,7 +87,7 @@ const releaseDialogBody = ref('')
|
||||
// 打开日志对话框
|
||||
function showReleaseDialog(title: string, body: string) {
|
||||
releaseDialogTitle.value = title
|
||||
releaseDialogBody.value = body.replaceAll('\r\n', '<br />')
|
||||
releaseDialogBody.value = body ? md.render(body) : ''
|
||||
releaseDialog.value = true
|
||||
}
|
||||
|
||||
@@ -393,7 +410,7 @@ onMounted(() => {
|
||||
<VDialogCloseBtn @click="releaseDialog = false" />
|
||||
<VCardTitle>{{ releaseDialogTitle }} {{ t('setting.about.changelog') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VCardText v-html="releaseDialogBody" />
|
||||
<VCardText class="markdown-body" v-html="releaseDialogBody" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</VDialog>
|
||||
@@ -411,4 +428,101 @@ onMounted(() => {
|
||||
.section {
|
||||
margin-block: 0.5rem 2.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-block: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding-inline-start: 1rem;
|
||||
margin-block: 0.5rem;
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
|
||||
.markdown-body :deep(hr) {
|
||||
margin-block: 1rem;
|
||||
border: none;
|
||||
border-block-start: 1px solid rgba(127, 127, 127, 0.3);
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
width: 100%;
|
||||
margin-block: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid rgba(127, 127, 127, 0.3);
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
font-weight: 600;
|
||||
background-color: rgba(127, 127, 127, 0.1);
|
||||
}
|
||||
|
||||
.markdown-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
663
src/components/dialog/CategoryEditDialog.vue
Normal file
663
src/components/dialog/CategoryEditDialog.vue
Normal file
@@ -0,0 +1,663 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
import type { CategoryConfig } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 定义输入参数
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['close', 'save'])
|
||||
|
||||
const activeTab = ref('movie')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const generateId = () => {
|
||||
return 'id-' + Math.random().toString(36).substr(2, 9) + '-' + Date.now()
|
||||
}
|
||||
|
||||
interface CategoryItem {
|
||||
id: string
|
||||
name: string
|
||||
rule: any
|
||||
}
|
||||
|
||||
const movieList = ref<CategoryItem[]>([])
|
||||
const tvList = ref<CategoryItem[]>([])
|
||||
|
||||
// TMDB 类型映射
|
||||
const genreOptions = [
|
||||
{ title: '动作 (Action)', value: '28' },
|
||||
{ title: '冒险 (Adventure)', value: '12' },
|
||||
{ title: '动画 (Animation)', value: '16' },
|
||||
{ title: '喜剧 (Comedy)', value: '35' },
|
||||
{ title: '犯罪 (Crime)', value: '80' },
|
||||
{ title: '纪录 (Documentary)', value: '99' },
|
||||
{ title: '剧情 (Drama)', value: '18' },
|
||||
{ title: '家庭 (Family)', value: '10751' },
|
||||
{ title: '奇幻 (Fantasy)', value: '14' },
|
||||
{ title: '历史 (History)', value: '36' },
|
||||
{ title: '恐怖 (Horror)', value: '27' },
|
||||
{ title: '音乐 (Music)', value: '10402' },
|
||||
{ title: '悬疑 (Mystery)', value: '9648' },
|
||||
{ title: '爱情 (Romance)', value: '10749' },
|
||||
{ title: '科幻 (SF)', value: '878' },
|
||||
{ title: '电视电影', value: '10770' },
|
||||
{ title: '惊悚 (Thriller)', value: '53' },
|
||||
{ title: '战争 (War)', value: '10752' },
|
||||
{ title: '西部 (Western)', value: '37' },
|
||||
{ title: '儿童 (Kids)', value: '10762' },
|
||||
{ title: '新闻 (News)', value: '10763' },
|
||||
{ title: '真人秀 (Reality)', value: '10764' },
|
||||
{ title: '科幻/奇幻 (Sci-Fi)', value: '10765' },
|
||||
{ title: '肥皂剧 (Soap)', value: '10766' },
|
||||
{ title: '访谈 (Talk)', value: '10767' },
|
||||
{ title: '战争/政治', value: '10768' },
|
||||
]
|
||||
|
||||
// 语种选项 (original_language)
|
||||
const languageOptions = [
|
||||
{ title: '中文', value: 'zh' },
|
||||
{ title: '中文', value: 'cn' },
|
||||
{ title: '英语 (English)', value: 'en' },
|
||||
{ title: '日语 (Japanese)', value: 'ja' },
|
||||
{ title: '韩语 (Korean)', value: 'ko' },
|
||||
{ title: '法语 (French)', value: 'fr' },
|
||||
{ title: '德语 (German)', value: 'de' },
|
||||
{ title: '西班牙语 (Spanish)', value: 'es' },
|
||||
{ title: '意大利语 (Italian)', value: 'it' },
|
||||
{ title: '葡萄牙语 (Portuguese)', value: 'pt' },
|
||||
{ title: '俄语 (Russian)', value: 'ru' },
|
||||
{ title: '阿拉伯语', value: 'ar' },
|
||||
{ title: '泰语 (Thai)', value: 'th' },
|
||||
{ title: '越南语 (Vietnamese)', value: 'vi' },
|
||||
{ title: '印地语 (Hindi)', value: 'hi' },
|
||||
{ title: '土耳其语 (Turkish)', value: 'tr' },
|
||||
{ title: '荷兰语 (Dutch)', value: 'nl' },
|
||||
{ title: '波兰语 (Polish)', value: 'pl' },
|
||||
{ title: '瑞典语 (Swedish)', value: 'sv' },
|
||||
{ title: '丹麦语 (Danish)', value: 'da' },
|
||||
{ title: '挪威语 (Norwegian)', value: 'nb' },
|
||||
{ title: '芬兰语 (Finnish)', value: 'fi' },
|
||||
{ title: '希腊语 (Greek)', value: 'el' },
|
||||
{ title: '捷克语 (Czech)', value: 'cs' },
|
||||
{ title: '匈牙利语 (Hungarian)', value: 'hu' },
|
||||
{ title: '罗马尼亚语 (Romanian)', value: 'ro' },
|
||||
{ title: '乌克兰语 (Ukrainian)', value: 'uk' },
|
||||
{ title: '印度尼西亚语 (Indonesian)', value: 'id' },
|
||||
{ title: '马来语 (Malay)', value: 'ms' },
|
||||
{ title: '希伯来语 (Hebrew)', value: 'he' },
|
||||
]
|
||||
|
||||
// 国家/地区选项 (origin_country/production_countries)
|
||||
const countryOptions = [
|
||||
{ title: '中国大陆 (CN)', value: 'CN' },
|
||||
{ title: '中国香港 (HK)', value: 'HK' },
|
||||
{ title: '中国台湾 (TW)', value: 'TW' },
|
||||
{ title: '美国 (US)', value: 'US' },
|
||||
{ title: '英国 (GB)', value: 'GB' },
|
||||
{ title: '日本 (JP)', value: 'JP' },
|
||||
{ title: '韩国 (KR)', value: 'KR' },
|
||||
{ title: '法国 (FR)', value: 'FR' },
|
||||
{ title: '德国 (DE)', value: 'DE' },
|
||||
{ title: '意大利 (IT)', value: 'IT' },
|
||||
{ title: '西班牙 (ES)', value: 'ES' },
|
||||
{ title: '加拿大 (CA)', value: 'CA' },
|
||||
{ title: '澳大利亚 (AU)', value: 'AU' },
|
||||
{ title: '俄罗斯 (RU)', value: 'RU' },
|
||||
{ title: '印度 (IN)', value: 'IN' },
|
||||
{ title: '泰国 (TH)', value: 'TH' },
|
||||
{ title: '新加坡 (SG)', value: 'SG' },
|
||||
{ title: '马来西亚 (MY)', value: 'MY' },
|
||||
{ title: '越南 (VN)', value: 'VN' },
|
||||
{ title: '菲律宾 (PH)', value: 'PH' },
|
||||
{ title: '巴西 (BR)', value: 'BR' },
|
||||
{ title: '墨西哥 (MX)', value: 'MX' },
|
||||
{ title: '阿根廷 (AR)', value: 'AR' },
|
||||
{ title: '荷兰 (NL)', value: 'NL' },
|
||||
{ title: '比利时 (BE)', value: 'BE' },
|
||||
{ title: '瑞士 (CH)', value: 'CH' },
|
||||
{ title: '瑞典 (SE)', value: 'SE' },
|
||||
{ title: '挪威 (NO)', value: 'NO' },
|
||||
{ title: '丹麦 (DK)', value: 'DK' },
|
||||
{ title: '波兰 (PL)', value: 'PL' },
|
||||
{ title: '捷克 (CZ)', value: 'CZ' },
|
||||
{ title: '土耳其 (TR)', value: 'TR' },
|
||||
{ title: '以色列 (IL)', value: 'IL' },
|
||||
{ title: '埃及 (EG)', value: 'EG' },
|
||||
{ title: '南非 (ZA)', value: 'ZA' },
|
||||
{ title: '新西兰 (NZ)', value: 'NZ' },
|
||||
]
|
||||
|
||||
const fetchConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await api.get('media/category/config')
|
||||
if (res && res.data) {
|
||||
parseConfig(res.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseConfig = (data: CategoryConfig) => {
|
||||
// 将对象 { "Name": { ... } } 转换为数组 [ { id: uuid, name: "Name", rule: { ... } } ]
|
||||
movieList.value = []
|
||||
if (data.movie) {
|
||||
for (const [key, value] of Object.entries(data.movie)) {
|
||||
// 为了UI一致性处理 genre_ids 为数组或字符串,但 API 发送的是字符串
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// UI 多选预期为数组,检查输入。实际上 VAutocomplete 多选预期数组。我们需要将字符串分割为数组。
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理制片国家/地区
|
||||
if (rule.production_countries && typeof rule.production_countries === 'string') {
|
||||
// @ts-ignore
|
||||
rule.production_countries = rule.production_countries.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.production_countries = []
|
||||
}
|
||||
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tvList.value = []
|
||||
if (data.tv) {
|
||||
for (const [key, value] of Object.entries(data.tv)) {
|
||||
const rule = { ...value }
|
||||
if (rule.genre_ids && typeof rule.genre_ids === 'string') {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = rule.genre_ids.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = []
|
||||
}
|
||||
|
||||
// 处理语种
|
||||
if (rule.original_language && typeof rule.original_language === 'string') {
|
||||
// @ts-ignore
|
||||
rule.original_language = rule.original_language.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.original_language = []
|
||||
}
|
||||
|
||||
// 处理发行国家/地区
|
||||
if (rule.origin_country && typeof rule.origin_country === 'string') {
|
||||
// @ts-ignore
|
||||
rule.origin_country = rule.origin_country.split(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.origin_country = []
|
||||
}
|
||||
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: key,
|
||||
rule: rule as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addMovieItem = () => {
|
||||
movieList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeMovieItem = (index: number) => {
|
||||
movieList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const addTvItem = () => {
|
||||
tvList.value.push({
|
||||
id: generateId(),
|
||||
name: '新分类',
|
||||
rule: { genre_ids: [] as any },
|
||||
})
|
||||
}
|
||||
|
||||
const removeTvItem = (index: number) => {
|
||||
tvList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 将数组转换回对象
|
||||
const payload: CategoryConfig = {
|
||||
movie: {},
|
||||
tv: {},
|
||||
}
|
||||
|
||||
movieList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
// 将 genre_ids 数组转换回字符串
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 production_countries 数组转换回字符串
|
||||
if (Array.isArray(rule.production_countries) && rule.production_countries.length > 0) {
|
||||
rule.production_countries = rule.production_countries.join(',')
|
||||
} else {
|
||||
rule.production_countries = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.movie[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
tvList.value.forEach(item => {
|
||||
if (item.name) {
|
||||
const rule = { ...item.rule }
|
||||
if (Array.isArray(rule.genre_ids) && rule.genre_ids.length > 0) {
|
||||
rule.genre_ids = rule.genre_ids.join(',')
|
||||
} else {
|
||||
// @ts-ignore
|
||||
rule.genre_ids = null
|
||||
}
|
||||
|
||||
// 将 original_language 数组转换回字符串
|
||||
if (Array.isArray(rule.original_language) && rule.original_language.length > 0) {
|
||||
rule.original_language = rule.original_language.join(',')
|
||||
} else {
|
||||
rule.original_language = undefined
|
||||
}
|
||||
|
||||
// 将 origin_country 数组转换回字符串
|
||||
if (Array.isArray(rule.origin_country) && rule.origin_country.length > 0) {
|
||||
rule.origin_country = rule.origin_country.join(',')
|
||||
} else {
|
||||
rule.origin_country = undefined
|
||||
}
|
||||
|
||||
// 清理空字符串
|
||||
if (!rule.release_year) rule.release_year = undefined
|
||||
|
||||
// @ts-ignore
|
||||
payload.tv[item.name] = rule
|
||||
}
|
||||
})
|
||||
|
||||
const res: any = await api.post('media/category/config', payload)
|
||||
if (res && res.success) {
|
||||
toast.success(t('setting.category.saveSuccess'))
|
||||
emit('save')
|
||||
emit('close')
|
||||
} else {
|
||||
toast.error(t('setting.category.saveFailed', { message: res.message || 'Error' }))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(t('setting.category.saveFailed', { message: 'Network or Config Error' }))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="1000" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem class="py-3">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-shape-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.category.title') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ t('setting.category.subtitle') }}
|
||||
</VCardSubtitle>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText>
|
||||
<VTabs v-model="activeTab" show-arrows class="mb-4">
|
||||
<VTab value="movie">
|
||||
<VIcon icon="mdi-movie-outline" class="me-2" />
|
||||
{{ t('setting.category.movie') }}
|
||||
</VTab>
|
||||
<VTab value="tv">
|
||||
<VIcon icon="mdi-television" class="me-2" />
|
||||
{{ t('setting.category.tv') }}
|
||||
</VTab>
|
||||
</VTabs>
|
||||
|
||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
||||
<VProgressCircular indeterminate color="primary" size="64" />
|
||||
</div>
|
||||
|
||||
<VWindow v-else v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="movie">
|
||||
<draggable v-model="movieList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeMovieItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.production_countries"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addMovieItem"
|
||||
>
|
||||
{{ t('setting.category.addMovie') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
|
||||
<VWindowItem value="tv">
|
||||
<draggable v-model="tvList" handle=".drag-handle" item-key="id" animation="200">
|
||||
<template #item="{ element, index }">
|
||||
<VCard variant="tonal" class="mb-4 category-item">
|
||||
<VCardText class="pa-4">
|
||||
<div class="d-flex align-center mb-5">
|
||||
<VTextField
|
||||
v-model="element.name"
|
||||
:label="t('setting.category.name')"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
variant="plain"
|
||||
class="font-bold"
|
||||
prepend-inner-icon="mdi-tag-outline"
|
||||
/>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="drag-handle me-2"
|
||||
color="primary"
|
||||
/>
|
||||
<VBtn
|
||||
icon="mdi-delete-outline"
|
||||
color="error"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="removeTvItem(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.genre_ids"
|
||||
:items="genreOptions"
|
||||
:label="t('setting.category.genre')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-movie-filter-outline"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.origin_country"
|
||||
:items="countryOptions"
|
||||
:label="t('setting.category.country')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-earth"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VAutocomplete
|
||||
v-model="element.rule.original_language"
|
||||
:items="languageOptions"
|
||||
:label="t('setting.category.language')"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-translate"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="element.rule.release_year"
|
||||
:label="t('setting.category.year')"
|
||||
:placeholder="t('setting.category.yearPlaceholder')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-calendar-range"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<VBtn
|
||||
block
|
||||
variant="outlined"
|
||||
size="large"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
class="mt-2 add-category-btn"
|
||||
@click="addTvItem"
|
||||
>
|
||||
{{ t('setting.category.addTv') }}
|
||||
</VBtn>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.category-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.add-category-btn {
|
||||
border-style: dashed !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-category-btn:hover {
|
||||
border-style: solid !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.disable-tab-transition > * {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
|
||||
// 插件仓库设置字符串
|
||||
const repoString = ref('')
|
||||
// 用于显示的仓库地址数组
|
||||
const repoArray = ref<string[]>([])
|
||||
const repoList = ref<string[]>([])
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
|
||||
// 计算属性:在数组和换行符分隔的字符串之间转换
|
||||
const displayRepos = computed({
|
||||
get: () => repoArray.value.join('\n'),
|
||||
set: (value: string) => {
|
||||
repoArray.value = value.split('\n').filter((repo: string) => repo.trim() !== '')
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
// 查询已设置的插件仓库
|
||||
async function queryMarketRepoSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
||||
if (result && result.data && result.data.value) {
|
||||
repoString.value = result.data.value
|
||||
repoArray.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
||||
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
async function saveHandle() {
|
||||
try {
|
||||
// 将数组转换为逗号分隔的字符串
|
||||
const repoStringToSave = repoArray.value.join(',')
|
||||
const repoStringToSave = repoList.value.join(',')
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
|
||||
|
||||
if (result.success) {
|
||||
@@ -57,6 +42,76 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.value.trim()
|
||||
if (!url) return
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
if (repoList.value.includes(url)) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
repoList.value.push(url)
|
||||
newRepoUrl.value = ''
|
||||
}
|
||||
|
||||
function removeRepo(index: number) {
|
||||
repoList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function startEdit(index: number) {
|
||||
editingIndex.value = index
|
||||
editingUrl.value = repoList.value[index]
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (editingIndex.value === null) return
|
||||
|
||||
const url = editingUrl.value.trim()
|
||||
if (!url) return
|
||||
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
repoList.value[editingIndex.value] = url
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingIndex.value = null
|
||||
editingUrl.value = ''
|
||||
}
|
||||
|
||||
function formatRepoDisplay(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and fall back to the original value.
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
function repoItemKey(repo: string) {
|
||||
return repo
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
queryMarketRepoSetting()
|
||||
})
|
||||
@@ -64,7 +119,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCard class="plugin-market-dialog-card">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-store-cog" class="me-2" />
|
||||
@@ -73,21 +128,127 @@ onMounted(() => {
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea
|
||||
v-model="displayRepos"
|
||||
:placeholder="t('dialog.pluginMarketSetting.repoPlaceholder')"
|
||||
:hint="t('dialog.pluginMarketSetting.repoHint')"
|
||||
persistent-hint
|
||||
auto-grow
|
||||
/>
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-input mb-4">
|
||||
<VTextField
|
||||
v-model="newRepoUrl"
|
||||
density="compact"
|
||||
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
|
||||
prepend-inner-icon="mdi-link-plus"
|
||||
clearable
|
||||
@keyup.enter="addRepo"
|
||||
>
|
||||
<template #append>
|
||||
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
|
||||
</template>
|
||||
</VTextField>
|
||||
</div>
|
||||
|
||||
<div class="plugin-market-list-wrap">
|
||||
<VList v-if="repoList.length > 0" class="px-0">
|
||||
<draggable
|
||||
v-model="repoList"
|
||||
:item-key="repoItemKey"
|
||||
handle=".drag-handle"
|
||||
animation="200"
|
||||
:disabled="editingIndex !== null"
|
||||
>
|
||||
<template #item="{ element: repo, index }">
|
||||
<div>
|
||||
<VListItem class="py-2">
|
||||
<template #prepend>
|
||||
<VBtn
|
||||
icon="mdi-drag-vertical"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="drag-handle me-2"
|
||||
:disabled="editingIndex !== null"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VListItemTitle v-if="editingIndex !== index">
|
||||
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
|
||||
</VListItemTitle>
|
||||
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="editingUrl"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@keyup.enter="saveEdit"
|
||||
@keyup.escape="cancelEdit"
|
||||
/>
|
||||
|
||||
<template #append v-if="editingIndex !== index">
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
|
||||
<IconBtn
|
||||
icon="mdi-delete"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="error"
|
||||
@click="removeRepo(index)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #append v-else>
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
|
||||
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</VList>
|
||||
|
||||
<div v-else class="text-center text-medium-emphasis py-8">
|
||||
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
|
||||
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveHandle" prepend-icon="mdi-content-save-check" class="px-5 me-3">
|
||||
<VBtn
|
||||
@click="saveHandle"
|
||||
prepend-icon="mdi-content-save-check"
|
||||
class="px-5 me-3"
|
||||
:disabled="repoList.length === 0"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.plugin-market-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-input {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
flex: 1;
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FileItem, StorageConf, TransferDirectoryConf, TransferForm } from '@/ap
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -63,6 +64,9 @@ const progressText = ref(t('dialog.reorganize.processing'))
|
||||
// 整理进度
|
||||
const progressValue = ref(0)
|
||||
|
||||
// 进度SSE连接
|
||||
const progressSSE = ref<any>(null)
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
|
||||
@@ -200,25 +204,31 @@ function handleProgressMessage(event: MessageEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优化的进度SSE连接
|
||||
const progressSSE = useProgressSSE(
|
||||
`${import.meta.env.VITE_API_BASE_URL}system/progress/filetransfer`,
|
||||
handleProgressMessage,
|
||||
'reorganize-progress',
|
||||
progressActive,
|
||||
)
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
function startLoadingProgress(key: string) {
|
||||
progressText.value = t('dialog.reorganize.processing')
|
||||
progressActive.value = true
|
||||
progressSSE.start()
|
||||
|
||||
// 如果已经有连接,先停止
|
||||
if (progressSSE.value) {
|
||||
progressSSE.value.stop()
|
||||
}
|
||||
|
||||
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${key}`
|
||||
|
||||
// 创建新的SSE连接
|
||||
progressSSE.value = useProgressSSE(url, handleProgressMessage, `reorganize-progress-${key}`, progressActive)
|
||||
|
||||
progressSSE.value.start()
|
||||
}
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
progressActive.value = false
|
||||
progressSSE.stop()
|
||||
if (progressSSE.value) {
|
||||
progressSSE.value.stop()
|
||||
progressSSE.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 整理文件
|
||||
@@ -228,25 +238,30 @@ async function transfer(background: boolean = false) {
|
||||
// 显示进度条
|
||||
progressDialog.value = true
|
||||
|
||||
if (!background) {
|
||||
// 开始监听进度
|
||||
startLoadingProgress()
|
||||
}
|
||||
|
||||
// 文件整理
|
||||
if (props.items) {
|
||||
for (const item of props.items) {
|
||||
if (!background) {
|
||||
// 如果是文件,计算MD5
|
||||
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
|
||||
|
||||
// 开始监听进度
|
||||
startLoadingProgress(key)
|
||||
}
|
||||
await handleTransfer(item, background)
|
||||
}
|
||||
}
|
||||
|
||||
// 日志整理
|
||||
if (props.logids) {
|
||||
if (!background) {
|
||||
// 为日志整理任务开启进度监听
|
||||
startLoadingProgress('filetransfer')
|
||||
}
|
||||
for (const logid of props.logids) {
|
||||
await handleTransferLog(logid, background)
|
||||
}
|
||||
}
|
||||
|
||||
if (!background) {
|
||||
// 停止监听进度
|
||||
stopLoadingProgress()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { Site } from '@/api/types'
|
||||
import api from '@/api'
|
||||
import type { TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -23,6 +26,30 @@ const selectCategory = ref<number[]>([])
|
||||
// 全部分类
|
||||
const siteCategoryList = ref<SiteCategory[]>()
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 当前页
|
||||
const resourcePage = ref(1)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 移动端搜索栏是否展开
|
||||
const mobileSearchExpanded = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 分类选项
|
||||
const categoryOptions = computed(() => {
|
||||
return siteCategoryList.value?.map(item => {
|
||||
@@ -30,77 +57,85 @@ const categoryOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 注册事件
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 数据列表
|
||||
const resourceDataList = ref<TorrentInfo[]>([])
|
||||
|
||||
// 搜索
|
||||
const resourceSearch = ref('')
|
||||
|
||||
// 总条数
|
||||
const resourceTotalItems = ref(0)
|
||||
|
||||
// 每页条数
|
||||
const resourceItemsPerPage = ref(25)
|
||||
|
||||
// 加载状态
|
||||
const resourceLoading = ref(false)
|
||||
|
||||
// 种子元数据
|
||||
const torrent = ref<TorrentInfo>()
|
||||
const resourceTotalItems = computed(() => resourceDataList.value.length)
|
||||
|
||||
// 资源浏览表头
|
||||
const resourceHeaders = [
|
||||
const resourceHeaders = computed(() => [
|
||||
{ title: t('dialog.siteResource.titleColumn'), key: 'title', sortable: false },
|
||||
{ title: t('dialog.siteResource.timeColumn'), key: 'pubdate', sortable: true },
|
||||
{ title: t('dialog.siteResource.sizeColumn'), key: 'size', sortable: true },
|
||||
{ title: t('dialog.siteResource.seedersColumn'), key: 'seeders', sortable: true },
|
||||
{ title: t('dialog.siteResource.peersColumn'), key: 'peers', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false },
|
||||
]
|
||||
])
|
||||
|
||||
// 输入框标签
|
||||
const keywordFieldLabel = computed(() => {
|
||||
return keyword.value ? '' : t('dialog.siteResource.searchKeyword')
|
||||
})
|
||||
|
||||
const categoryFieldLabel = computed(() => {
|
||||
return selectCategory.value.length > 0 ? '' : t('dialog.siteResource.resourceCategory')
|
||||
})
|
||||
|
||||
// 结果统计文案
|
||||
const resultSummaryText = computed(() => {
|
||||
if (locale.value.startsWith('zh')) {
|
||||
return `共 ${resourceTotalItems.value} 条结果`
|
||||
}
|
||||
|
||||
return `${resourceTotalItems.value} results`
|
||||
})
|
||||
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 打开种子详情页面
|
||||
function openTorrentDetail(page_url: string) {
|
||||
if (!page_url) return
|
||||
window.open(page_url, '_blank')
|
||||
}
|
||||
|
||||
// 下载种子文件
|
||||
async function downloadTorrentFile(enclosure: string) {
|
||||
if (!enclosure) return
|
||||
window.open(enclosure, '_blank')
|
||||
}
|
||||
|
||||
// 促销Chip类
|
||||
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
|
||||
if (downloadVolume === 0) return 'text-white bg-lime-500'
|
||||
else if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
else return 'text-white bg-gray-500'
|
||||
if (downloadVolume < 1) return 'text-white bg-green-500'
|
||||
if (uploadVolume !== 1) return 'text-white bg-sky-500'
|
||||
|
||||
return 'text-white bg-gray-500'
|
||||
}
|
||||
|
||||
// 添加下载
|
||||
async function addDownload(_torrent: any) {
|
||||
async function addDownload(_torrent: TorrentInfo) {
|
||||
torrent.value = _torrent
|
||||
addDownloadDialog.value = true
|
||||
}
|
||||
|
||||
// 添加下载对话框
|
||||
const addDownloadDialog = ref(false)
|
||||
|
||||
// 添加下载成功
|
||||
function addDownloadSuccess(url: string) {
|
||||
function addDownloadSuccess(_url: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 添加下载失败
|
||||
function addDownloadError(error: string) {
|
||||
function addDownloadError(_error: string) {
|
||||
addDownloadDialog.value = false
|
||||
}
|
||||
|
||||
// 调用API,查询站点资源
|
||||
async function getResourceList() {
|
||||
resourceLoading.value = true
|
||||
resourcePage.value = 1
|
||||
|
||||
try {
|
||||
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`, {
|
||||
params: {
|
||||
@@ -111,7 +146,12 @@ async function getResourceList() {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
resourceLoading.value = false
|
||||
|
||||
if (isMobileLayout.value) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载站点分类
|
||||
@@ -123,16 +163,44 @@ async function getSiteCategoryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// 装载时查询站点图标
|
||||
watch([resourceItemsPerPage, resourceTotalItems, () => display.mdAndUp.value], () => {
|
||||
if (display.mdAndUp.value) {
|
||||
const maxPage = Math.max(1, Math.ceil(resourceTotalItems.value / resourceItemsPerPage.value))
|
||||
if (resourcePage.value > maxPage) {
|
||||
resourcePage.value = maxPage
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => display.mdAndUp.value,
|
||||
isDesktop => {
|
||||
if (isDesktop) {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
|
||||
// 装载时查询站点分类和资源
|
||||
onMounted(() => {
|
||||
getSiteCategoryList()
|
||||
getResourceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable fullscreen :scrim="false" transition="dialog-bottom-transition">
|
||||
<VCard>
|
||||
<!-- Toolbar -->
|
||||
<VDialog scrollable :fullscreen="display.smAndDown.value" max-width="92rem" transition="dialog-bottom-transition">
|
||||
<VCard class="site-resource-dialog">
|
||||
<div>
|
||||
<VToolbar color="primary" density="comfortable">
|
||||
<VToolbarTitle>{{ t('dialog.siteResource.browseTitle', { name: props.site?.name }) }}</VToolbarTitle>
|
||||
@@ -144,45 +212,153 @@ onMounted(() => {
|
||||
</VToolbarItems>
|
||||
</VToolbar>
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<VRow>
|
||||
<VCol cols="6" md="5">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
size="small"
|
||||
density="compact"
|
||||
:label="t('dialog.siteResource.searchKeyword')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
size="small"
|
||||
density="compact"
|
||||
chips
|
||||
:label="t('dialog.siteResource.resourceCategory')"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="2" class="text-center">
|
||||
<VBtn variant="tonal" block prepend-icon="mdi-magnify" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
|
||||
<div class="pa-3 pb-2">
|
||||
<template v-if="!isMobileLayout">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="5">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="3" class="d-flex align-center">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
block
|
||||
size="default"
|
||||
rounded="lg"
|
||||
prepend-icon="mdi-magnify"
|
||||
class="site-resource-search-btn"
|
||||
@click="getResourceList"
|
||||
>
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="resourceTotalItems > 0"
|
||||
class="d-flex justify-space-between align-center flex-wrap gap-2 mt-3"
|
||||
>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal" class="site-resource-result-chip">
|
||||
{{ resourceTotalItems }}
|
||||
</VChip>
|
||||
</div>
|
||||
</div>
|
||||
</VSheet>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="site-resource-mobile-search">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="site-resource-mobile-search__toggle"
|
||||
@click="toggleMobileSearch"
|
||||
>
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<div v-if="resourceTotalItems > 0" class="text-body-2 text-medium-emphasis">
|
||||
{{ resultSummaryText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VExpandTransition>
|
||||
<div v-if="mobileSearchExpanded" class="mt-2">
|
||||
<VSheet class="site-resource-filter-panel" rounded="lg" border>
|
||||
<div class="site-resource-filter-panel__inner">
|
||||
<VRow class="site-resource-filter-row">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="keyword"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
:label="keywordFieldLabel"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
autofocus
|
||||
@keyup.enter="getResourceList"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VSelect
|
||||
v-model="selectCategory"
|
||||
:items="categoryOptions"
|
||||
class="site-resource-filter-input"
|
||||
size="small"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
chips
|
||||
:label="categoryFieldLabel"
|
||||
multiple
|
||||
clearable
|
||||
prepend-inner-icon="mdi-folder"
|
||||
hide-details
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" class="d-flex gap-2">
|
||||
<VBtn color="primary" variant="flat" block rounded="lg" class="site-resource-search-btn" @click="getResourceList">
|
||||
{{ t('dialog.siteResource.search') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" rounded="lg" @click="closeMobileSearch">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</VSheet>
|
||||
</div>
|
||||
</VExpandTransition>
|
||||
</template>
|
||||
</div>
|
||||
<VCardText class="px-0 py-0 my-0">
|
||||
|
||||
<VCardText class="site-resource-content px-0 py-0 my-0">
|
||||
<VDataTable
|
||||
v-if="display.mdAndUp.value"
|
||||
v-model:page="resourcePage"
|
||||
v-model:items-per-page="resourceItemsPerPage"
|
||||
:headers="resourceHeaders"
|
||||
:items="resourceDataList"
|
||||
:items-length="resourceTotalItems"
|
||||
:search="resourceSearch"
|
||||
:loading="resourceLoading"
|
||||
density="compact"
|
||||
item-value="title"
|
||||
@@ -191,60 +367,69 @@ onMounted(() => {
|
||||
hover
|
||||
:items-per-page-text="t('dialog.siteResource.itemsPerPage')"
|
||||
:loading-text="t('dialog.siteResource.loading')"
|
||||
class="h-full"
|
||||
:items-per-page-options="[10, 25, 50, 100]"
|
||||
height="100%"
|
||||
class="h-full site-resource-table"
|
||||
>
|
||||
<template #item.title="{ item }">
|
||||
<a href="javascript:void(0)" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click.stop="addDownload(item)">
|
||||
<div class="text-high-emphasis pt-1 font-weight-medium">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="text-sm my-1">
|
||||
<div v-if="item.description" class="text-sm my-1 text-medium-emphasis">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</a>
|
||||
<div class="mt-2">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, index) in item.labels"
|
||||
:key="index"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #item.pubdate="{ item }">
|
||||
<div>{{ item.date_elapsed }}</div>
|
||||
<div class="text-sm">
|
||||
<div class="text-sm text-medium-emphasis">
|
||||
{{ item.pubdate }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.size="{ item }">
|
||||
<div class="text-nowrap whitespace-nowrap">
|
||||
{{ formatFileSize(item.size) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.seeders="{ item }">
|
||||
<div>{{ item.seeders }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.peers="{ item }">
|
||||
<div>{{ item.peers }}</div>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
@@ -268,11 +453,119 @@ onMounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #no-data>{{ t('dialog.siteResource.noData') }}</template>
|
||||
</VDataTable>
|
||||
|
||||
<div v-else class="site-resource-mobile">
|
||||
<div v-if="resourceLoading" class="px-4 py-6">
|
||||
<VProgressLinear color="primary" indeterminate rounded />
|
||||
<div class="text-center text-body-2 text-medium-emphasis mt-3">
|
||||
{{ t('dialog.siteResource.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
|
||||
<VCard
|
||||
v-for="(item, index) in mobileResourceList"
|
||||
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
|
||||
class="mb-3"
|
||||
>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="item.description"
|
||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
||||
H&R
|
||||
</VChip>
|
||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
||||
{{ item.freedate_diff }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-for="(label, chipIndex) in item.labels"
|
||||
:key="chipIndex"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
color="primary"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
<VChip
|
||||
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
|
||||
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
class="me-1 mb-1"
|
||||
>
|
||||
{{ item.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__actions mt-4">
|
||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
||||
{{ t('actionStep.addDownload') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
{{ t('dialog.siteResource.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
<!-- 添加下载对话框 -->
|
||||
|
||||
<AddDownloadDialog
|
||||
v-if="addDownloadDialog"
|
||||
v-model="addDownloadDialog"
|
||||
@@ -285,7 +578,160 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.site-resource-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-filter-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel {
|
||||
border-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(var(--v-theme-primary), 0.06), transparent 40%),
|
||||
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.93));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 4%);
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field) {
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.8));
|
||||
}
|
||||
|
||||
.site-resource-filter-input :deep(.v-field__prepend-inner) {
|
||||
color: rgba(var(--v-theme-primary), 0.85);
|
||||
}
|
||||
|
||||
.site-resource-search-btn {
|
||||
box-shadow: 0 8px 18px rgba(var(--v-theme-primary), 0.18);
|
||||
letter-spacing: 0.02em;
|
||||
min-block-size: 40px;
|
||||
}
|
||||
|
||||
.site-resource-result-chip {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search__toggle {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-resource-title-btn {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-content {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-resource-table {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-table__wrapper) {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.site-resource-table :deep(.v-data-table-footer) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.v-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.site-resource-card__meta {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.7));
|
||||
border-radius: 0.6rem;
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.site-resource-dialog {
|
||||
block-size: min(88vh, 960px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 959px) {
|
||||
.site-resource-dialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.site-resource-filter-panel__inner {
|
||||
padding: 0.7rem 0.75rem;
|
||||
}
|
||||
|
||||
.site-resource-mobile-search {
|
||||
min-block-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">{{
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import QRCode from 'qrcode'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 常量定义
|
||||
const AUTH_WINDOW_WIDTH = 600
|
||||
const AUTH_WINDOW_HEIGHT = 700
|
||||
const POLL_INTERVAL = 2000
|
||||
const AUTH_STATUS_SUCCESS = 2
|
||||
const AUTH_STATUS_FAILED = -1
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
conf: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
@@ -18,27 +24,40 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
// Events 定义
|
||||
const emit = defineEmits(['done', 'close'])
|
||||
|
||||
// 二维码内容
|
||||
const qrCodeContent = ref('')
|
||||
// 响应式状态
|
||||
const authUrl = ref('')
|
||||
const authState = ref('')
|
||||
const text = ref('')
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning'>('info')
|
||||
|
||||
// 二维码图片 base64
|
||||
const qrCodeImage = ref('')
|
||||
// 授权窗口引用
|
||||
let authWindow: Window | null = null
|
||||
let pollTimer: NodeJS.Timeout | undefined
|
||||
|
||||
// 下方的提示信息
|
||||
const text = ref(t('dialog.u115Auth.scanQrCode'))
|
||||
// 清理资源
|
||||
function cleanup() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = undefined
|
||||
}
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.close()
|
||||
authWindow = null
|
||||
}
|
||||
}
|
||||
|
||||
// 提醒类型
|
||||
const alertType = ref<'success' | 'info' | 'error' | 'warning' | undefined>('info')
|
||||
// 设置提示消息
|
||||
function setMessage(type: typeof alertType.value, message: string) {
|
||||
alertType.value = type
|
||||
text.value = message
|
||||
}
|
||||
|
||||
// timeout定时器
|
||||
let timeoutTimer: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
// 完成
|
||||
async function handleDone() {
|
||||
clearTimeout(timeoutTimer)
|
||||
// 完成授权
|
||||
function handleDone() {
|
||||
cleanup()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
@@ -47,78 +66,118 @@ async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/u115')
|
||||
if (result.success) {
|
||||
// 重置成功
|
||||
alertType.value = 'success'
|
||||
setMessage('success', t('dialog.u115Auth.authSuccess'))
|
||||
handleDone()
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 调用/u115/qrcode api生成二维码
|
||||
async function getQrcode() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/qrcode/u115')
|
||||
if (result.success && result.data) {
|
||||
qrCodeContent.value = result.data.codeContent
|
||||
// 生成二维码图片
|
||||
qrCodeImage.value = await QRCode.toDataURL(result.data.codeContent, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
})
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else {
|
||||
text.value = result.message
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
catch (error) {
|
||||
console.error('Reset failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.authFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 调用/aliyun/check api验证二维码
|
||||
async function checkQrcode() {
|
||||
// 获取授权URL
|
||||
async function fetchAuthUrl() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/auth_url/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
authUrl.value = result.data.authUrl
|
||||
authState.value = result.data.state
|
||||
}
|
||||
else {
|
||||
setMessage('error', result.message || t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Fetch auth URL failed:', error)
|
||||
setMessage('error', t('dialog.u115Auth.urlFetchFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 打开授权窗口
|
||||
function openAuthWindow() {
|
||||
if (!authUrl.value) {
|
||||
setMessage('error', t('dialog.u115Auth.urlEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
const left = (window.screen.width - AUTH_WINDOW_WIDTH) / 2
|
||||
const top = (window.screen.height - AUTH_WINDOW_HEIGHT) / 2
|
||||
const features = [
|
||||
`width=${AUTH_WINDOW_WIDTH}`,
|
||||
`height=${AUTH_WINDOW_HEIGHT}`,
|
||||
`left=${left}`,
|
||||
`top=${top}`,
|
||||
'toolbar=no',
|
||||
'location=no',
|
||||
'status=no',
|
||||
'menubar=no',
|
||||
'scrollbars=yes',
|
||||
'resizable=yes',
|
||||
].join(',')
|
||||
|
||||
authWindow = window.open(authUrl.value, '115授权', features)
|
||||
|
||||
if (authWindow) {
|
||||
setMessage('info', t('dialog.u115Auth.authorizing'))
|
||||
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
|
||||
}
|
||||
else {
|
||||
setMessage('error', t('dialog.u115Auth.popupBlocked'))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查授权状态
|
||||
async function checkAuthStatus() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/check/u115')
|
||||
|
||||
if (result.success && result.data) {
|
||||
const status = result.data.status
|
||||
text.value = result.data.tip
|
||||
if (status == 0) {
|
||||
alertType.value = 'info'
|
||||
// 新建、待扫码
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 1) {
|
||||
// 已扫码
|
||||
alertType.value = 'info'
|
||||
text.value = t('dialog.u115Auth.scanned')
|
||||
clearTimeout(timeoutTimer)
|
||||
timeoutTimer = setTimeout(checkQrcode, 3000)
|
||||
} else if (status == 2) {
|
||||
// 已确认完成
|
||||
alertType.value = 'success'
|
||||
const { status, tip } = result.data
|
||||
|
||||
if (status === AUTH_STATUS_SUCCESS) {
|
||||
// 授权成功
|
||||
setMessage('success', t('dialog.u115Auth.authSuccess'))
|
||||
handleDone()
|
||||
} else {
|
||||
// 过期或者已取消
|
||||
alertType.value = 'error'
|
||||
return
|
||||
}
|
||||
} else {
|
||||
alertType.value = 'error'
|
||||
text.value = result.message
|
||||
|
||||
if (status === AUTH_STATUS_FAILED) {
|
||||
// 授权失败或过期
|
||||
setMessage('error', tip || t('dialog.u115Auth.authFailed'))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// status === 0 或 1,继续等待
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Check auth status failed:', error)
|
||||
}
|
||||
|
||||
// 检查窗口是否被用户关闭
|
||||
if (authWindow?.closed) {
|
||||
setMessage('warning', t('dialog.u115Auth.authCanceled'))
|
||||
cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollTimer = setTimeout(checkAuthStatus, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getQrcode()
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
fetchAuthUrl()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer)
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -126,37 +185,63 @@ onUnmounted(() => {
|
||||
<VDialog width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-qrcode" class="me-2" />
|
||||
<VIcon icon="mdi-shield-key" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('dialog.u115Auth.loginTitle') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="pt-2 flex flex-col items-center justify-center">
|
||||
<div class="mt-6 rounded text-center p-3 border">
|
||||
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
<!-- 授权按钮 -->
|
||||
<div class="mt-6 mb-4 text-center">
|
||||
<VBtn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
prepend-icon="mdi-login"
|
||||
:disabled="!authUrl"
|
||||
class="px-8"
|
||||
@click="openAuthWindow"
|
||||
>
|
||||
{{ t('dialog.u115Auth.openAuthWindow') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div>
|
||||
<VAlert variant="tonal" :type="alertType" class="my-4 text-center" :text="text">
|
||||
|
||||
<!-- 状态提示 -->
|
||||
<div v-if="text" class="w-full">
|
||||
<VAlert
|
||||
variant="tonal"
|
||||
:type="alertType"
|
||||
:text="text"
|
||||
class="my-4 text-center"
|
||||
>
|
||||
<template #prepend />
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VBtn
|
||||
color="error"
|
||||
prepend-icon="mdi-restore"
|
||||
class="px-5 me-3"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
</VBtn>
|
||||
|
||||
<VSpacer />
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
||||
|
||||
<VBtn
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5 me-3"
|
||||
@click="handleDone"
|
||||
>
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -199,6 +199,7 @@ async function fetchUserInfo() {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
if (userForm.value) {
|
||||
userForm.value.avatar = userForm.value.avatar || avatar1
|
||||
userForm.value.nickname = userForm.value.settings?.nickname ?? ''
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
currentUserName.value = userForm.value.name
|
||||
userName.value = userForm.value.name
|
||||
@@ -273,12 +274,10 @@ async function updateUser() {
|
||||
}
|
||||
|
||||
// 将nickname保存到settings中,后端可以直接处理JSON对象
|
||||
if (userForm.value.nickname) {
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname
|
||||
if (!userForm.value.settings) {
|
||||
userForm.value.settings = {}
|
||||
}
|
||||
userForm.value.settings.nickname = userForm.value.nickname ?? ''
|
||||
|
||||
const oldUserName = userForm.value.name
|
||||
userForm.value.name = currentUserName.value
|
||||
|
||||
@@ -13,6 +13,7 @@ import MediaInfoDialog from '../dialog/MediaInfoDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -23,6 +24,10 @@ const display = useDisplay()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 计算列表可用高度
|
||||
// componentOffset = FileToolbar(48) + FileList操作栏(40) + VCard边距(4) = 92
|
||||
const { availableHeight: listAvailableHeight } = useAvailableHeight(92, 300)
|
||||
|
||||
// 输入参数
|
||||
const inProps = defineProps({
|
||||
icons: Object,
|
||||
@@ -143,29 +148,12 @@ const transferItems = ref<FileItem[]>([])
|
||||
// 当前图片地址
|
||||
const currentImgLink = ref('')
|
||||
|
||||
// 计算列表可用高度
|
||||
const listAvailableHeight = computed(() => {
|
||||
// 获取视口高度
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
function revokeCurrentImgLink() {
|
||||
if (!currentImgLink.value) return
|
||||
|
||||
// navbar高度
|
||||
const navbarHeight = 72
|
||||
// 工具栏高度(包含搜索框和按钮)
|
||||
const toolbarHeight = 64
|
||||
// 底部导航栏高度
|
||||
const footerHeight = appMode.value ? 80 : 16
|
||||
// 安全区域高度
|
||||
const safeAreaHeight =
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
|
||||
0
|
||||
|
||||
// 计算可用高度,预留一些边距
|
||||
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
|
||||
|
||||
// 确保最小高度
|
||||
return Math.max(availableHeight, 300)
|
||||
})
|
||||
URL.revokeObjectURL(currentImgLink.value)
|
||||
currentImgLink.value = ''
|
||||
}
|
||||
|
||||
// 是否为图片文件
|
||||
const isImage = computed(() => {
|
||||
@@ -304,6 +292,9 @@ async function download(item: FileItem) {
|
||||
if (result) {
|
||||
const downloadUrl = URL.createObjectURL(result)
|
||||
window.open(downloadUrl, '_blank')
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
}, 60000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,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)
|
||||
}
|
||||
}
|
||||
@@ -331,7 +323,10 @@ watch(
|
||||
async () => {
|
||||
if (isImage.value && isFile.value) {
|
||||
await getImgLink(inProps.item)
|
||||
return
|
||||
}
|
||||
|
||||
revokeCurrentImgLink()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -614,6 +609,11 @@ function stopLoadingProgress() {
|
||||
onMounted(() => {
|
||||
list_files()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
revokeCurrentImgLink()
|
||||
stopLoadingProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,38 +5,18 @@ import { useDisplay } from 'vuetify'
|
||||
import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 计算列表可用高度
|
||||
const availableHeight = computed(() => {
|
||||
// 获取视口高度
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
|
||||
|
||||
// navbar高度
|
||||
const navbarHeight = 72
|
||||
// 工具栏高度
|
||||
const toolbarHeight = 25
|
||||
// 底部导航栏高度
|
||||
const footerHeight = appMode.value ? 80 : 16
|
||||
// 安全区域高度
|
||||
const safeAreaHeight =
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-bottom')) ||
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-area-inset-top')) ||
|
||||
0
|
||||
|
||||
// 计算可用高度,预留一些边距
|
||||
const availableHeight = viewportHeight - navbarHeight - toolbarHeight - footerHeight - safeAreaHeight - 40
|
||||
|
||||
// 确保最小高度
|
||||
return Math.max(availableHeight, 300)
|
||||
})
|
||||
// componentOffset = FileToolbar(48) = 48
|
||||
const { availableHeight } = useAvailableHeight(48, 300)
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
|
||||
@@ -30,6 +30,10 @@ const inProps = defineProps({
|
||||
type: String,
|
||||
default: 'name',
|
||||
},
|
||||
showNewFolderButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 对外事件
|
||||
@@ -109,11 +113,20 @@ async function mkdir() {
|
||||
emit('foldercreated')
|
||||
}
|
||||
|
||||
function openNewFolderDialog() {
|
||||
newFolderName.value = ''
|
||||
newFolderPopper.value = true
|
||||
}
|
||||
|
||||
// 计算排序图标
|
||||
const sortIcon = computed(() => {
|
||||
if (inProps.sort === 'time') return 'mdi-sort-clock-ascending-outline'
|
||||
else return 'mdi-sort-alphabetical-ascending'
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
openNewFolderDialog,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -165,9 +178,9 @@ const sortIcon = computed(() => {
|
||||
</IconBtn>
|
||||
<!-- 新建文件夹 -->
|
||||
<VDialog v-model="newFolderPopper" max-width="35rem">
|
||||
<template #activator="{ props }">
|
||||
<IconBtn>
|
||||
<VIcon v-bind="props" icon="mdi-folder-plus-outline" />
|
||||
<template v-if="showNewFolderButton" #activator="{ props }">
|
||||
<IconBtn v-bind="props">
|
||||
<VIcon icon="mdi-folder-plus-outline" />
|
||||
</IconBtn>
|
||||
</template>
|
||||
<VCard>
|
||||
|
||||
@@ -8,7 +8,44 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const menu = ref(false)
|
||||
const currentCron = ref(props.modelValue)
|
||||
const menuRoot = ref<HTMLElement>()
|
||||
const instance = getCurrentInstance()
|
||||
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
||||
const menuContentSelector = `.${menuContentClass}`
|
||||
|
||||
function isCronMenuTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) return false
|
||||
|
||||
if (menuRoot.value?.contains(target)) return true
|
||||
|
||||
const menuContent = document.querySelector(menuContentSelector)
|
||||
|
||||
if (menuContent?.contains(target)) return true
|
||||
|
||||
const overlayId = target.closest('.v-overlay')?.getAttribute('id')
|
||||
|
||||
if (!overlayId || !menuContent) return false
|
||||
|
||||
return Array.from(menuContent.querySelectorAll('[aria-owns]')).some(
|
||||
activator => activator.getAttribute('aria-owns') === overlayId,
|
||||
)
|
||||
}
|
||||
|
||||
function closeOnOutsidePointerDown(event: PointerEvent) {
|
||||
if (!menu.value || isCronMenuTarget(event.target)) return
|
||||
|
||||
menu.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', closeOnOutsidePointerDown, true)
|
||||
})
|
||||
|
||||
watch(currentCron, newVal => {
|
||||
emit('update:modelValue', newVal)
|
||||
@@ -23,8 +60,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VMenu :close-on-content-click="false" content-class="cursor-default" persistent>
|
||||
<div ref="menuRoot">
|
||||
<VMenu
|
||||
v-model="menu"
|
||||
:close-on-content-click="false"
|
||||
:content-class="['cursor-default', menuContentClass]"
|
||||
persistent
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<slot name="activator" :menuprops="props" />
|
||||
</template>
|
||||
|
||||
@@ -103,8 +103,21 @@ const selectedPath = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
function isFileItem(value: unknown): value is FileItem {
|
||||
return typeof value === 'object' && value !== null && 'path' in value && 'type' in value
|
||||
}
|
||||
|
||||
function activateDir({ id }: { id: unknown }) {
|
||||
const item = isFileItem(id) ? id : typeof id === 'string' ? findPath(treeItems.value[0], id) : null
|
||||
|
||||
if (!item || item.type !== 'dir') return
|
||||
|
||||
activedDirs.value = [item]
|
||||
}
|
||||
|
||||
watch(activedDirs, newVal => {
|
||||
if (!newVal.length) return
|
||||
|
||||
emit('update:modelValue', selectedPath.value)
|
||||
})
|
||||
|
||||
@@ -165,8 +178,10 @@ watch(
|
||||
activatable
|
||||
return-object
|
||||
max-height="20rem"
|
||||
open-on-click
|
||||
expand-icon="mdi-folder"
|
||||
collapse-icon="mdi-folder-open"
|
||||
@click:open="activateDir"
|
||||
/>
|
||||
</VMenu>
|
||||
</div>
|
||||
|
||||
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,5 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 初始化 markdown-it
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
// 插件:链接在新窗口打开
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 渲染 Markdown
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -14,10 +37,79 @@ const props = defineProps({
|
||||
<VListItemTitle class="font-bold text-lg">
|
||||
{{ key }}
|
||||
</VListItemTitle>
|
||||
<div class="text-gray-500">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body :deep(h1),
|
||||
.markdown-body :deep(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
margin-block: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h1) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h2) {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(h3) {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
padding-inline-start: 1.5rem;
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(li) {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin-block: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a) {
|
||||
color: rgb(99 102 241);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body :deep(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-block: 0.5rem;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre code) {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
padding-inline-start: 1rem;
|
||||
margin-block: 0.5rem;
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
color: rgba(127, 127, 127, 0.8);
|
||||
}
|
||||
</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>
|
||||
87
src/composables/useAvailableHeight.ts
Normal file
87
src/composables/useAvailableHeight.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
|
||||
/**
|
||||
* 计算页面内容的可用高度,自动适配 iOS 安全区域和底部 Dock 栏。
|
||||
*
|
||||
* 通过 DOM 测量获取布局的实际 padding(含 safe-area-inset-top/bottom),
|
||||
* 以及 Footer Dock 的实际高度,确保在任何设备上都不会被 Dock 遮挡。
|
||||
*
|
||||
* 计算公式: viewport - layoutPaddingTop - layoutPaddingBottom - footerDock - componentOffset
|
||||
*
|
||||
* @param componentOffset - 组件内部额外占用的空间(工具栏、分页栏等,默认 64)
|
||||
* @param minHeight - 最小高度(默认 300)
|
||||
*/
|
||||
export function useAvailableHeight(
|
||||
componentOffset: number = 64,
|
||||
minHeight: number = 300,
|
||||
) {
|
||||
const { appMode } = usePWA()
|
||||
|
||||
// 响应式测量值
|
||||
const viewportHeight = ref(window.innerHeight || document.documentElement.clientHeight)
|
||||
const layoutPaddingTop = ref(72)
|
||||
const layoutPaddingBottom = ref(24)
|
||||
const footerDockMeasuredHeight = ref(0)
|
||||
|
||||
function updateMeasurements() {
|
||||
viewportHeight.value = window.innerHeight || document.documentElement.clientHeight
|
||||
|
||||
// 测量 .layout-page-content 的实际 padding(含 env(safe-area-inset-top) 等)
|
||||
const layoutEl = document.querySelector('.layout-page-content') as HTMLElement | null
|
||||
if (layoutEl) {
|
||||
const style = getComputedStyle(layoutEl)
|
||||
layoutPaddingTop.value = parseFloat(style.paddingTop) || 72
|
||||
layoutPaddingBottom.value = parseFloat(style.paddingBottom) || 24
|
||||
}
|
||||
|
||||
// 直接查询 Footer Dock DOM,无论 appMode 状态
|
||||
// Dock 通过 Teleport 挂载到 body,存在即测量,不存在即为 0
|
||||
const footerEl = document.querySelector('.footer-nav-container') as HTMLElement | null
|
||||
footerDockMeasuredHeight.value = footerEl ? footerEl.offsetHeight : 0
|
||||
}
|
||||
|
||||
// appMode 异步变化时(PWA 检测完成、屏幕尺寸变化等),Dock 会出现/消失
|
||||
// 需要等 DOM 更新后重新测量
|
||||
watch(appMode, () => {
|
||||
nextTick(updateMeasurements)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateMeasurements)
|
||||
|
||||
window.addEventListener('resize', updateMeasurements)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateMeasurements)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateMeasurements)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateMeasurements)
|
||||
}
|
||||
})
|
||||
|
||||
const availableHeight = computed(() => {
|
||||
const vh = viewportHeight.value
|
||||
|
||||
// 布局顶部 padding(含 safe-area-inset-top + navbar 高度)
|
||||
const topPadding = layoutPaddingTop.value
|
||||
|
||||
// 布局底部 padding
|
||||
const bottomPadding = layoutPaddingBottom.value
|
||||
|
||||
// 底部 Dock 栏遮挡高度(通过 DOM 测量,含 safe-area-inset-bottom)
|
||||
const footerDockHeight = footerDockMeasuredHeight.value
|
||||
|
||||
const available = vh - topPadding - bottomPadding - footerDockHeight - componentOffset
|
||||
|
||||
return Math.max(available, minHeight)
|
||||
})
|
||||
|
||||
return {
|
||||
availableHeight,
|
||||
viewportHeight,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import { ref, inject, nextTick, onMounted, onActivated, onDeactivated, onUnmounted } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
unref,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
__VUE_INJECT_DYNAMIC_BUTTON__?: (button: any) => void
|
||||
__VUE_UNINJECT_DYNAMIC_BUTTON__?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
type MaybeRefValue<T> = T | Ref<T> | ComputedRef<T>
|
||||
|
||||
export interface DynamicButtonMenuItem {
|
||||
title?: string
|
||||
titleKey?: string
|
||||
titleParams?: Record<string, unknown>
|
||||
icon?: string
|
||||
color?: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined): T | undefined
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback: T): T
|
||||
function resolveMaybeRef<T>(value: MaybeRefValue<T> | undefined, fallback?: T) {
|
||||
return value !== undefined ? unref(value) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态按钮钩子函数
|
||||
*
|
||||
@@ -23,12 +54,14 @@ declare global {
|
||||
* })
|
||||
*/
|
||||
export function useDynamicButton(options: {
|
||||
icon: string
|
||||
onClick: () => void
|
||||
icon: MaybeRefValue<string>
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -36,22 +69,42 @@ export function useDynamicButton(options: {
|
||||
|
||||
// 按钮注册状态
|
||||
const dynamicButtonRegistered = ref(false)
|
||||
const componentActive = ref(false)
|
||||
|
||||
const resolvedIcon = computed(() => resolveMaybeRef(icon, 'mdi-plus'))
|
||||
const resolvedShow = computed(() => resolveMaybeRef(show, true))
|
||||
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
|
||||
|
||||
function buildDynamicButton() {
|
||||
const buttonMenuItems = resolvedMenuItems.value
|
||||
|
||||
return {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态按钮
|
||||
function setupDynamicButton() {
|
||||
// 避免重复注册
|
||||
if (dynamicButtonRegistered.value) return
|
||||
if (!componentActive.value) return
|
||||
|
||||
const button = buildDynamicButton()
|
||||
|
||||
if (!button.show) {
|
||||
cleanupDynamicButton()
|
||||
return
|
||||
}
|
||||
|
||||
// 确保注册方法存在
|
||||
if (!registerDynamicButton) {
|
||||
// 尝试获取全局注册方法
|
||||
const tryUseGlobalMethod = () => {
|
||||
if (!componentActive.value) return false
|
||||
|
||||
if (typeof window !== 'undefined' && window.__VUE_INJECT_DYNAMIC_BUTTON__) {
|
||||
window.__VUE_INJECT_DYNAMIC_BUTTON__({
|
||||
icon,
|
||||
action: onClick,
|
||||
show: true,
|
||||
})
|
||||
window.__VUE_INJECT_DYNAMIC_BUTTON__(button)
|
||||
dynamicButtonRegistered.value = true
|
||||
return true
|
||||
}
|
||||
@@ -68,11 +121,9 @@ export function useDynamicButton(options: {
|
||||
|
||||
// 如果注册方法存在,直接注册
|
||||
nextTick(() => {
|
||||
registerDynamicButton({
|
||||
icon,
|
||||
action: onClick,
|
||||
show: true,
|
||||
})
|
||||
if (!componentActive.value) return
|
||||
|
||||
registerDynamicButton(button)
|
||||
dynamicButtonRegistered.value = true
|
||||
})
|
||||
}
|
||||
@@ -82,17 +133,24 @@ export function useDynamicButton(options: {
|
||||
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
|
||||
unregisterDynamicButton()
|
||||
dynamicButtonRegistered.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.__VUE_UNINJECT_DYNAMIC_BUTTON__) {
|
||||
window.__VUE_UNINJECT_DYNAMIC_BUTTON__()
|
||||
dynamicButtonRegistered.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法:手动打开对话框
|
||||
function openDialog() {
|
||||
onClick()
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
if (autoRegister) {
|
||||
onMounted(() => {
|
||||
componentActive.value = true
|
||||
// 延迟执行,确保Footer组件已加载
|
||||
setTimeout(() => {
|
||||
setupDynamicButton()
|
||||
@@ -100,18 +158,27 @@ export function useDynamicButton(options: {
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
componentActive.value = true
|
||||
// 重置注册状态,确保每次激活时都重新注册
|
||||
dynamicButtonRegistered.value = false
|
||||
setupDynamicButton()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
componentActive.value = false
|
||||
cleanupDynamicButton()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
componentActive.value = false
|
||||
cleanupDynamicButton()
|
||||
})
|
||||
|
||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
|
||||
if (!componentActive.value) return
|
||||
|
||||
setupDynamicButton()
|
||||
}, { deep: true })
|
||||
}
|
||||
|
||||
// 返回控制函数和状态
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,16 @@ export interface WizardData {
|
||||
username: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
recognizeSource: string
|
||||
ocrHost: string
|
||||
proxyHost: string
|
||||
githubToken: string
|
||||
}
|
||||
siteAuth: {
|
||||
auxiliaryAuthEnable: boolean
|
||||
site: string
|
||||
params: Record<string, string | number>
|
||||
}
|
||||
storage: {
|
||||
downloadPath: string
|
||||
libraryPath: string
|
||||
@@ -41,6 +48,33 @@ export interface WizardData {
|
||||
config: any
|
||||
switchs: any[]
|
||||
}
|
||||
agent: {
|
||||
enabled: boolean
|
||||
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
|
||||
recommendUserPreference: string
|
||||
recommendMaxItems: number
|
||||
}
|
||||
preferences: {
|
||||
quality: string
|
||||
subtitle: string
|
||||
@@ -67,9 +101,14 @@ export interface ConnectivityTestState {
|
||||
}
|
||||
|
||||
export interface ValidationErrorState {
|
||||
siteAuth: {
|
||||
site: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
downloader: {
|
||||
name: boolean
|
||||
host: boolean
|
||||
apikey: boolean
|
||||
username: boolean
|
||||
password: boolean
|
||||
}
|
||||
@@ -85,11 +124,45 @@ export interface ValidationErrorState {
|
||||
name: boolean
|
||||
[key: string]: boolean
|
||||
}
|
||||
agent: {
|
||||
provider: boolean
|
||||
apiKey: boolean
|
||||
model: boolean
|
||||
maxContextTokens: boolean
|
||||
recommendMaxItems: boolean
|
||||
}
|
||||
}
|
||||
|
||||
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 = 6
|
||||
const totalSteps = 8
|
||||
|
||||
// 加载状态
|
||||
const isLoading = ref(false)
|
||||
@@ -97,6 +170,22 @@ const isLoading = ref(false)
|
||||
// 选中的预设规则
|
||||
const selectedPreset = ref('')
|
||||
|
||||
// 可认证站点列表
|
||||
const authSites = ref<{
|
||||
[key: string]: {
|
||||
name: string
|
||||
icon: string
|
||||
params: {
|
||||
[key: string]: {
|
||||
name: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
tooltip?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}>({})
|
||||
|
||||
// 向导数据
|
||||
const wizardData = ref<WizardData>({
|
||||
basic: {
|
||||
@@ -105,9 +194,16 @@ const wizardData = ref<WizardData>({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
recognizeSource: 'themoviedb',
|
||||
ocrHost: '',
|
||||
proxyHost: '',
|
||||
githubToken: '',
|
||||
},
|
||||
siteAuth: {
|
||||
auxiliaryAuthEnable: false,
|
||||
site: '',
|
||||
params: {},
|
||||
},
|
||||
storage: {
|
||||
downloadPath: '',
|
||||
libraryPath: '',
|
||||
@@ -133,6 +229,33 @@ const wizardData = ref<WizardData>({
|
||||
config: {},
|
||||
switchs: [],
|
||||
},
|
||||
agent: {
|
||||
enabled: false,
|
||||
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,
|
||||
recommendUserPreference: '',
|
||||
recommendMaxItems: 50,
|
||||
},
|
||||
preferences: {
|
||||
quality: '4K',
|
||||
subtitle: 'chinese',
|
||||
@@ -151,9 +274,13 @@ const connectivityTest = ref<ConnectivityTestState>({
|
||||
|
||||
// 验证错误状态
|
||||
const validationErrors = ref<ValidationErrorState>({
|
||||
siteAuth: {
|
||||
site: false,
|
||||
},
|
||||
downloader: {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
},
|
||||
@@ -168,6 +295,13 @@ const validationErrors = ref<ValidationErrorState>({
|
||||
notification: {
|
||||
name: false,
|
||||
},
|
||||
agent: {
|
||||
provider: false,
|
||||
apiKey: false,
|
||||
model: false,
|
||||
maxContextTokens: false,
|
||||
recommendMaxItems: false,
|
||||
},
|
||||
})
|
||||
|
||||
export function useSetupWizard() {
|
||||
@@ -181,6 +315,7 @@ export function useSetupWizard() {
|
||||
downloader: {
|
||||
'qbittorrent': 'QbittorrentModule',
|
||||
'transmission': 'TransmissionModule',
|
||||
'rtorrent': 'RtorrentModule',
|
||||
},
|
||||
// 媒体服务器映射
|
||||
mediaServer: {
|
||||
@@ -188,6 +323,7 @@ export function useSetupWizard() {
|
||||
'jellyfin': 'JellyfinModule',
|
||||
'plex': 'PlexModule',
|
||||
'trimemedia': 'TrimeMediaModule',
|
||||
'ugreen': 'UgreenModule',
|
||||
},
|
||||
// 通知映射
|
||||
notification: {
|
||||
@@ -195,6 +331,7 @@ export function useSetupWizard() {
|
||||
'wechat': 'WechatModule',
|
||||
'slack': 'SlackModule',
|
||||
'synologychat': 'SynologyChatModule',
|
||||
'qqbot': 'QQBotModule',
|
||||
'vocechat': 'VoceChatModule',
|
||||
'webpush': 'WebPushModule',
|
||||
},
|
||||
@@ -203,20 +340,24 @@ export function useSetupWizard() {
|
||||
// 步骤标题
|
||||
const stepTitles = computed(() => [
|
||||
t('setupWizard.basic.title'),
|
||||
t('setupWizard.siteAuth.title'),
|
||||
t('setupWizard.storage.title'),
|
||||
t('setupWizard.downloader.title'),
|
||||
t('setupWizard.mediaServer.title'),
|
||||
t('setupWizard.notification.title'),
|
||||
t('setupWizard.agent.title'),
|
||||
t('setupWizard.preferences.title'),
|
||||
])
|
||||
|
||||
// 步骤描述
|
||||
const stepDescriptions = computed(() => [
|
||||
t('setupWizard.basic.description'),
|
||||
t('setupWizard.siteAuth.description'),
|
||||
t('setupWizard.storage.description'),
|
||||
t('setupWizard.downloader.description'),
|
||||
t('setupWizard.mediaServer.description'),
|
||||
t('setupWizard.notification.description'),
|
||||
t('setupWizard.agent.description'),
|
||||
t('setupWizard.preferences.description'),
|
||||
])
|
||||
|
||||
@@ -323,9 +464,13 @@ export function useSetupWizard() {
|
||||
|
||||
// 清除验证错误状态
|
||||
function clearValidationErrors() {
|
||||
validationErrors.value.siteAuth = {
|
||||
site: false,
|
||||
}
|
||||
validationErrors.value.downloader = {
|
||||
name: false,
|
||||
host: false,
|
||||
apikey: false,
|
||||
username: false,
|
||||
password: false,
|
||||
}
|
||||
@@ -340,6 +485,54 @@ export function useSetupWizard() {
|
||||
validationErrors.value.notification = {
|
||||
name: false,
|
||||
}
|
||||
validationErrors.value.agent = {
|
||||
provider: false,
|
||||
apiKey: false,
|
||||
model: false,
|
||||
maxContextTokens: false,
|
||||
recommendMaxItems: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 验证用户站点认证字段
|
||||
function validateSiteAuthFields(): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
clearValidationErrors()
|
||||
|
||||
if (!wizardData.value.siteAuth.site) {
|
||||
return {
|
||||
isValid: true,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSite = authSites.value[wizardData.value.siteAuth.site]
|
||||
if (!selectedSite) {
|
||||
errors.push(t('setupWizard.siteAuth.siteConfigNotExist'))
|
||||
validationErrors.value.siteAuth.site = true
|
||||
return {
|
||||
isValid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
const fields = Object.keys(selectedSite.params || {}).filter(key => {
|
||||
return selectedSite.params[key]?.name && selectedSite.params[key]?.type
|
||||
})
|
||||
|
||||
fields.forEach(key => {
|
||||
const fieldKey = `${wizardData.value.siteAuth.site.toUpperCase()}_${key.toUpperCase()}`
|
||||
const value = wizardData.value.siteAuth.params[fieldKey]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
errors.push(t('setupWizard.siteAuth.fieldRequired', { name: selectedSite.params[key].name }))
|
||||
validationErrors.value.siteAuth[fieldKey] = true
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// 验证下载器字段
|
||||
@@ -360,7 +553,20 @@ 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()) {
|
||||
errors.push(t('downloader.usernameRequired'))
|
||||
validationErrors.value.downloader.username = true
|
||||
@@ -405,7 +611,7 @@ export function useSetupWizard() {
|
||||
errors.push(t('mediaserver.tokenRequired'))
|
||||
validationErrors.value.mediaServer.token = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'trimemedia') {
|
||||
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
|
||||
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||
errors.push(t('mediaserver.usernameRequired'))
|
||||
validationErrors.value.mediaServer.username = true
|
||||
@@ -486,6 +692,65 @@ export function useSetupWizard() {
|
||||
validationErrors.value.notification.VOCECHAT_API_KEY = true
|
||||
}
|
||||
break
|
||||
case 'webpush':
|
||||
if (!config.WEBPUSH_USERNAME?.trim()) {
|
||||
errors.push(t('notification.webpush.usernameRequired'))
|
||||
validationErrors.value.notification.WEBPUSH_USERNAME = true
|
||||
}
|
||||
break
|
||||
case 'qqbot':
|
||||
if (!config.QQ_APP_ID?.trim()) {
|
||||
errors.push(t('notification.qqbot.appIdRequired'))
|
||||
validationErrors.value.notification.QQ_APP_ID = true
|
||||
}
|
||||
if (!config.QQ_APP_SECRET?.trim()) {
|
||||
errors.push(t('notification.qqbot.appSecretRequired'))
|
||||
validationErrors.value.notification.QQ_APP_SECRET = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// 验证智能助手字段
|
||||
function validateAgentFields(): { isValid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
clearValidationErrors()
|
||||
|
||||
if (!wizardData.value.agent.enabled) {
|
||||
return {
|
||||
isValid: true,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.provider?.trim()) {
|
||||
errors.push(t('setupWizard.agent.providerRequired'))
|
||||
validationErrors.value.agent.provider = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
|
||||
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
|
||||
validationErrors.value.agent.apiKey = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.model?.trim()) {
|
||||
errors.push(t('setupWizard.agent.modelRequired'))
|
||||
validationErrors.value.agent.model = true
|
||||
}
|
||||
|
||||
if (!wizardData.value.agent.maxContextTokens || wizardData.value.agent.maxContextTokens < 1) {
|
||||
errors.push(t('setupWizard.agent.maxContextTokensRequired'))
|
||||
validationErrors.value.agent.maxContextTokens = true
|
||||
}
|
||||
|
||||
if (wizardData.value.agent.recommendEnabled && (!wizardData.value.agent.recommendMaxItems || wizardData.value.agent.recommendMaxItems < 1)) {
|
||||
errors.push(t('setupWizard.agent.recommendMaxItemsRequired'))
|
||||
validationErrors.value.agent.recommendMaxItems = true
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -520,6 +785,13 @@ export function useSetupWizard() {
|
||||
break
|
||||
|
||||
case 2: // 存储设置
|
||||
if (wizardData.value.siteAuth.site) {
|
||||
const validation = validateSiteAuthFields()
|
||||
errors.push(...validation.errors)
|
||||
}
|
||||
break
|
||||
|
||||
case 3: // 存储设置
|
||||
if (!wizardData.value.storage.downloadPath) {
|
||||
errors.push(t('setupWizard.storage.downloadPathRequired'))
|
||||
}
|
||||
@@ -528,7 +800,7 @@ export function useSetupWizard() {
|
||||
}
|
||||
break
|
||||
|
||||
case 3: // 下载器设置
|
||||
case 4: // 下载器设置
|
||||
if (wizardData.value.downloader.type) {
|
||||
// 如果选择了下载器,则验证必输项
|
||||
const validation = validateDownloaderFields()
|
||||
@@ -536,7 +808,7 @@ export function useSetupWizard() {
|
||||
}
|
||||
break
|
||||
|
||||
case 4: // 媒体服务器设置
|
||||
case 5: // 媒体服务器设置
|
||||
if (wizardData.value.mediaServer.type) {
|
||||
// 如果选择了媒体服务器,则验证必输项
|
||||
const validation = validateMediaServerFields()
|
||||
@@ -544,7 +816,7 @@ export function useSetupWizard() {
|
||||
}
|
||||
break
|
||||
|
||||
case 5: // 通知设置
|
||||
case 6: // 通知设置
|
||||
if (wizardData.value.notification.type) {
|
||||
// 如果选择了通知,则验证必输项
|
||||
const validation = validateNotificationFields()
|
||||
@@ -552,7 +824,14 @@ export function useSetupWizard() {
|
||||
}
|
||||
break
|
||||
|
||||
case 6: // 偏好设置
|
||||
case 7: // 智能助手设置
|
||||
if (wizardData.value.agent.enabled) {
|
||||
const validation = validateAgentFields()
|
||||
errors.push(...validation.errors)
|
||||
}
|
||||
break
|
||||
|
||||
case 8: // 偏好设置
|
||||
// 偏好设置有默认值,不需要验证
|
||||
break
|
||||
}
|
||||
@@ -567,12 +846,14 @@ export function useSetupWizard() {
|
||||
function shouldPerformTest(step: number): boolean {
|
||||
switch (step) {
|
||||
case 2: // 存储目录测试 - 总是需要测试
|
||||
return false
|
||||
case 3: // 存储目录测试 - 总是需要测试
|
||||
return true
|
||||
case 3: // 下载器测试 - 只有选择了下载器才测试
|
||||
case 4: // 下载器测试 - 只有选择了下载器才测试
|
||||
return !!wizardData.value.downloader.type
|
||||
case 4: // 媒体服务器测试 - 只有选择了媒体服务器才测试
|
||||
case 5: // 媒体服务器测试 - 只有选择了媒体服务器才测试
|
||||
return !!wizardData.value.mediaServer.type
|
||||
case 5: // 消息通知测试 - 只有选择了通知才测试
|
||||
case 6: // 消息通知测试 - 只有选择了通知才测试
|
||||
return !!wizardData.value.notification.type
|
||||
default:
|
||||
return false
|
||||
@@ -592,15 +873,17 @@ export function useSetupWizard() {
|
||||
|
||||
switch (step) {
|
||||
case 2: // 存储目录测试
|
||||
break
|
||||
case 3: // 存储目录测试
|
||||
testResult = await testStorageConnectivity()
|
||||
break
|
||||
case 3: // 下载器测试
|
||||
case 4: // 下载器测试
|
||||
testResult = await testDownloaderConnectivity()
|
||||
break
|
||||
case 4: // 媒体服务器测试
|
||||
case 5: // 媒体服务器测试
|
||||
testResult = await testMediaServerConnectivity()
|
||||
break
|
||||
case 5: // 消息通知测试
|
||||
case 6: // 消息通知测试
|
||||
testResult = await testNotificationConnectivity()
|
||||
break
|
||||
}
|
||||
@@ -783,18 +1066,21 @@ export function useSetupWizard() {
|
||||
validation.errors.forEach(error => {
|
||||
$toast.error(error)
|
||||
})
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 保存当前步骤的设置
|
||||
await saveCurrentStepSettings()
|
||||
const saved = await saveCurrentStepSettings()
|
||||
if (!saved) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要进行测试
|
||||
const needsTest = shouldPerformTest(currentStep.value)
|
||||
if (needsTest) {
|
||||
const testResult = await testConnectivity(currentStep.value)
|
||||
if (!testResult) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,6 +1089,8 @@ export function useSetupWizard() {
|
||||
currentStep.value++
|
||||
connectivityTest.value.showResult = false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 上一步
|
||||
@@ -818,35 +1106,38 @@ export function useSetupWizard() {
|
||||
try {
|
||||
switch (currentStep.value) {
|
||||
case 1:
|
||||
await saveBasicSettings()
|
||||
break
|
||||
return await saveBasicSettings()
|
||||
case 2:
|
||||
await saveStorageSettings()
|
||||
break
|
||||
return await saveSiteAuthSettings()
|
||||
case 3:
|
||||
await saveDownloaderSettings()
|
||||
break
|
||||
return await saveStorageSettings()
|
||||
case 4:
|
||||
await saveMediaServerSettings()
|
||||
break
|
||||
return await saveDownloaderSettings()
|
||||
case 5:
|
||||
await saveNotificationSettings()
|
||||
break
|
||||
return await saveMediaServerSettings()
|
||||
case 6:
|
||||
await savePreferenceSettings()
|
||||
break
|
||||
return await saveNotificationSettings()
|
||||
case 7:
|
||||
return await saveAgentSettings()
|
||||
case 8:
|
||||
return await savePreferenceSettings()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save current step settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveStepFailed'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 完成向导
|
||||
async function completeWizard() {
|
||||
try {
|
||||
// 先处理下一步(保存当前步骤设置)
|
||||
await nextStep()
|
||||
const saved = await nextStep()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
// 保存设置向导完成状态
|
||||
await saveSetupWizardState()
|
||||
|
||||
@@ -899,6 +1190,8 @@ export function useSetupWizard() {
|
||||
const basicSettings = {
|
||||
APP_DOMAIN: wizardData.value.basic.appDomain,
|
||||
API_TOKEN: wizardData.value.basic.apiToken,
|
||||
RECOGNIZE_SOURCE: 'themoviedb',
|
||||
OCR_HOST: wizardData.value.basic.ocrHost,
|
||||
PROXY_HOST: wizardData.value.basic.proxyHost,
|
||||
GITHUB_TOKEN: wizardData.value.basic.githubToken,
|
||||
}
|
||||
@@ -906,21 +1199,23 @@ export function useSetupWizard() {
|
||||
// 保存基础设置
|
||||
const response: { [key: string]: any } = await api.post('system/env', basicSettings)
|
||||
if (!response.success) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果输入了密码,验证密码一致性
|
||||
if (wizardData.value.basic.password) {
|
||||
if (wizardData.value.basic.password !== wizardData.value.basic.confirmPassword) {
|
||||
$toast.error(t('dialog.userAddEdit.passwordMismatch'))
|
||||
return
|
||||
return false
|
||||
}
|
||||
// 更新用户密码
|
||||
await updateUserPassword()
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save basic settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveBasicSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,9 +1254,44 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
await api.post('system/setting/Directories', [directory])
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save storage settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveStorageSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户站点认证设置
|
||||
async function saveSiteAuthSettings() {
|
||||
try {
|
||||
const envResponse: { [key: string]: any } = await api.post('system/env', {
|
||||
AUXILIARY_AUTH_ENABLE: wizardData.value.siteAuth.auxiliaryAuthEnable,
|
||||
})
|
||||
|
||||
if (!envResponse.success) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!wizardData.value.siteAuth.site) {
|
||||
return true
|
||||
}
|
||||
|
||||
const response: { [key: string]: any } = await api.post('site/auth', {
|
||||
site: wizardData.value.siteAuth.site,
|
||||
params: wizardData.value.siteAuth.params,
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: response.message }))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save site auth settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveSiteAuthSettingsFailed', { message: (error as Error).message || '' }))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,13 +1311,16 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
await api.post('system/setting/Downloaders', [downloader])
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save downloader settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveDownloaderSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择下载器时,清空现有配置
|
||||
console.log('No downloader selected, skipping save')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,13 +1341,16 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
await api.post('system/setting/MediaServers', [mediaServer])
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save media server settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveMediaServerSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择媒体服务器时,清空现有配置
|
||||
console.log('No media server selected, skipping save')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1035,13 +1371,56 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
await api.post('system/setting/Notifications', [notification])
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save notification settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveNotificationSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择通知时,清空现有配置
|
||||
console.log('No notification selected, skipping save')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 保存智能助手设置
|
||||
async function saveAgentSettings() {
|
||||
try {
|
||||
const agentSettings = {
|
||||
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
|
||||
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
|
||||
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:
|
||||
wizardData.value.agent.enabled && wizardData.value.agent.recommendEnabled,
|
||||
AI_RECOMMEND_USER_PREFERENCE: wizardData.value.agent.recommendUserPreference,
|
||||
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
|
||||
}
|
||||
|
||||
await api.post('system/env', agentSettings)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save agent settings failed:', error)
|
||||
$toast.error(t('setupWizard.saveAgentSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1070,9 +1449,11 @@ export function useSetupWizard() {
|
||||
console.error('Save rule sequences failed:', error)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save preference settings failed:', error)
|
||||
$toast.error(t('setupWizard.savePreferenceSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,12 +1485,41 @@ export function useSetupWizard() {
|
||||
if (result.data.PROXY_HOST) {
|
||||
wizardData.value.basic.proxyHost = result.data.PROXY_HOST
|
||||
}
|
||||
if (result.data.OCR_HOST) {
|
||||
wizardData.value.basic.ocrHost = result.data.OCR_HOST
|
||||
}
|
||||
if (result.data.GITHUB_TOKEN) {
|
||||
wizardData.value.basic.githubToken = result.data.GITHUB_TOKEN
|
||||
}
|
||||
wizardData.value.siteAuth.auxiliaryAuthEnable = Boolean(result.data.AUXILIARY_AUTH_ENABLE)
|
||||
if (result.data.SUPERUSER) {
|
||||
wizardData.value.basic.username = result.data.SUPERUSER
|
||||
}
|
||||
wizardData.value.agent.enabled = Boolean(result.data.AI_AGENT_ENABLE)
|
||||
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)
|
||||
wizardData.value.agent.recommendUserPreference = result.data.AI_RECOMMEND_USER_PREFERENCE || ''
|
||||
wizardData.value.agent.recommendMaxItems = result.data.AI_RECOMMEND_MAX_ITEMS || 50
|
||||
|
||||
// 如果没有API Token,则创建一个随机的
|
||||
if (!wizardData.value.basic.apiToken) {
|
||||
@@ -1121,6 +1531,28 @@ export function useSetupWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户站点认证列表
|
||||
async function loadAuthSites() {
|
||||
try {
|
||||
authSites.value = (await api.get('site/auth')) || {}
|
||||
} catch (error) {
|
||||
console.log('Load auth sites failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户站点认证设置
|
||||
async function loadSiteAuthSettings() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/UserSiteAuthParams')
|
||||
if (result.success && result.data?.value) {
|
||||
wizardData.value.siteAuth.site = result.data.value.site || ''
|
||||
wizardData.value.siteAuth.params = result.data.value.params || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Load site auth settings failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载存储设置
|
||||
async function loadStorageSettings() {
|
||||
try {
|
||||
@@ -1190,6 +1622,8 @@ export function useSetupWizard() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await loadSystemSettings()
|
||||
await loadAuthSites()
|
||||
await loadSiteAuthSettings()
|
||||
await loadStorageSettings()
|
||||
await loadDownloaderSettings()
|
||||
await loadMediaServerSettings()
|
||||
@@ -1206,6 +1640,7 @@ export function useSetupWizard() {
|
||||
stepTitles,
|
||||
stepDescriptions,
|
||||
wizardData,
|
||||
authSites,
|
||||
selectedPreset,
|
||||
connectivityTest,
|
||||
validationErrors,
|
||||
@@ -1220,9 +1655,11 @@ export function useSetupWizard() {
|
||||
selectPreset,
|
||||
updatePreferences,
|
||||
validateCurrentStep,
|
||||
validateSiteAuthFields,
|
||||
validateDownloaderFields,
|
||||
validateMediaServerFields,
|
||||
validateNotificationFields,
|
||||
validateAgentFields,
|
||||
clearValidationErrors,
|
||||
testConnectivity,
|
||||
nextStep,
|
||||
|
||||
@@ -9,8 +9,9 @@ import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -30,6 +31,7 @@ const route = useRoute()
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 响应式的超级用户状态
|
||||
const superUser = computed(() => userStore.superUser)
|
||||
@@ -229,7 +231,34 @@ function handlePluginClick() {
|
||||
showPluginQuickAccess.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
function appendPluginSidebarMenus() {
|
||||
for (const { navMenu, section } of filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
t,
|
||||
userPermissions.value,
|
||||
)) {
|
||||
switch (section) {
|
||||
case 'start':
|
||||
startMenus.value.push(navMenu)
|
||||
break
|
||||
case 'discovery':
|
||||
discoveryMenus.value.push(navMenu)
|
||||
break
|
||||
case 'subscribe':
|
||||
subscribeMenus.value.push(navMenu)
|
||||
break
|
||||
case 'organize':
|
||||
organizeMenus.value.push(navMenu)
|
||||
break
|
||||
case 'system':
|
||||
default:
|
||||
systemMenus.value.push(navMenu)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||
@@ -237,6 +266,9 @@ onMounted(() => {
|
||||
organizeMenus.value = getMenuList(t('menu.organize'))
|
||||
systemMenus.value = getMenuList(t('menu.system'))
|
||||
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听全局未读消息事件
|
||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
|
||||
// 是否显示的输入参数
|
||||
defineProps({
|
||||
@@ -120,6 +121,7 @@ interface DynamicButton {
|
||||
action: () => void
|
||||
show: boolean
|
||||
routePath?: string // 添加路径属性,用于标识哪个路由注册的
|
||||
menuItems?: DynamicButtonMenuItem[]
|
||||
}
|
||||
|
||||
// 提供动态按钮注册和获取的方法
|
||||
@@ -141,11 +143,13 @@ const unregisterDynamicButton = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 确保在浏览器环境中
|
||||
;(window as any).__VUE_INJECT_DYNAMIC_BUTTON__ = registerDynamicButton
|
||||
;(window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__ = unregisterDynamicButton
|
||||
}
|
||||
|
||||
// 提供给其他组件使用
|
||||
provide('registerDynamicButton', registerDynamicButton)
|
||||
provide('unregisterDynamicButton', unregisterDynamicButton)
|
||||
provide('dynamicButton', dynamicButton)
|
||||
|
||||
// 在组件销毁时清理
|
||||
onUnmounted(() => {
|
||||
@@ -153,6 +157,7 @@ onUnmounted(() => {
|
||||
// 清理全局方法
|
||||
if (typeof window !== 'undefined') {
|
||||
delete (window as any).__VUE_INJECT_DYNAMIC_BUTTON__
|
||||
delete (window as any).__VUE_UNINJECT_DYNAMIC_BUTTON__
|
||||
}
|
||||
})
|
||||
|
||||
@@ -165,6 +170,30 @@ const showDynamicButton = computed(() => {
|
||||
(!dynamicButton.value.routePath || dynamicButton.value.routePath === route.path)
|
||||
)
|
||||
})
|
||||
|
||||
const hasDynamicButtonMenu = computed(() => Boolean(dynamicButton.value?.menuItems?.length))
|
||||
|
||||
const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
|
||||
'components.subscribeHistory.title': 'dialog.subscribeHistory.title',
|
||||
'components.subscribeEdit.titleDefault': 'dialog.subscribeEdit.titleDefault',
|
||||
'components.transferQueue.title': 'dialog.transferQueue.title',
|
||||
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
|
||||
}
|
||||
|
||||
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
|
||||
if (item.titleKey) {
|
||||
return t(item.titleKey, item.titleParams as any)
|
||||
}
|
||||
|
||||
if (!item.title) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizedTitleKey = legacyDynamicMenuTitleKeyMap[item.title] || item.title
|
||||
const looksLikeI18nKey = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/i.test(normalizedTitleKey)
|
||||
|
||||
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -223,16 +252,37 @@ const showDynamicButton = computed(() => {
|
||||
>
|
||||
<VCardText class="footer-card-content">
|
||||
<!-- 各页面的动态按钮 -->
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="dynamicButton?.action()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
<VIcon color="secondary" :icon="dynamicButton?.icon || 'mdi-plus'" size="28"></VIcon>
|
||||
</VBtn>
|
||||
<div class="dynamic-btn-activator">
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
:ripple="false"
|
||||
@click="!hasDynamicButtonMenu && dynamicButton?.action()"
|
||||
rounded="pill"
|
||||
class="footer-nav-btn"
|
||||
>
|
||||
<VIcon
|
||||
color="secondary"
|
||||
:icon="hasDynamicButtonMenu ? 'mdi-chevron-up' : dynamicButton?.icon || 'mdi-plus'"
|
||||
size="28"
|
||||
></VIcon>
|
||||
</VBtn>
|
||||
<VMenu v-if="hasDynamicButtonMenu" activator="parent" location="top end" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(item, index) in dynamicButton?.menuItems"
|
||||
:key="item.titleKey || item.title || index"
|
||||
:base-color="item.color"
|
||||
@click="item.action()"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon v-if="item.icon" :icon="item.icon" />
|
||||
</template>
|
||||
<VListItemTitle>{{ resolveDynamicMenuItemTitle(item) }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</TransitionGroup>
|
||||
@@ -271,7 +321,7 @@ const showDynamicButton = computed(() => {
|
||||
background-color: rgba(var(--v-theme-surface), 0.6);
|
||||
pointer-events: auto;
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
will-change: transform, max-width, opacity;
|
||||
will-change: transform, max-inline-size, opacity;
|
||||
|
||||
// 透明主题下的特殊样式
|
||||
.v-theme--transparent & {
|
||||
@@ -335,8 +385,8 @@ const showDynamicButton = computed(() => {
|
||||
.dynamic-btn-card {
|
||||
block-size: auto;
|
||||
inline-size: auto;
|
||||
max-inline-size: 60px;
|
||||
min-block-size: 0;
|
||||
max-width: 60px;
|
||||
|
||||
.footer-card-content {
|
||||
padding: 3px;
|
||||
@@ -361,17 +411,17 @@ const showDynamicButton = computed(() => {
|
||||
// 底部导航动画
|
||||
.footer-nav-enter-active,
|
||||
.footer-nav-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.footer-nav-enter-from,
|
||||
.footer-nav-leave-to {
|
||||
opacity: 0;
|
||||
max-width: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
border-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-width: 0 !important;
|
||||
margin-inline-start: 0 !important;
|
||||
max-inline-size: 0 !important;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,25 +27,65 @@ const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 👉 Search Icon -->
|
||||
<div class="d-flex align-center cursor-pointer ms-lg-n2" style="user-select: none">
|
||||
<IconBtn @click="openSearchDialog">
|
||||
<VIcon icon="ri-search-line" />
|
||||
</IconBtn>
|
||||
<span v-if="display.lgAndUp.value" class="flex align-center text-disabled ms-2" @click="openSearchDialog">
|
||||
<span class="me-3">{{ t('common.search') }}</span>
|
||||
<span class="meta-key">{{ metaKey }}</span>
|
||||
</span>
|
||||
<!-- 小屏:仅图标按钮 -->
|
||||
<IconBtn v-if="!display.mdAndUp.value" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上:胶囊搜索触发栏 -->
|
||||
<div v-else class="search-trigger" @click="openSearchDialog">
|
||||
<VIcon icon="mdi-magnify" size="18" class="search-trigger-icon" />
|
||||
<span class="search-trigger-text">{{ t('common.search') }}</span>
|
||||
<kbd class="search-trigger-kbd">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
|
||||
<!-- 搜索弹窗 -->
|
||||
<SearchBarDialog v-model="searchDialog" v-if="searchDialog" @close="searchDialog = false" />
|
||||
</template>
|
||||
<style type="scss" scoped>
|
||||
.meta-key {
|
||||
border: thin solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 6px;
|
||||
block-size: 1.75rem;
|
||||
padding-block: 0.1rem;
|
||||
padding-inline: 0.25rem;
|
||||
|
||||
<style scoped>
|
||||
.search-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1.5px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 22px;
|
||||
block-size: 36px;
|
||||
cursor: pointer;
|
||||
padding-inline: 12px;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-trigger:hover {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.22);
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 4%);
|
||||
}
|
||||
|
||||
.search-trigger-icon {
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-trigger-text {
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
font-size: 13.5px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-trigger-kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 5px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
color: rgba(var(--v-theme-on-surface), 0.4);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
margin-inline-start: 4px;
|
||||
padding-block: 3px;
|
||||
padding-inline: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,19 @@ import ModuleTestView from '@/views/system/ModuleTestView.vue'
|
||||
import MessageView from '@/views/system/MessageView.vue'
|
||||
import WordsView from '@/views/system/WordsView.vue'
|
||||
import CacheView from '@/views/system/CacheView.vue'
|
||||
import AccountSettingService from '@/views/system/ServiceView.vue'
|
||||
import api from '@/api'
|
||||
import { useDisplay } from 'vuetify'
|
||||
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()
|
||||
|
||||
@@ -49,6 +56,9 @@ const wordsDialog = ref(false)
|
||||
// 缓存管理弹窗
|
||||
const cacheDialog = ref(false)
|
||||
|
||||
// 定时服务弹窗
|
||||
const schedulerDialog = ref(false)
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
@@ -59,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>()
|
||||
@@ -108,6 +118,13 @@ const shortcuts = [
|
||||
dialog: 'cache',
|
||||
dialogRef: cacheDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.scheduler.title'),
|
||||
subtitle: t('shortcut.scheduler.subtitle'),
|
||||
icon: 'mdi-list-box',
|
||||
dialog: 'scheduler',
|
||||
dialogRef: schedulerDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
@@ -142,9 +159,7 @@ async function openMessageDialog() {
|
||||
}, 600)
|
||||
// 等待对话框打开后恢复SSE连接
|
||||
nextTick(() => {
|
||||
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
|
||||
messageViewRef.value.resumeSSE()
|
||||
}
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,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()
|
||||
}
|
||||
@@ -275,10 +297,10 @@ onMounted(() => {
|
||||
item.dialog === 'message'
|
||||
? openMessageDialog()
|
||||
: item.dialog === 'words'
|
||||
? openDialog(item.dialogRef)
|
||||
: item.dialog === 'cache'
|
||||
? openDialog(item.dialogRef)
|
||||
: openDialog(item.dialogRef)
|
||||
? openDialog(item.dialogRef)
|
||||
: item.dialog === 'cache'
|
||||
? openDialog(item.dialogRef)
|
||||
: openDialog(item.dialogRef)
|
||||
"
|
||||
>
|
||||
<VAvatar variant="text" size="48" rounded="lg">
|
||||
@@ -339,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>
|
||||
@@ -361,7 +383,7 @@ onMounted(() => {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -420,6 +442,29 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 定时服务弹窗 -->
|
||||
<VDialog
|
||||
v-if="schedulerDialog"
|
||||
v-model="schedulerDialog"
|
||||
max-width="60rem"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-list-box" class="me-2" />
|
||||
{{ t('shortcut.scheduler.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
|
||||
<VDialogCloseBtn @click="schedulerDialog = false" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<AccountSettingService />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
<!-- 系统健康检查弹窗 -->
|
||||
<VDialog
|
||||
v-if="systemTestDialog"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
unsubscribe: 'Unsubscribe',
|
||||
media: 'Media',
|
||||
unknown: 'Unknown',
|
||||
notFetched: 'Not Fetched',
|
||||
notice: 'Notice',
|
||||
itemsPerPage: 'Items per page',
|
||||
pageText: '{0}-{1} of {2}',
|
||||
@@ -73,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',
|
||||
@@ -89,6 +93,7 @@ export default {
|
||||
mediaServer: 'Media Server',
|
||||
manual: 'Manual',
|
||||
plugin: 'Plugin',
|
||||
agent: 'Agent',
|
||||
other: 'Other',
|
||||
},
|
||||
actionStep: {
|
||||
@@ -256,6 +261,7 @@ export default {
|
||||
serverError: 'Login failed, server error!',
|
||||
loginFailed: 'Login Failed',
|
||||
secondaryVerification: 'Secondary Verification',
|
||||
orDivider: 'OR',
|
||||
loginWithPasskey: 'Login with Passkey',
|
||||
loginWithOtp: 'Login with OTP',
|
||||
orUsePasskey: 'Or use Passkey for verification',
|
||||
@@ -314,7 +320,8 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: 'System',
|
||||
description: 'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex)',
|
||||
description:
|
||||
'Basic settings, downloaders (Qbittorrent, Transmission), media servers (Emby, Jellyfin, Plex, TrimeMedia, Ugreen)',
|
||||
},
|
||||
directory: {
|
||||
title: 'Storage & Directories',
|
||||
@@ -433,6 +440,8 @@ export default {
|
||||
config: 'Configuration',
|
||||
wechat: {
|
||||
name: 'WeChat Work',
|
||||
useBotMode: 'Use AI Bot',
|
||||
useBotModeHint: 'Enable WebSocket bot mode with fixed dmPolicy=open and groupPolicy=disabled',
|
||||
corpId: 'Corp ID',
|
||||
corpIdHint: 'Corp ID in WeChat Work backend enterprise information',
|
||||
corpIdRequired: 'Corp ID cannot be empty',
|
||||
@@ -449,6 +458,16 @@ export default {
|
||||
tokenHint: 'Token in WeChat Work self-built app -> API message receiving configuration',
|
||||
encodingAesKey: 'EncodingAESKey',
|
||||
encodingAesKeyHint: 'EncodingAESKey in WeChat Work self-built app -> API message receiving configuration',
|
||||
botId: 'Bot ID',
|
||||
botIdHint: 'Bot ID of the WeChat Work AI bot',
|
||||
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',
|
||||
botChatIdPlaceholder: 'userid or group:chatid',
|
||||
botWsUrl: 'WebSocket URL',
|
||||
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
|
||||
admins: 'Admin Whitelist',
|
||||
adminsHint: 'User IDs that can use admin menu and commands, separated by commas',
|
||||
adminsPlaceholder: 'User IDs list, separated by commas',
|
||||
@@ -519,6 +538,21 @@ export default {
|
||||
usernameHint: 'Only push messages to the corresponding logged-in user',
|
||||
usernameRequired: 'Username cannot be empty',
|
||||
},
|
||||
qqbot: {
|
||||
name: 'QQ',
|
||||
appId: 'App ID',
|
||||
appIdHint: 'QQ Open Platform bot App ID',
|
||||
appIdRequired: 'App ID cannot be empty',
|
||||
appSecret: 'App Secret',
|
||||
appSecretHint: 'QQ Open Platform bot App Secret',
|
||||
appSecretRequired: 'App Secret cannot be empty',
|
||||
openId: 'User OpenID',
|
||||
openIdHint: 'Default recipient openid (C2C), user must have interacted with bot before',
|
||||
openIdPlaceholder: '32-char hex',
|
||||
groupOpenId: 'Group OpenID',
|
||||
groupOpenIdHint: 'Default group openid (group chat), use either this or User OpenID',
|
||||
groupOpenIdPlaceholder: 'Group openid',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
title: 'Shortcuts',
|
||||
@@ -554,6 +588,10 @@ export default {
|
||||
title: 'Cache',
|
||||
subtitle: 'Manage Cache',
|
||||
},
|
||||
scheduler: {
|
||||
title: 'Services',
|
||||
subtitle: 'Scheduled Services',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: 'Action Components',
|
||||
@@ -963,6 +1001,7 @@ export default {
|
||||
aiRecommend: 'AI Recommendation',
|
||||
reRecommend: 'Regenerate Recommendation',
|
||||
aiRecommendError: 'AI Recommendation Failed',
|
||||
refreshSearch: 'Re-search',
|
||||
},
|
||||
browse: {
|
||||
actor: 'Actor',
|
||||
@@ -1198,6 +1237,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',
|
||||
@@ -1288,27 +1338,103 @@ 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.',
|
||||
llmApiKey: 'LLM API Key',
|
||||
llmApiKeyHint: 'API key from the LLM service provider for authentication',
|
||||
llmApiKeyPlaceholder: 'Please enter API key',
|
||||
llmBaseUrl: 'LLM Base URL',
|
||||
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
|
||||
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',
|
||||
llmTestFailedToastWithMessage: 'LLM test call failed: {message}',
|
||||
aiAgentGlobal: 'Global AI Assistant',
|
||||
aiAgentGlobalHint: 'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
|
||||
aiAgentGlobalHint:
|
||||
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
|
||||
aiAgentJobInterval: 'Scheduled Wake',
|
||||
aiAgentJobIntervalHint:
|
||||
'Set the check interval for scheduled wake. Select "Disabled" to disable scheduled tasks.',
|
||||
aiAgentVerbose: 'Verbose Mode',
|
||||
aiAgentVerboseHint: 'When enabled, tool call process will be displayed in AI agent responses',
|
||||
aiAgentJobIntervalDisabled: 'Disabled',
|
||||
aiAgentJobInterval1h: '1 Hour',
|
||||
aiAgentJobInterval3h: '3 Hours',
|
||||
aiAgentJobInterval6h: '6 Hours',
|
||||
aiAgentJobInterval12h: '12 Hours',
|
||||
aiAgentJobInterval24h: '24 Hours',
|
||||
aiAgentJobInterval1w: '1 Week',
|
||||
aiAgentJobInterval1M: '1 Month',
|
||||
advancedSettings: 'Advanced Settings',
|
||||
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
||||
downloaders: 'Downloaders',
|
||||
downloadersDesc: 'Only the default downloader will be used by default.',
|
||||
aiAgentRetryTransfer: 'AI Takeover on Transfer Failure',
|
||||
aiAgentRetryTransferHint:
|
||||
'When enabled, the AI assistant will automatically take over and retry when file transfer/organization fails, using AI capabilities to resolve recognition and matching issues.',
|
||||
aiRecommendEnabled: 'AI Search Recommendation',
|
||||
aiRecommendEnabledHint: 'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
|
||||
aiRecommendEnabledHint:
|
||||
'Enable AI search recommendation. When enabled, an AI recommendation button will be displayed on the search result page, recommending resources based on user preferences.',
|
||||
aiRecommendUserPreference: 'User Preference',
|
||||
aiRecommendUserPreferenceHint: 'Set user preferences for AI recommendation, e.g., 4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: 'AI Recommendation Analysis Limit',
|
||||
aiRecommendMaxItemsHint: 'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
|
||||
aiRecommendMaxItemsHint:
|
||||
'Limit the number of search results sent to the AI assistant for analysis. More items mean slower analysis and more token consumption. It is recommended to manually filter to a general range before using AI recommendation.',
|
||||
mediaServers: 'Media Servers',
|
||||
mediaServersDesc: 'All enabled media servers will be used.',
|
||||
trimeMedia: 'TrimeMedia',
|
||||
@@ -1316,6 +1442,7 @@ export default {
|
||||
media: 'Media',
|
||||
network: 'Network',
|
||||
log: 'Log',
|
||||
data: 'Data',
|
||||
lab: 'Lab',
|
||||
downloaderSaveSuccess: 'Downloader settings saved successfully',
|
||||
downloaderSaveFailed: 'Failed to save downloader settings!',
|
||||
@@ -1331,9 +1458,11 @@ export default {
|
||||
reloading: 'Applying configuration...',
|
||||
qbittorrent: 'Qbittorrent',
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: 'Ugreen',
|
||||
reloadSuccess: 'System configuration has taken effect',
|
||||
reloadFailed: 'Failed to reload system!',
|
||||
auxAuthEnable: 'User Auxiliary Authentication',
|
||||
@@ -1376,6 +1505,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',
|
||||
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',
|
||||
@@ -1405,8 +1540,26 @@ 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',
|
||||
pluginLocalRepoPathsHint:
|
||||
'Local plugin repository directories. Separate multiple directories with commas. Relative and absolute paths are supported.',
|
||||
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
|
||||
encodingDetectionPerformanceModeHint:
|
||||
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
|
||||
@@ -1448,6 +1601,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.',
|
||||
@@ -1490,6 +1644,11 @@ export default {
|
||||
episodeThumb: 'Thumb',
|
||||
scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}',
|
||||
scrapingSwitchSaveError: 'Scraping switch settings save failed',
|
||||
policy: {
|
||||
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',
|
||||
@@ -1587,6 +1746,7 @@ export default {
|
||||
synologyChat: 'SynologyChat',
|
||||
voceChat: 'VoceChat',
|
||||
webPush: 'WebPush',
|
||||
qq: 'QQ',
|
||||
custom: 'Custom Notification',
|
||||
},
|
||||
words: {
|
||||
@@ -1694,6 +1854,25 @@ export default {
|
||||
storageSaveSuccess: 'Storage settings saved successfully',
|
||||
storageSaveFailed: 'Failed to save storage settings!',
|
||||
},
|
||||
category: {
|
||||
title: 'Category Policy',
|
||||
subtitle: 'Configure media auto-categorization rules by type, language, region, etc.',
|
||||
movie: 'Movies',
|
||||
tv: 'TV Shows',
|
||||
name: 'Category Name (Directory)',
|
||||
genre: 'Genre',
|
||||
language: 'Language',
|
||||
languagePlaceholder: 'e.g., en,fr,zh (comma separated)',
|
||||
country: 'Country/Region',
|
||||
countryPlaceholder: 'e.g., US,CN,JP',
|
||||
year: 'Year',
|
||||
yearPlaceholder: 'e.g., 2023, 2020-2024',
|
||||
addMovie: 'Add Movie Category',
|
||||
addTv: 'Add TV Category',
|
||||
saveSuccess: 'Category policy saved successfully',
|
||||
loadFailed: 'Failed to load category configuration',
|
||||
saveFailed: 'Save failed: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: 'Custom Rules',
|
||||
customRulesDesc: 'Custom priority rule items',
|
||||
@@ -1923,7 +2102,7 @@ export default {
|
||||
},
|
||||
searchBar: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Search features, subscriptions, settings...',
|
||||
searchPlaceholder: 'Search movies, TV shows and more...',
|
||||
recentSearches: 'Recent Searches',
|
||||
noRecentSearches: 'No recent search history',
|
||||
functions: 'Functions',
|
||||
@@ -1943,6 +2122,9 @@ export default {
|
||||
searchInSites: 'Search for torrent resources in sites',
|
||||
relatedResources: 'Related Resources',
|
||||
searchTip: 'You can search for movies, TV shows, actors, resources, etc.',
|
||||
emptySearchHint: 'Enter keywords to search',
|
||||
escClose: 'Close',
|
||||
openSearch: 'Open search',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: 'Select Sites',
|
||||
@@ -1999,9 +2181,15 @@ export default {
|
||||
'Before sharing, please ensure the workflow does not contain sensitive information such as PassKey in RSS links to avoid information leakage.',
|
||||
},
|
||||
u115Auth: {
|
||||
loginTitle: '115 Cloud Login',
|
||||
scanQrCode: 'Please scan with WeChat or 115 client',
|
||||
scanned: 'Scanned, please confirm login',
|
||||
loginTitle: '115 Cloud Authorization',
|
||||
openAuthWindow: 'Open Authorization Window',
|
||||
authorizing: 'Please complete authorization in the new window...',
|
||||
authSuccess: 'Authorization successful!',
|
||||
authFailed: 'Authorization failed or expired',
|
||||
authCanceled: 'Authorization canceled, please try again',
|
||||
urlEmpty: 'Authorization URL is empty',
|
||||
urlFetchFailed: 'Failed to fetch authorization URL',
|
||||
popupBlocked: 'Unable to open authorization window, please check browser popup settings',
|
||||
complete: 'Complete',
|
||||
reset: 'Reset',
|
||||
},
|
||||
@@ -2154,6 +2342,10 @@ export default {
|
||||
repoUrl: 'Plugin Repository URL',
|
||||
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
|
||||
urlPlaceholder: 'Enter plugin repository URL',
|
||||
noRepos: 'No plugin repository URLs',
|
||||
invalidUrl: 'Please enter a valid URL',
|
||||
duplicateUrl: 'This URL already exists',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
saveSuccess: 'Plugin repository saved successfully',
|
||||
@@ -2504,6 +2696,7 @@ export default {
|
||||
settings: 'Settings',
|
||||
projectHome: 'Project Home',
|
||||
updateHistory: 'Update History',
|
||||
local: 'Local',
|
||||
installToLocal: 'Install to Local',
|
||||
totalDownloads: 'Total {count} downloads',
|
||||
viewData: 'View Data',
|
||||
@@ -2617,7 +2810,8 @@ export default {
|
||||
passkeyManagement: 'Passkey Management',
|
||||
registerNewPasskey: 'Register New Passkey',
|
||||
passkeyDescription: 'Passkeys allow you to sign in quickly and securely without a password.',
|
||||
passkeyAppDescription: 'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
|
||||
passkeyAppDescription:
|
||||
'Passkeys are a simpler, more secure way to sign in, serving as an alternative to passwords. You can authenticate using passkey-supported apps like iCloud Keychain, Bitwarden, or hardware keys.',
|
||||
passkeyName: 'Passkey Name',
|
||||
passkeyNamePlaceholder: 'e.g.: iPhone, Windows Hello',
|
||||
registerPasskey: 'Register Passkey',
|
||||
@@ -2631,8 +2825,10 @@ export default {
|
||||
passkeyDeleteSuccess: 'Passkey deleted',
|
||||
passkeyDeleteFailed: 'Delete failed',
|
||||
deletePasskey: 'Delete Passkey',
|
||||
passkeyDomainWarning: 'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
|
||||
otpRequiredForPasskey: 'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
|
||||
passkeyDomainWarning:
|
||||
'The availability of PassKeys is closely related to the {domain}. In a public network environment, please make sure to configure the correct access domain name in "Basic Settings". Domain changes or configuration errors will cause the PassKey to be unusable.',
|
||||
otpRequiredForPasskey:
|
||||
'For security reasons, you must first enable {otp} before you can register a PassKey. This is to ensure that you can still log in to your account via OTP code if the PassKey becomes invalid due to domain configuration changes.',
|
||||
accessDomain: 'access domain name',
|
||||
otpAuthenticator: 'OTP Authenticator',
|
||||
otpGenerateFailed: 'Failed to get OTP URI: {message}!',
|
||||
@@ -2641,8 +2837,10 @@ export default {
|
||||
otpCodeRequired: 'Please enter the 6-digit verification code',
|
||||
otpEnableSuccess: 'Two-factor authentication enabled successfully!',
|
||||
otpEnableFailed: 'Failed to enable OTP: {message}!',
|
||||
otpDisableRestrictedByPasskey: 'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
|
||||
confirmToDisableOtp: 'For security reasons, verifying your login password is required to disable two-factor authentication.',
|
||||
otpDisableRestrictedByPasskey:
|
||||
'You have registered Passkeys. Please delete all Passkeys before disabling OTP verification.',
|
||||
confirmToDisableOtp:
|
||||
'For security reasons, verifying your login password is required to disable two-factor authentication.',
|
||||
confirmToDeletePasskey: 'For security reasons, verifying your login password is required to delete a Passkey.',
|
||||
authenticatorAppDescription:
|
||||
'Use an authenticator app like Google Authenticator, Microsoft Authenticator, Authy, or 1Password to scan the QR code and generate a 6-digit code.',
|
||||
@@ -2688,10 +2886,19 @@ export default {
|
||||
loading: 'Loading...',
|
||||
pageSize: 'Items Per Page',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: 'Please enable the AI assistant in system settings first',
|
||||
aiRedoQueued: 'Assistant organize task submitted: {title}',
|
||||
aiRedoFailed: 'Failed to submit assistant organize task',
|
||||
actions: {
|
||||
aiRedo: 'Assistant Organize',
|
||||
aiRedoPending: 'Assistant Organizing...',
|
||||
batchAiRedo: 'Assistant Batch Organize',
|
||||
redo: 'Reorganize',
|
||||
delete: 'Delete',
|
||||
batchRedo: 'Batch Reorganize',
|
||||
batchDelete: 'Batch Delete',
|
||||
},
|
||||
batchOperationTitle: 'Batch Operation',
|
||||
progress: {
|
||||
processing: 'Processing',
|
||||
pleaseWait: 'Please wait...',
|
||||
@@ -2746,10 +2953,13 @@ export default {
|
||||
type: 'Type',
|
||||
enabled: 'Enabled',
|
||||
customTypeHint: 'Custom downloader type, for plugin scenarios',
|
||||
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',
|
||||
@@ -2815,6 +3025,15 @@ export default {
|
||||
password: 'Password',
|
||||
syncLibraries: 'Sync Libraries',
|
||||
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
||||
scanMode: 'Scan Mode',
|
||||
scanModeHint: 'Applies to full-library and targeted refresh: New & Modified / Supplement Missing / Full Override',
|
||||
verifySsl: 'Verify SSL Certificate',
|
||||
verifySslHint: 'When enabled, HTTPS certificates are verified; disable for self-signed certificates',
|
||||
scanModeOptions: {
|
||||
newAndModified: 'New & Modified',
|
||||
supplementMissing: 'Supplement Missing',
|
||||
fullOverride: 'Full Override',
|
||||
},
|
||||
hostRequired: 'Host cannot be empty',
|
||||
apiKeyRequired: 'API Key cannot be empty',
|
||||
tokenRequired: 'Token cannot be empty',
|
||||
@@ -3069,6 +3288,8 @@ export default {
|
||||
saveMediaServerSettingsFailed: 'Failed to save media server settings',
|
||||
notificationSettingsSaved: 'Notification settings saved successfully',
|
||||
saveNotificationSettingsFailed: 'Failed to save notification settings',
|
||||
saveSiteAuthSettingsFailed: 'Failed to save user site authentication settings: {message}',
|
||||
saveAgentSettingsFailed: 'Failed to save AI assistant settings',
|
||||
preferenceSettingsSaved: 'Preference settings saved successfully',
|
||||
savePreferenceSettingsFailed: 'Failed to save preference settings',
|
||||
passwordUpdateSuccess: 'Password updated successfully',
|
||||
@@ -3090,6 +3311,18 @@ export default {
|
||||
confirmPasswordHint: 'Confirm new password',
|
||||
apiTokenRequired: 'API Token is required',
|
||||
},
|
||||
siteAuth: {
|
||||
title: 'User Authentication',
|
||||
description: 'Configure site authentication and auxiliary authentication',
|
||||
info: 'User Site Authentication',
|
||||
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.',
|
||||
siteConfigNotExist: 'Authentication site configuration does not exist',
|
||||
fieldRequired: 'Please enter {name}',
|
||||
},
|
||||
storage: {
|
||||
title: 'Storage',
|
||||
description: 'Configure download directory and media library directory',
|
||||
@@ -3122,7 +3355,8 @@ export default {
|
||||
title: 'Media Server',
|
||||
description: 'Configure media server',
|
||||
info: 'Media Server Configuration',
|
||||
infoDesc: 'Configure media server for media library management, can choose Emby, Jellyfin or Plex etc.',
|
||||
infoDesc:
|
||||
'Configure media server for media library management, can choose Emby, Jellyfin, Plex, TrimeMedia or Ugreen.',
|
||||
type: 'Media Server Type',
|
||||
typeHint: 'Select the type of media server to use',
|
||||
name: 'Server Name',
|
||||
@@ -3153,6 +3387,19 @@ export default {
|
||||
senderPassword: 'Sender Password',
|
||||
receiverEmail: 'Receiver Email',
|
||||
},
|
||||
agent: {
|
||||
title: 'AI Assistant',
|
||||
description: 'Configure the Agent assistant and LLM parameters',
|
||||
info: 'AI Assistant Configuration',
|
||||
infoDesc:
|
||||
'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',
|
||||
},
|
||||
preferences: {
|
||||
title: 'Resource Preferences',
|
||||
description: 'Set resource download preferences',
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
unsubscribe: '取消订阅',
|
||||
media: '媒体',
|
||||
unknown: '未知',
|
||||
notFetched: '未获取',
|
||||
notice: '注意',
|
||||
itemsPerPage: '每页条数',
|
||||
pageText: '{0}-{1} 共 {2} 条',
|
||||
@@ -73,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||
clearCache: '清除缓存',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已进入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '电影',
|
||||
@@ -89,6 +93,7 @@ export default {
|
||||
mediaServer: '媒体服务器',
|
||||
manual: '手动处理',
|
||||
plugin: '插件',
|
||||
agent: '智能体',
|
||||
other: '其它',
|
||||
},
|
||||
actionStep: {
|
||||
@@ -255,6 +260,7 @@ export default {
|
||||
serverError: '登录失败,服务器错误!',
|
||||
loginFailed: '登录失败',
|
||||
secondaryVerification: '二次验证',
|
||||
orDivider: '或',
|
||||
loginWithPasskey: '使用通行密钥登录',
|
||||
loginWithOtp: '使用验证码登录',
|
||||
orUsePasskey: '或使用通行密钥进行验证',
|
||||
@@ -313,7 +319,7 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系统',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex)',
|
||||
description: '基础设置、下载器(Qbittorrent、Transmission)、媒体服务器(Emby、Jellyfin、Plex、飞牛影视、绿联影视)',
|
||||
},
|
||||
directory: {
|
||||
title: '存储 & 目录',
|
||||
@@ -432,6 +438,8 @@ export default {
|
||||
config: '配置',
|
||||
wechat: {
|
||||
name: '企业微信',
|
||||
useBotMode: '使用智能机器人',
|
||||
useBotModeHint: '开启后使用智能机器人长连接,固定 dmPolicy=open、groupPolicy=disabled',
|
||||
corpId: '企业ID',
|
||||
corpIdHint: '企业微信后台企业信息中的企业ID',
|
||||
corpIdRequired: '企业ID不能为空',
|
||||
@@ -447,6 +455,15 @@ export default {
|
||||
tokenHint: '微信企业自建应用->API接收消息配置中的Token',
|
||||
encodingAesKey: 'EncodingAESKey',
|
||||
encodingAesKeyHint: '微信企业自建应用->API接收消息配置中的EncodingAESKey',
|
||||
botId: '机器人 BotID',
|
||||
botIdHint: '企业微信智能机器人的 BotID',
|
||||
botSecret: '机器人 Secret',
|
||||
botSecretHint: '企业微信智能机器人长连接专用 Secret',
|
||||
botChatId: '默认通知目标',
|
||||
botChatIdHint: '可填写用户 userid;如需主动发群消息可填写 group:群聊chatid,不填则默认发给已互动用户',
|
||||
botChatIdPlaceholder: 'userid 或 group:chatid',
|
||||
botWsUrl: '长连接地址',
|
||||
botWsUrlHint: '企业微信智能机器人 WebSocket 地址,通常使用默认值',
|
||||
admins: '管理员白名单',
|
||||
adminsHint: '可使用管理菜单及命令的用户ID列表,多个ID使用,分隔',
|
||||
adminsPlaceholder: '用户ID列表,多个ID使用,分隔',
|
||||
@@ -517,6 +534,21 @@ export default {
|
||||
usernameHint: '只有对应的用户登录后才会推送消息',
|
||||
usernameRequired: '用户名不能为空',
|
||||
},
|
||||
qqbot: {
|
||||
name: 'QQ',
|
||||
appId: 'AppID',
|
||||
appIdHint: 'QQ 开放平台机器人 AppID',
|
||||
appIdRequired: 'AppID 不能为空',
|
||||
appSecret: 'AppSecret',
|
||||
appSecretHint: 'QQ 开放平台机器人 AppSecret',
|
||||
appSecretRequired: 'AppSecret 不能为空',
|
||||
openId: '用户 OpenID',
|
||||
openIdHint: '默认接收者 openid(单聊),用户需曾与机器人交互过',
|
||||
openIdPlaceholder: '32位十六进制',
|
||||
groupOpenId: '群组 OpenID',
|
||||
groupOpenIdHint: '默认群组 openid(群聊),与用户 OpenID 二选一',
|
||||
groupOpenIdPlaceholder: '群组 openid',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
title: '捷径',
|
||||
@@ -552,6 +584,10 @@ export default {
|
||||
title: '缓存',
|
||||
subtitle: '管理缓存',
|
||||
},
|
||||
scheduler: {
|
||||
title: '服务',
|
||||
subtitle: '定时服务',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: '动作组件',
|
||||
@@ -960,6 +996,7 @@ export default {
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
refreshSearch: '重新搜索',
|
||||
},
|
||||
browse: {
|
||||
actor: '演员',
|
||||
@@ -1195,6 +1232,17 @@ export default {
|
||||
content: '内容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日志内容',
|
||||
allLevels: '全部级别',
|
||||
followTail: '跟随最新日志',
|
||||
wrapLines: '自动换行',
|
||||
pauseStream: '暂停日志流',
|
||||
resumeStream: '恢复日志流',
|
||||
waitingForLogs: '等待日志输出...',
|
||||
paused: '已暂停',
|
||||
connected: '实时更新中',
|
||||
lineCount: '显示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1284,27 +1332,95 @@ 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 限制',
|
||||
llmApiKey: 'LLM API密钥',
|
||||
llmApiKeyHint: 'LLM服务提供商的API密钥,用于身份验证',
|
||||
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 调用测试失败',
|
||||
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
|
||||
aiAgentJobInterval: '定时唤醒',
|
||||
aiAgentJobIntervalHint: '设置定时唤醒的检查间隔,选择"不启用"则不执行定时任务',
|
||||
aiAgentVerbose: '啰嗦模式',
|
||||
aiAgentVerboseHint: '开启后会在智能体回复时显示工具调用过程',
|
||||
aiAgentJobIntervalDisabled: '不启用',
|
||||
aiAgentJobInterval1h: '1小时',
|
||||
aiAgentJobInterval3h: '3小时',
|
||||
aiAgentJobInterval6h: '6小时',
|
||||
aiAgentJobInterval12h: '12小时',
|
||||
aiAgentJobInterval24h: '24小时',
|
||||
aiAgentJobInterval1w: '1周',
|
||||
aiAgentJobInterval1M: '1个月',
|
||||
advancedSettings: '高级设置',
|
||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||
downloaders: '下载器',
|
||||
downloadersDesc: '只有默认下载器才会被默认使用。',
|
||||
aiAgentRetryTransfer: '文件整理失败智能接管',
|
||||
aiAgentRetryTransferHint:
|
||||
'启用后,当文件整理失败时,智能助手将自动接管并尝试重新整理,利用AI能力解决识别和匹配问题',
|
||||
aiRecommendEnabled: '搜索结果智能推荐',
|
||||
aiRecommendEnabledHint: '启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
|
||||
aiRecommendEnabledHint:
|
||||
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
|
||||
aiRecommendUserPreference: '用户偏好',
|
||||
aiRecommendUserPreferenceHint: '设置智能推荐时的用户偏好,例如:4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: '智能推荐分析条目上限',
|
||||
aiRecommendMaxItemsHint: '限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
|
||||
aiRecommendMaxItemsHint:
|
||||
'限制发送给智能助手进行分析的搜索结果数量,数量越多分析越慢且消耗 Token 越多,建议先手动筛选,筛选出大致范围后再进行智能推荐',
|
||||
mediaServers: '媒体服务器',
|
||||
mediaServersDesc: '所有启用的媒体服务器都会被使用。',
|
||||
trimeMedia: '飞牛影视',
|
||||
@@ -1312,6 +1428,7 @@ export default {
|
||||
media: '媒体',
|
||||
network: '网络',
|
||||
log: '日志',
|
||||
data: '数据',
|
||||
lab: '实验室',
|
||||
downloaderSaveSuccess: '下载器设置保存成功',
|
||||
downloaderSaveFailed: '下载器设置保存失败!',
|
||||
@@ -1327,9 +1444,11 @@ export default {
|
||||
reloading: '正在应用配置...',
|
||||
qbittorrent: 'Qbittorrent',
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '绿联影视',
|
||||
reloadSuccess: '系统配置已生效',
|
||||
reloadFailed: '重载系统失败!',
|
||||
auxAuthEnable: '用户辅助认证',
|
||||
@@ -1369,6 +1488,10 @@ export default {
|
||||
fanartEnableHint: '使用 fanart.tv 的图片数据',
|
||||
fanartLang: 'Fanart语言',
|
||||
fanartLangHint: '设置Fanart图片的语言偏好,多选时按优先级顺序排列',
|
||||
recognizePluginFirst: "优先使用插件识别",
|
||||
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
|
||||
mediaRecognizeShare: '共享使用媒体识别数据',
|
||||
mediaRecognizeShareHint: '识别成功后上报关键字与媒体ID,识别失败时优先回查共享识别结果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github访问速度',
|
||||
@@ -1397,8 +1520,23 @@ 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: '本地插件仓库路径',
|
||||
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
|
||||
encodingDetectionPerformanceMode: '编码探测性能模式',
|
||||
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
|
||||
transferThreads: '文件整理线程数',
|
||||
@@ -1438,6 +1576,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小时',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定义壁纸API地址',
|
||||
customizeWallpaperApiHint: '会获取API返回内容中所有允许的安全域名地址的图片,需要同步设置安全域名地址',
|
||||
customizeWallpaperApiRequired: '必填项;请输入自定义壁纸API',
|
||||
@@ -1479,6 +1618,11 @@ export default {
|
||||
episodeThumb: '缩略图',
|
||||
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
|
||||
scrapingSwitchSaveError: '刮削开关设置保存失败',
|
||||
policy: {
|
||||
skipDesc: '跳过刮削,不生成该文件',
|
||||
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
|
||||
overwriteDesc: '始终刮削,已存在则覆盖',
|
||||
}
|
||||
},
|
||||
site: {
|
||||
siteSync: '站点同步',
|
||||
@@ -1573,6 +1717,7 @@ export default {
|
||||
synologyChat: 'SynologyChat',
|
||||
voceChat: 'VoceChat',
|
||||
webPush: 'WebPush',
|
||||
qq: 'QQ',
|
||||
custom: '自定义通知',
|
||||
},
|
||||
words: {
|
||||
@@ -1672,6 +1817,25 @@ export default {
|
||||
storageSaveSuccess: '存储设置保存成功',
|
||||
storageSaveFailed: '存储设置保存失败!',
|
||||
},
|
||||
category: {
|
||||
title: '分类策略',
|
||||
subtitle: '配置媒体自动分类规则,按类型、语言、地区等条件自动归类',
|
||||
movie: '电影 (Movie)',
|
||||
tv: '电视剧 (TV)',
|
||||
name: '分类名称 (目录名)',
|
||||
genre: '内容类型 (Genre)',
|
||||
language: '语种 (Language)',
|
||||
languagePlaceholder: '如: zh,cn,en (使用逗号分隔)',
|
||||
country: '国家/地区 (Country)',
|
||||
countryPlaceholder: '如: US,CN,JP',
|
||||
year: '年份 (Year)',
|
||||
yearPlaceholder: '如: 2023, 2020-2024',
|
||||
addMovie: '添加电影分类',
|
||||
addTv: '添加电视剧分类',
|
||||
saveSuccess: '分类策略保存成功',
|
||||
loadFailed: '加载分类配置失败',
|
||||
saveFailed: '保存失败: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: '自定义规则',
|
||||
customRulesDesc: '自定义优先级规则项',
|
||||
@@ -1898,7 +2062,7 @@ export default {
|
||||
},
|
||||
searchBar: {
|
||||
search: '搜索',
|
||||
searchPlaceholder: '搜索功能、订阅、设置...',
|
||||
searchPlaceholder: '搜索电影、剧集以及更多...',
|
||||
recentSearches: '最近搜索',
|
||||
noRecentSearches: '没有最近搜索记录',
|
||||
functions: '功能',
|
||||
@@ -1918,6 +2082,9 @@ export default {
|
||||
searchInSites: '在站点中搜索种子资源',
|
||||
relatedResources: '相关资源',
|
||||
searchTip: '可搜索电影、电视剧、演员、资源等',
|
||||
emptySearchHint: '输入关键字开始搜索',
|
||||
escClose: '关闭',
|
||||
openSearch: '打开搜索',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: '选择站点',
|
||||
@@ -1971,9 +2138,15 @@ export default {
|
||||
securityWarningMessage: '分享前请确保工作流没有敏感信息,比如RSS链接中的PassKey等,避免产生信息泄露。',
|
||||
},
|
||||
u115Auth: {
|
||||
loginTitle: '115网盘登录',
|
||||
scanQrCode: '请使用微信或115客户端扫码',
|
||||
scanned: '已扫码,请确认登录',
|
||||
loginTitle: '115网盘授权',
|
||||
openAuthWindow: '打开授权窗口',
|
||||
authorizing: '请在新窗口中完成授权...',
|
||||
authSuccess: '授权成功!',
|
||||
authFailed: '授权失败或已过期',
|
||||
authCanceled: '授权已取消,请重试',
|
||||
urlEmpty: '授权URL为空',
|
||||
urlFetchFailed: '获取授权URL失败',
|
||||
popupBlocked: '无法打开授权窗口,请检查浏览器弹窗设置',
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
@@ -2125,6 +2298,10 @@ export default {
|
||||
repoUrl: '插件仓库地址',
|
||||
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||
repoHint: '多个地址使用换行分隔,仅支持Github仓库',
|
||||
urlPlaceholder: '输入插件仓库地址',
|
||||
noRepos: '暂无插件仓库地址',
|
||||
invalidUrl: '请输入有效的URL地址',
|
||||
duplicateUrl: '该地址已存在',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
saveSuccess: '插件仓库保存成功',
|
||||
@@ -2475,6 +2652,7 @@ export default {
|
||||
settings: '设置',
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
local: '本地',
|
||||
installToLocal: '安装到本地',
|
||||
totalDownloads: '共 {count} 次下载',
|
||||
viewData: '查看数据',
|
||||
@@ -2585,7 +2763,8 @@ export default {
|
||||
passkeyManagement: '通行密钥管理',
|
||||
registerNewPasskey: '注册新通行密钥',
|
||||
passkeyDescription: '通行密钥可以让您无需密码即可快速安全地登录。',
|
||||
passkeyAppDescription: '通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
|
||||
passkeyAppDescription:
|
||||
'通行密钥是一种更简单、更安全的登录方式,可以替代密码进行登录。您可以使用 iCloud 钥匙串、Bitwarden 等支持通行密钥的应用程序或硬件密钥完成验证。',
|
||||
passkeyName: '通行密钥名称',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '注册通行密钥',
|
||||
@@ -2599,8 +2778,10 @@ export default {
|
||||
passkeyDeleteSuccess: '通行密钥已删除',
|
||||
passkeyDeleteFailed: '删除失败',
|
||||
deletePasskey: '删除通行密钥',
|
||||
passkeyDomainWarning: '通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey: '为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
passkeyDomainWarning:
|
||||
'通行密钥(PassKey)的可用性与 {domain} 紧密相关。在公网环境下,请务必在“基础设置”中配置正确的访问域名。域名变更或配置错误将导致通行密钥无法使用。',
|
||||
otpRequiredForPasskey:
|
||||
'为了安全起见,您必须先启用 {otp} 验证码,然后才能注册通行密钥。这是为了防止在域名配置变动导致 PassKey 失效时,您仍能通过 OTP 码登录账户。',
|
||||
accessDomain: '访问域名',
|
||||
otpAuthenticator: 'OTP 身份验证器',
|
||||
otpGenerateFailed: '获取otp uri失败:{message}!',
|
||||
@@ -2655,10 +2836,19 @@ export default {
|
||||
loading: '加载中...',
|
||||
pageSize: '每页条数',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
|
||||
aiRedoQueued: '已提交智能助手整理任务:{title}',
|
||||
aiRedoFailed: '提交智能助手整理任务失败',
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '删除',
|
||||
batchRedo: '批量重新整理',
|
||||
batchDelete: '批量删除',
|
||||
},
|
||||
batchOperationTitle: '批量操作',
|
||||
progress: {
|
||||
processing: '处理中',
|
||||
pleaseWait: '请稍候...',
|
||||
@@ -2713,10 +2903,13 @@ export default {
|
||||
type: '类型',
|
||||
enabled: '启用',
|
||||
customTypeHint: '自定义下载器类型,用于插件等场景',
|
||||
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: '强制继续',
|
||||
@@ -2782,6 +2975,15 @@ export default {
|
||||
password: '密码',
|
||||
syncLibraries: '同步媒体库',
|
||||
syncLibrariesHint: '只有选中的媒体库才会被同步',
|
||||
scanMode: '扫描模式',
|
||||
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
|
||||
verifySsl: '校验 SSL 证书',
|
||||
verifySslHint: '开启后会校验 HTTPS 证书;如使用自签名证书可关闭',
|
||||
scanModeOptions: {
|
||||
newAndModified: '新添加和修改',
|
||||
supplementMissing: '补充缺失',
|
||||
fullOverride: '覆盖扫描',
|
||||
},
|
||||
nameExists: '【{name}】已存在,请替换为其他名称',
|
||||
hostRequired: '地址不能为空',
|
||||
apiKeyRequired: 'API密钥不能为空',
|
||||
@@ -3035,6 +3237,8 @@ export default {
|
||||
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
|
||||
notificationSettingsSaved: '通知设置保存成功',
|
||||
saveNotificationSettingsFailed: '保存通知设置失败',
|
||||
saveSiteAuthSettingsFailed: '保存用户站点认证设置失败:{message}',
|
||||
saveAgentSettingsFailed: '保存智能助手设置失败',
|
||||
preferenceSettingsSaved: '偏好设置保存成功',
|
||||
savePreferenceSettingsFailed: '保存偏好设置失败',
|
||||
passwordUpdateSuccess: '密码更新成功',
|
||||
@@ -3056,6 +3260,16 @@ export default {
|
||||
confirmPasswordHint: '确认新密码',
|
||||
apiTokenRequired: 'API Token不能为空',
|
||||
},
|
||||
siteAuth: {
|
||||
title: '用户认证',
|
||||
description: '配置用户站点认证与辅助认证',
|
||||
info: '用户站点认证说明',
|
||||
infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选,后续也可在个人菜单中继续配置。',
|
||||
selectSiteHint: '选择一个支持认证的站点,并填写该站点要求的认证参数',
|
||||
submitHint: '点击下一步时将立即向认证站点发起校验,认证成功后会保存当前参数。',
|
||||
siteConfigNotExist: '认证站点配置不存在',
|
||||
fieldRequired: '请输入{name}',
|
||||
},
|
||||
storage: {
|
||||
title: '存储',
|
||||
description: '配置下载目录和媒体库目录',
|
||||
@@ -3088,7 +3302,7 @@ export default {
|
||||
title: '媒体服务器',
|
||||
description: '配置媒体服务器',
|
||||
info: '媒体服务器配置说明',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin或Plex等',
|
||||
infoDesc: '配置媒体服务器用于媒体库管理,可选择Emby、Jellyfin、Plex、飞牛影视或绿联影视',
|
||||
type: '媒体服务器类型',
|
||||
typeHint: '选择要使用的媒体服务器类型',
|
||||
name: '服务器名称',
|
||||
@@ -3119,6 +3333,18 @@ export default {
|
||||
senderPassword: '发送密码',
|
||||
receiverEmail: '接收邮箱',
|
||||
},
|
||||
agent: {
|
||||
title: '智能助手',
|
||||
description: '配置 Agent 助手与 LLM 参数',
|
||||
info: '智能助手配置说明',
|
||||
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
|
||||
providerRequired: 'LLM 提供商不能为空',
|
||||
apiKeyRequired: 'LLM API 密钥不能为空',
|
||||
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
|
||||
modelRequired: 'LLM 模型名称不能为空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
|
||||
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
|
||||
},
|
||||
preferences: {
|
||||
title: '资源偏好',
|
||||
description: '设置资源下载偏好',
|
||||
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
unsubscribe: '取消訂閱',
|
||||
media: '媒體',
|
||||
unknown: '未知',
|
||||
notFetched: '未獲取',
|
||||
notice: '注意',
|
||||
itemsPerPage: '每頁條數',
|
||||
pageText: '{0}-{1} 共 {2} 條',
|
||||
@@ -73,6 +74,9 @@ export default {
|
||||
descending: '降序',
|
||||
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||
clearCache: '清除快取',
|
||||
sortMode: '排序模式',
|
||||
sortModeHint: '已進入拖拽排序模式',
|
||||
exit: '退出',
|
||||
},
|
||||
mediaType: {
|
||||
movie: '電影',
|
||||
@@ -89,6 +93,7 @@ export default {
|
||||
mediaServer: '媒體伺服器',
|
||||
manual: '手動處理',
|
||||
plugin: '插件',
|
||||
agent: '智能體',
|
||||
other: '其它',
|
||||
},
|
||||
actionStep: {
|
||||
@@ -255,6 +260,7 @@ export default {
|
||||
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
|
||||
loginFailed: '登錄失敗',
|
||||
secondaryVerification: '二次驗證',
|
||||
orDivider: '或',
|
||||
loginWithPasskey: '使用通行密鑰登錄',
|
||||
loginWithOtp: '使用驗證碼登錄',
|
||||
orUsePasskey: '或使用通行密鑰進行驗證',
|
||||
@@ -313,7 +319,8 @@ export default {
|
||||
settingTabs: {
|
||||
system: {
|
||||
title: '系統',
|
||||
description: '基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、Jellyfin、Plex)',
|
||||
description:
|
||||
'基礎設置、下載器(Qbittorrent、Transmission)、媒體服務器(Emby、Jellyfin、Plex、飛牛影視、綠聯影視)',
|
||||
},
|
||||
directory: {
|
||||
title: '存儲 & 目錄',
|
||||
@@ -432,6 +439,8 @@ export default {
|
||||
config: '配置',
|
||||
wechat: {
|
||||
name: '企業微信',
|
||||
useBotMode: '使用智能機器人',
|
||||
useBotModeHint: '開啟後使用智能機器人長連線,固定 dmPolicy=open、groupPolicy=disabled',
|
||||
corpId: '企業ID',
|
||||
corpIdHint: '企業微信後台企業信息中的企業ID',
|
||||
corpIdRequired: '企業ID不能為空',
|
||||
@@ -447,6 +456,15 @@ export default {
|
||||
tokenHint: '微信企業自建應用->API接收消息配置中的Token',
|
||||
encodingAesKey: 'EncodingAESKey',
|
||||
encodingAesKeyHint: '微信企業自建應用->API接收消息配置中的EncodingAESKey',
|
||||
botId: '機器人 BotID',
|
||||
botIdHint: '企業微信智能機器人的 BotID',
|
||||
botSecret: '機器人 Secret',
|
||||
botSecretHint: '企業微信智能機器人長連線專用 Secret',
|
||||
botChatId: '預設通知目標',
|
||||
botChatIdHint: '可填寫使用者 userid;如需主動發群消息可填寫 group:群聊chatid,不填則預設發給已互動使用者',
|
||||
botChatIdPlaceholder: 'userid 或 group:chatid',
|
||||
botWsUrl: '長連線地址',
|
||||
botWsUrlHint: '企業微信智能機器人 WebSocket 位址,通常使用預設值',
|
||||
admins: '管理員白名單',
|
||||
adminsHint: '可使用管理菜單及命令的用戶ID列表,多個ID使用,分隔',
|
||||
adminsPlaceholder: '用戶ID列表,多個ID使用,分隔',
|
||||
@@ -517,6 +535,21 @@ export default {
|
||||
usernameHint: '只有對應的用戶登錄後才會推送消息',
|
||||
usernameRequired: '用戶名不能為空',
|
||||
},
|
||||
qqbot: {
|
||||
name: 'QQ',
|
||||
appId: 'AppID',
|
||||
appIdHint: 'QQ 開放平台機器人 AppID',
|
||||
appIdRequired: 'AppID 不能為空',
|
||||
appSecret: 'AppSecret',
|
||||
appSecretHint: 'QQ 開放平台機器人 AppSecret',
|
||||
appSecretRequired: 'AppSecret 不能為空',
|
||||
openId: '用戶 OpenID',
|
||||
openIdHint: '默認接收者 openid(單聊),用戶需曾與機器人交互過',
|
||||
openIdPlaceholder: '32位十六進制',
|
||||
groupOpenId: '群組 OpenID',
|
||||
groupOpenIdHint: '默認群組 openid(群聊),與用戶 OpenID 二選一',
|
||||
groupOpenIdPlaceholder: '群組 openid',
|
||||
},
|
||||
},
|
||||
shortcut: {
|
||||
title: '捷徑',
|
||||
@@ -552,6 +585,10 @@ export default {
|
||||
title: '緩存',
|
||||
subtitle: '管理緩存',
|
||||
},
|
||||
scheduler: {
|
||||
title: '服務',
|
||||
subtitle: '定時服務',
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
components: '動作組件',
|
||||
@@ -960,6 +997,7 @@ export default {
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
refreshSearch: '重新搜尋',
|
||||
},
|
||||
browse: {
|
||||
actor: '演員',
|
||||
@@ -1196,6 +1234,17 @@ export default {
|
||||
content: '內容',
|
||||
refreshing: '正在刷新',
|
||||
initializing: '正在初始化',
|
||||
searchPlaceholder: '搜索日誌內容',
|
||||
allLevels: '全部級別',
|
||||
followTail: '跟隨最新日誌',
|
||||
wrapLines: '自動換行',
|
||||
pauseStream: '暫停日誌流',
|
||||
resumeStream: '恢復日誌流',
|
||||
waitingForLogs: '等待日誌輸出...',
|
||||
paused: '已暫停',
|
||||
connected: '實時更新中',
|
||||
lineCount: '顯示 {visible}/{total} 行',
|
||||
jumpToLatest: '查看最新 ({count})',
|
||||
},
|
||||
moduleTest: {
|
||||
normal: '正常',
|
||||
@@ -1285,27 +1334,95 @@ 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 限制',
|
||||
llmApiKey: 'LLM API密鑰',
|
||||
llmApiKeyHint: 'LLM服務提供商的API密鑰,用於身份驗證',
|
||||
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 調用測試失敗',
|
||||
llmTestFailedToastWithMessage: 'LLM 調用測試失敗:{message}',
|
||||
aiAgentGlobal: '全局智能助手',
|
||||
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
|
||||
aiAgentJobInterval: '定時喚醒',
|
||||
aiAgentJobIntervalHint: '設置定時喚醒的檢查間隔,選擇「不啟用」則不執行定時任務',
|
||||
aiAgentVerbose: '囉嗦模式',
|
||||
aiAgentVerboseHint: '開啟後會在智能體回覆時顯示工具調用過程',
|
||||
aiAgentJobIntervalDisabled: '不啟用',
|
||||
aiAgentJobInterval1h: '1小時',
|
||||
aiAgentJobInterval3h: '3小時',
|
||||
aiAgentJobInterval6h: '6小時',
|
||||
aiAgentJobInterval12h: '12小時',
|
||||
aiAgentJobInterval24h: '24小時',
|
||||
aiAgentJobInterval1w: '1週',
|
||||
aiAgentJobInterval1M: '1個月',
|
||||
advancedSettings: '高級設置',
|
||||
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
||||
downloaders: '下載器',
|
||||
downloadersDesc: '只有默認下載器才會被默認使用。',
|
||||
aiAgentRetryTransfer: '檔案整理失敗智能接管',
|
||||
aiAgentRetryTransferHint:
|
||||
'啟用後,當檔案整理失敗時,智能助手將自動接管並嘗試重新整理,利用AI能力解決識別和匹配問題',
|
||||
aiRecommendEnabled: '搜索結果智能推薦',
|
||||
aiRecommendEnabledHint: '啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
|
||||
aiRecommendEnabledHint:
|
||||
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
|
||||
aiRecommendUserPreference: '用戶偏好',
|
||||
aiRecommendUserPreferenceHint: '設置智能推薦時的用戶偏好,例如:4K WEB-DL Dolby Vision',
|
||||
aiRecommendMaxItems: '智能推薦分析條目上限',
|
||||
aiRecommendMaxItemsHint: '限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
|
||||
aiRecommendMaxItemsHint:
|
||||
'限制發送給智能助手進行分析的搜索結果數量,數量越多分析越慢且消耗 Token 越多,建議先手動篩選,篩選出大致範圍後再進行智能推薦',
|
||||
mediaServers: '媒體服務器',
|
||||
mediaServersDesc: '所有啟用的媒體服務器都會被使用。',
|
||||
trimeMedia: '飛牛影視',
|
||||
@@ -1313,6 +1430,7 @@ export default {
|
||||
media: '媒體',
|
||||
network: '網絡',
|
||||
log: '日誌',
|
||||
data: '數據',
|
||||
lab: '實驗室',
|
||||
downloaderSaveSuccess: '下載器設置保存成功',
|
||||
downloaderSaveFailed: '下載器設置保存失敗!',
|
||||
@@ -1328,9 +1446,11 @@ export default {
|
||||
reloading: '正在應用配置...',
|
||||
qbittorrent: 'Qbittorrent',
|
||||
transmission: 'Transmission',
|
||||
rtorrent: 'rTorrent',
|
||||
emby: 'Emby',
|
||||
jellyfin: 'Jellyfin',
|
||||
plex: 'Plex',
|
||||
ugreen: '綠聯影視',
|
||||
reloadSuccess: '系統配置已生效',
|
||||
reloadFailed: '重載系統失敗!',
|
||||
auxAuthEnable: '用戶輔助認證',
|
||||
@@ -1370,6 +1490,10 @@ export default {
|
||||
fanartEnableHint: '使用 fanart.tv 的圖片數據',
|
||||
fanartLang: 'Fanart語言',
|
||||
fanartLangHint: '設定Fanart圖片的語言偏好,多選時按優先級順序排列',
|
||||
recognizePluginFirst: '優先使用插件識別',
|
||||
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
|
||||
mediaRecognizeShare: '共享使用媒體識別數據',
|
||||
mediaRecognizeShareHint: '識別成功後上報關鍵字與媒體ID,識別失敗時優先回查共享識別結果',
|
||||
githubProxy: 'Github加速代理',
|
||||
githubProxyPlaceholder: '留空表示不使用代理',
|
||||
githubProxyHint: '使用代理加速Github訪問速度',
|
||||
@@ -1398,8 +1522,23 @@ 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: '本地插件倉庫路徑',
|
||||
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
|
||||
encodingDetectionPerformanceMode: '編碼探測性能模式',
|
||||
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
|
||||
transferThreads: '文件整理線程數',
|
||||
@@ -1439,6 +1578,7 @@ export default {
|
||||
},
|
||||
mb: 'MB',
|
||||
hour: '小時',
|
||||
day: '天',
|
||||
customizeWallpaperApi: '自定義壁紙API',
|
||||
customizeWallpaperApiHint: '會獲取 API 返回內容中所有安全設置中允許的圖片地址,需要設置安全域名白名單',
|
||||
customizeWallpaperApiRequired: '必填項;請輸出自定義壁紙API',
|
||||
@@ -1480,6 +1620,11 @@ export default {
|
||||
episodeThumb: '縮略圖',
|
||||
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
|
||||
scrapingSwitchSaveError: '刮削開關設定保存失敗',
|
||||
policy: {
|
||||
skipDesc: '跳過刮削,不生成該文件',
|
||||
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
|
||||
overwriteDesc: '始終刮削,已存在則覆蓋',
|
||||
},
|
||||
},
|
||||
site: {
|
||||
siteSync: '站點同步',
|
||||
@@ -1574,6 +1719,7 @@ export default {
|
||||
synologyChat: 'SynologyChat',
|
||||
voceChat: 'VoceChat',
|
||||
webPush: 'WebPush',
|
||||
qq: 'QQ',
|
||||
custom: '自定義通知',
|
||||
},
|
||||
words: {
|
||||
@@ -1673,6 +1819,25 @@ export default {
|
||||
storageSaveSuccess: '存儲設置保存成功',
|
||||
storageSaveFailed: '存儲設置保存失敗!',
|
||||
},
|
||||
category: {
|
||||
title: '分類策略',
|
||||
subtitle: '配置媒體自動分類規則,按類型、語言、地區等條件自動歸類',
|
||||
movie: '電影 (Movie)',
|
||||
tv: '電視劇 (TV)',
|
||||
name: '分類名稱 (目錄名)',
|
||||
genre: '內容類型 (Genre)',
|
||||
language: '語種 (Language)',
|
||||
languagePlaceholder: '如: zh,cn,en (使用逗號分隔)',
|
||||
country: '國家/地區 (Country)',
|
||||
countryPlaceholder: '如: US,CN,JP',
|
||||
year: '年份 (Year)',
|
||||
yearPlaceholder: '如: 2023, 2020-2024',
|
||||
addMovie: '添加電影分類',
|
||||
addTv: '添加電視劇分類',
|
||||
saveSuccess: '分類策略保存成功',
|
||||
loadFailed: '加載分類配置失敗',
|
||||
saveFailed: '保存失敗: {message}',
|
||||
},
|
||||
rule: {
|
||||
customRules: '自定義規則',
|
||||
customRulesDesc: '自定義優先級規則項',
|
||||
@@ -1899,7 +2064,7 @@ export default {
|
||||
},
|
||||
searchBar: {
|
||||
search: '搜索',
|
||||
searchPlaceholder: '搜索功能、訂閱、設置...',
|
||||
searchPlaceholder: '搜索電影、劇集以及更多...',
|
||||
recentSearches: '最近搜索',
|
||||
noRecentSearches: '沒有最近搜索記錄',
|
||||
functions: '功能',
|
||||
@@ -1919,6 +2084,9 @@ export default {
|
||||
searchInSites: '在站點中搜索種子資源',
|
||||
relatedResources: '相關資源',
|
||||
searchTip: '可搜索電影、電視劇、演員、資源等',
|
||||
emptySearchHint: '輸入關鍵字開始搜索',
|
||||
escClose: '關閉',
|
||||
openSearch: '打開搜索',
|
||||
},
|
||||
searchSite: {
|
||||
selectSites: '選擇站點',
|
||||
@@ -1972,9 +2140,15 @@ export default {
|
||||
securityWarningMessage: '分享前請確保工作流沒有敏感資訊,比如RSS連結中的PassKey等,避免產生資訊洩露。',
|
||||
},
|
||||
u115Auth: {
|
||||
loginTitle: '115網盤登錄',
|
||||
scanQrCode: '請使用微信或115客戶端掃碼',
|
||||
scanned: '已掃碼,請確認登錄',
|
||||
loginTitle: '115網盤授權',
|
||||
openAuthWindow: '打開授權窗口',
|
||||
authorizing: '請在新窗口中完成授權...',
|
||||
authSuccess: '授權成功!',
|
||||
authFailed: '授權失敗或已過期',
|
||||
authCanceled: '授權已取消,請重試',
|
||||
urlEmpty: '授權URL為空',
|
||||
urlFetchFailed: '獲取授權URL失敗',
|
||||
popupBlocked: '無法打開授權窗口,請檢查瀏覽器彈窗設置',
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
@@ -2126,6 +2300,10 @@ export default {
|
||||
repoUrl: '插件倉庫地址',
|
||||
repoPlaceholder: '格式:https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
|
||||
repoHint: '多個地址使用换行分隔,僅支援Github倉庫',
|
||||
urlPlaceholder: '輸入插件倉庫地址',
|
||||
noRepos: '暫無插件倉庫地址',
|
||||
invalidUrl: '請輸入有效的URL地址',
|
||||
duplicateUrl: '該地址已存在',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
saveSuccess: '插件倉庫儲存成功',
|
||||
@@ -2476,6 +2654,7 @@ export default {
|
||||
settings: '設置',
|
||||
projectHome: '項目主頁',
|
||||
updateHistory: '更新說明',
|
||||
local: '本地',
|
||||
installToLocal: '安裝到本地',
|
||||
totalDownloads: '共 {count} 次下載',
|
||||
viewData: '查看數據',
|
||||
@@ -2586,7 +2765,8 @@ export default {
|
||||
passkeyManagement: '通行密鑰管理',
|
||||
registerNewPasskey: '註冊新通行密鑰',
|
||||
passkeyDescription: '通行密鑰可以讓您無需密碼即可快速安全地登入。',
|
||||
passkeyAppDescription: '通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
|
||||
passkeyAppDescription:
|
||||
'通行密鑰是一種更簡單、更安全的登入方式,可以替代密碼進行登入。您可以使用 iCloud 鑰匙圈、Bitwarden 等支援通行密鑰的應用程式或硬體金鑰完成驗證。',
|
||||
passkeyName: '通行密鑰名稱',
|
||||
passkeyNamePlaceholder: '例如:iPhone、Windows Hello',
|
||||
registerPasskey: '註冊通行密鑰',
|
||||
@@ -2658,10 +2838,19 @@ export default {
|
||||
loading: '加載中...',
|
||||
pageSize: '每頁條數',
|
||||
pageInfo: '{begin} - {end} / {total}',
|
||||
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
|
||||
aiRedoQueued: '已提交智能助手整理任務:{title}',
|
||||
aiRedoFailed: '提交智能助手整理任務失敗',
|
||||
actions: {
|
||||
aiRedo: '智能助手整理',
|
||||
aiRedoPending: '智能助手整理中...',
|
||||
batchAiRedo: '智能助手批量整理',
|
||||
redo: '重新整理',
|
||||
delete: '刪除',
|
||||
batchRedo: '批量重新整理',
|
||||
batchDelete: '批量刪除',
|
||||
},
|
||||
batchOperationTitle: '批量操作',
|
||||
progress: {
|
||||
processing: '處理中',
|
||||
pleaseWait: '請稍候...',
|
||||
@@ -2715,11 +2904,14 @@ export default {
|
||||
name: '名稱',
|
||||
type: '類型',
|
||||
customTypeHint: '自定義下載器類型,用於插件等場景',
|
||||
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
|
||||
enabled: '啟用',
|
||||
default: '預設',
|
||||
host: '地址',
|
||||
apiKey: 'API Key',
|
||||
username: '用戶名',
|
||||
password: '密碼',
|
||||
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key;填寫後將優先使用 API Key 登入。',
|
||||
category: '自動分類管理',
|
||||
sequentail: '順序下載',
|
||||
force_resume: '強制繼續',
|
||||
@@ -2790,6 +2982,15 @@ export default {
|
||||
password: '密碼',
|
||||
syncLibraries: '同步媒體庫',
|
||||
syncLibrariesHint: '只有選中的媒體庫才會被同步',
|
||||
scanMode: '掃描模式',
|
||||
scanModeHint: '用於全庫刷新和按庫刷新:新添加和修改 / 補充缺失 / 覆蓋掃描',
|
||||
verifySsl: '校驗 SSL 憑證',
|
||||
verifySslHint: '開啟後會校驗 HTTPS 憑證;如使用自簽憑證可關閉',
|
||||
scanModeOptions: {
|
||||
newAndModified: '新添加和修改',
|
||||
supplementMissing: '補充缺失',
|
||||
fullOverride: '覆蓋掃描',
|
||||
},
|
||||
nameExists: '【{name}】已存在,請替換為其他名稱',
|
||||
},
|
||||
bangumi: {
|
||||
@@ -3038,6 +3239,8 @@ export default {
|
||||
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
|
||||
notificationSettingsSaved: '通知設置保存成功',
|
||||
saveNotificationSettingsFailed: '保存通知設置失敗',
|
||||
saveSiteAuthSettingsFailed: '保存用戶站點認證設置失敗:{message}',
|
||||
saveAgentSettingsFailed: '保存智能助手設置失敗',
|
||||
preferenceSettingsSaved: '偏好設置保存成功',
|
||||
savePreferenceSettingsFailed: '保存偏好設置失敗',
|
||||
passwordUpdateSuccess: '密碼更新成功',
|
||||
@@ -3059,6 +3262,16 @@ export default {
|
||||
confirmPasswordHint: '確認新密碼',
|
||||
apiTokenRequired: 'API Token 不能為空',
|
||||
},
|
||||
siteAuth: {
|
||||
title: '用戶認證',
|
||||
description: '配置用戶站點認證與輔助認證',
|
||||
info: '用戶站點認證說明',
|
||||
infoDesc: '完成站點認證後可解鎖站點能力與部分插件權限。此步驟可選,後續也可在個人選單中繼續配置。',
|
||||
selectSiteHint: '選擇一個支援認證的站點,並填寫該站點要求的認證參數',
|
||||
submitHint: '點擊下一步時將立即向認證站點發起校驗,認證成功後會保存當前參數。',
|
||||
siteConfigNotExist: '認證站點配置不存在',
|
||||
fieldRequired: '請輸入{name}',
|
||||
},
|
||||
storage: {
|
||||
title: '儲存',
|
||||
description: '設定下載目錄和媒體庫目錄',
|
||||
@@ -3091,7 +3304,7 @@ export default {
|
||||
title: '媒體伺服器',
|
||||
description: '設定媒體伺服器',
|
||||
info: '媒體伺服器設定說明',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、Jellyfin或Plex等',
|
||||
infoDesc: '設定媒體伺服器用於媒體庫管理,可選擇Emby、Jellyfin、Plex、飛牛影視或綠聯影視',
|
||||
type: '媒體伺服器類型',
|
||||
typeHint: '選擇要使用的媒體伺服器類型',
|
||||
name: '伺服器名稱',
|
||||
@@ -3122,6 +3335,18 @@ export default {
|
||||
senderPassword: '發送密碼',
|
||||
receiverEmail: '接收信箱',
|
||||
},
|
||||
agent: {
|
||||
title: '智能助手',
|
||||
description: '配置 Agent 助手與 LLM 參數',
|
||||
info: '智能助手配置說明',
|
||||
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
|
||||
providerRequired: 'LLM 提供商不能為空',
|
||||
apiKeyRequired: 'LLM API 密鑰不能為空',
|
||||
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
|
||||
modelRequired: 'LLM 模型名稱不能為空',
|
||||
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
|
||||
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
|
||||
},
|
||||
preferences: {
|
||||
title: '資源偏好',
|
||||
description: '設定資源下載偏好',
|
||||
|
||||
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 { NavMenu } from '@/@layouts/types'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 从 Store 中获取用户信息
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => ({
|
||||
@@ -20,14 +21,22 @@ const userPermissions = computed(() => ({
|
||||
// 应用分组(以header分组)
|
||||
const appGroups = ref<Record<string, NavMenu[]>>({})
|
||||
|
||||
// 根据header属性对应用进行分类
|
||||
function categorizeApps() {
|
||||
// 获取所有菜单并根据权限过滤
|
||||
// 根据header属性对应用进行分类(含插件侧栏项,与桌面端侧栏一致)
|
||||
async function categorizeApps() {
|
||||
const allMenus = getNavMenus(t)
|
||||
const filteredMenus = filterMenusByPermission(allMenus, userPermissions.value)
|
||||
const menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
let menus = filteredMenus.filter((item: NavMenu) => !item.footer)
|
||||
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
if (pluginSidebarNavStore.items.length > 0) {
|
||||
const pluginNavMenus = filterPluginSidebarNavEntries(
|
||||
pluginSidebarNavStore.items,
|
||||
t,
|
||||
userPermissions.value,
|
||||
).map(e => e.navMenu)
|
||||
menus = [...menus, ...pluginNavMenus]
|
||||
}
|
||||
|
||||
// 按header属性分组
|
||||
const groupedMenus: Record<string, NavMenu[]> = {}
|
||||
|
||||
menus.forEach(menu => {
|
||||
@@ -38,11 +47,9 @@ function categorizeApps() {
|
||||
groupedMenus[header].push(menu)
|
||||
})
|
||||
|
||||
// 将分组结果赋值给响应式变量
|
||||
appGroups.value = groupedMenus
|
||||
}
|
||||
|
||||
// 页面加载时对应用进行分类
|
||||
onMounted(() => {
|
||||
categorizeApps()
|
||||
})
|
||||
@@ -60,7 +67,7 @@ onMounted(() => {
|
||||
<VList lines="one" class="settings-list">
|
||||
<VListItem
|
||||
v-for="(app, appIndex) in apps"
|
||||
:key="appIndex"
|
||||
:key="`${header}-${appIndex}-${String(app.to)}`"
|
||||
:to="app.to || ''"
|
||||
color="primary"
|
||||
class="settings-list-item"
|
||||
|
||||
@@ -376,16 +376,15 @@ onDeactivated(() => {
|
||||
|
||||
<!-- 底部操作按钮(只在非移动设备上显示) -->
|
||||
<Teleport to="body" v-if="route.path === '/dashboard'">
|
||||
<VFab
|
||||
v-if="!appMode"
|
||||
icon="mdi-view-dashboard-edit"
|
||||
location="bottom"
|
||||
size="x-large"
|
||||
fixed
|
||||
app
|
||||
appear
|
||||
@click="dialog = true"
|
||||
/>
|
||||
<div v-if="!appMode" class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-view-dashboard-edit"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="dialog = true"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 弹窗,根据配置生成选项 -->
|
||||
|
||||
@@ -234,8 +234,8 @@ async function handlePassKeyAuth(
|
||||
isConditional && conditionalAbortController
|
||||
? conditionalAbortController.signal
|
||||
: !isConditional && manualAbortController
|
||||
? manualAbortController.signal
|
||||
: undefined,
|
||||
? manualAbortController.signal
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await onSuccess(finishResponse)
|
||||
@@ -528,7 +528,7 @@ onUnmounted(() => {
|
||||
<!-- 登录表单 -->
|
||||
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
|
||||
<VCard
|
||||
class="auth-card px-7 py-3 w-full h-full"
|
||||
class="auth-card px-7 pt-3 w-full h-full"
|
||||
:class="{ 'glass-effect': !isTransparentTheme }"
|
||||
max-width="24rem"
|
||||
border
|
||||
@@ -539,7 +539,7 @@ onUnmounted(() => {
|
||||
<VImg :src="logo" width="64" height="64" />
|
||||
</div>
|
||||
</template>
|
||||
<VCardTitle class="font-weight-bold text-2xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
<VCardTitle class="font-weight-bold text-3xl text-uppercase"> MoviePilot </VCardTitle>
|
||||
|
||||
<!-- 语言切换按钮 -->
|
||||
<template #append>
|
||||
@@ -582,7 +582,7 @@ onUnmounted(() => {
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
autocomplete="username webauthn"
|
||||
autocomplete="username"
|
||||
:rules="[requiredValidator]"
|
||||
hide-details
|
||||
/>
|
||||
@@ -602,7 +602,7 @@ onUnmounted(() => {
|
||||
@click:append-inner="isPasswordVisible = !isPasswordVisible"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VCol cols="12" class="py-0">
|
||||
<!-- remember me checkbox -->
|
||||
<div class="d-flex align-center justify-space-between flex-wrap">
|
||||
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
|
||||
@@ -610,15 +610,21 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<!-- login button -->
|
||||
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading">
|
||||
<VBtn block type="submit" prepend-icon="mdi-login" :loading="loading" size="large">
|
||||
{{ t('login.login') }}
|
||||
</VBtn>
|
||||
|
||||
<!-- or divider -->
|
||||
<div class="or-divider my-4">
|
||||
<span class="or-divider-text">{{ t('login.orDivider') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- passkey login button -->
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
variant="outlined"
|
||||
color="success"
|
||||
class="mt-3 passkey-btn"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="passkeyLoading"
|
||||
@click="loginWithPassKey(false)"
|
||||
@@ -718,8 +724,29 @@ onUnmounted(() => {
|
||||
background: rgba(var(--v-theme-surface), 0.7) !important;
|
||||
}
|
||||
|
||||
.or-divider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
flex: 1;
|
||||
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
content: '';
|
||||
}
|
||||
|
||||
.or-divider-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.8125rem;
|
||||
padding-inline: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.v-theme--light {
|
||||
.passkey-btn.v-btn--variant-tonal {
|
||||
.passkey-btn.v-btn--variant-outlined {
|
||||
color: rgb(86, 170, 0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
50
src/pages/plugin-app.vue
Normal file
50
src/pages/plugin-app.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import api from '@/api'
|
||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const pluginId = computed(() => route.params.pluginId as string)
|
||||
const navKey = computed(() => (route.params.navKey as string) || 'main')
|
||||
|
||||
const RemoteView = shallowRef<Component | null>(null)
|
||||
const loadError = ref(false)
|
||||
|
||||
watch(
|
||||
[pluginId, navKey],
|
||||
async ([pid, nk]) => {
|
||||
loadError.value = false
|
||||
if (!pid) {
|
||||
RemoteView.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
RemoteView.value = (await loadRemoteAppPageComponent(pid, nk)) as Component
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
RemoteView.value = null
|
||||
loadError.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page">
|
||||
<VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误">
|
||||
无法加载插件全页组件。多入口时请暴露 AppPage 或 AppPage{Pascal}(见文档),并确认插件已启用。
|
||||
</VAlert>
|
||||
<VSkeletonLoader v-else-if="!RemoteView" class="ma-4" type="article, article, article" />
|
||||
<component
|
||||
v-else
|
||||
:is="RemoteView"
|
||||
:key="`${pluginId}-${navKey}`"
|
||||
:api="api"
|
||||
:nav-key="navKey"
|
||||
:plugin-id="pluginId"
|
||||
@action="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ import AccountSettingSite from '@/views/setting/AccountSettingSite.vue'
|
||||
import AccountSettingSearch from '@/views/setting/AccountSettingSearch.vue'
|
||||
import AccountSettingSubscribe from '@/views/setting/AccountSettingSubscribe.vue'
|
||||
import AccountSettingSystem from '@/views/setting/AccountSettingSystem.vue'
|
||||
import AccountSettingService from '@/views/setting/AccountSettingService.vue'
|
||||
import AccountSettingDirectory from '@/views/setting/AccountSettingDirectory.vue'
|
||||
import AccountSettingRule from '@/views/setting/AccountSettingRule.vue'
|
||||
import { getSettingTabs } from '@/router/i18n-menu'
|
||||
@@ -93,15 +92,6 @@ onMounted(() => {
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 服务 -->
|
||||
<VWindowItem value="scheduler">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AccountSettingService />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
|
||||
<!-- 通知 -->
|
||||
<VWindowItem value="notification">
|
||||
<transition name="fade-slide" appear>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSetupWizard } from '@/composables/useSetupWizard'
|
||||
import BasicSettingsStep from '@/views/setup/BasicSettingsStep.vue'
|
||||
import SiteAuthSettingsStep from '@/views/setup/SiteAuthSettingsStep.vue'
|
||||
import StorageSettingsStep from '@/views/setup/StorageSettingsStep.vue'
|
||||
import DownloaderSettingsStep from '@/views/setup/DownloaderSettingsStep.vue'
|
||||
import MediaServerSettingsStep from '@/views/setup/MediaServerSettingsStep.vue'
|
||||
import NotificationSettingsStep from '@/views/setup/NotificationSettingsStep.vue'
|
||||
import AgentSettingsStep from '@/views/setup/AgentSettingsStep.vue'
|
||||
import PreferencesSettingsStep from '@/views/setup/PreferencesSettingsStep.vue'
|
||||
import ConnectivityTest from '@/views/setup/ConnectivityTest.vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
@@ -101,28 +103,38 @@ onMounted(async () => {
|
||||
<BasicSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤2:存储目录 -->
|
||||
<!-- 步骤2:用户认证 -->
|
||||
<VStepperWindowItem :value="2">
|
||||
<SiteAuthSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤3:存储目录 -->
|
||||
<VStepperWindowItem :value="3">
|
||||
<StorageSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤3:下载器 -->
|
||||
<VStepperWindowItem :value="3">
|
||||
<!-- 步骤4:下载器 -->
|
||||
<VStepperWindowItem :value="4">
|
||||
<DownloaderSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤4:媒体服务器 -->
|
||||
<VStepperWindowItem :value="4">
|
||||
<!-- 步骤5:媒体服务器 -->
|
||||
<VStepperWindowItem :value="5">
|
||||
<MediaServerSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤5:通知 -->
|
||||
<VStepperWindowItem :value="5">
|
||||
<!-- 步骤6:通知 -->
|
||||
<VStepperWindowItem :value="6">
|
||||
<NotificationSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤6:资源偏好 -->
|
||||
<VStepperWindowItem :value="6">
|
||||
<!-- 步骤7:智能助手 -->
|
||||
<VStepperWindowItem :value="7">
|
||||
<AgentSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
|
||||
<!-- 步骤8:资源偏好 -->
|
||||
<VStepperWindowItem :value="8">
|
||||
<PreferencesSettingsStep />
|
||||
</VStepperWindowItem>
|
||||
</VStepperWindow>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import SubscribeListView from '@/views/subscribe/SubscribeListView.vue'
|
||||
import SubscribePopularView from '@/views/subscribe/SubscribePopularView.vue'
|
||||
import SubscribeShareView from '@/views/subscribe/SubscribeShareView.vue'
|
||||
@@ -6,6 +7,9 @@ import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
|
||||
import SubscribeShareStatisticsDialog from '@/components/dialog/SubscribeShareStatisticsDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useUserStore } from '@/stores'
|
||||
|
||||
import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||
|
||||
@@ -13,11 +17,13 @@ import { getSubscribeMovieTabs, getSubscribeTvTabs } from '@/router/i18n-menu'
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
const subType = route.meta.subType?.toString()
|
||||
const subId = ref(route.query.id as string)
|
||||
const activeTab = ref((route.query.tab as string) || '')
|
||||
const shareViewKey = ref(0)
|
||||
const subscribeListViewRef = ref<InstanceType<typeof SubscribeListView> | null>(null)
|
||||
|
||||
// 获取标签页
|
||||
const subscribeTabs = computed(() => {
|
||||
@@ -40,6 +46,9 @@ const searchShareDialog = ref(false)
|
||||
// 订阅分享统计弹窗
|
||||
const shareStatisticsDialog = ref(false)
|
||||
|
||||
// 排序模式
|
||||
const subscribeSortMode = ref(false)
|
||||
|
||||
// 订阅过滤词
|
||||
const subscribeFilter = ref('')
|
||||
|
||||
@@ -48,17 +57,12 @@ const subscribeStatusFilter = ref<string | null>(null)
|
||||
|
||||
// 分享搜索词
|
||||
const shareKeyword = ref('')
|
||||
|
||||
// 搜索分享
|
||||
const searchShares = () => {
|
||||
searchShareDialog.value = false
|
||||
shareViewKey.value++
|
||||
}
|
||||
const shareKeywordInput = ref('')
|
||||
|
||||
// 筛选选项
|
||||
const filterOptions = computed(() => {
|
||||
const baseOptions = [
|
||||
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
|
||||
{ value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },
|
||||
{ value: 'best_version', label: t('subscribe.bestVersion'), icon: 'mdi-refresh', color: 'warning' },
|
||||
]
|
||||
|
||||
@@ -82,17 +86,127 @@ const filterOptions = computed(() => {
|
||||
]
|
||||
})
|
||||
|
||||
// 计算筛选按钮颜色
|
||||
// 当前选中的筛选选项
|
||||
const currentFilter = computed(() => {
|
||||
return filterOptions.value.find(option => option.value === (subscribeStatusFilter.value || 'all'))
|
||||
})
|
||||
|
||||
// 计算筛选按钮颜色 - 有名称筛选或状态筛选时高亮
|
||||
const filterButtonColor = computed(() => {
|
||||
if (subscribeFilter.value || (subscribeStatusFilter.value && subscribeStatusFilter.value !== 'all')) {
|
||||
return 'primary'
|
||||
return currentFilter.value?.color || 'primary'
|
||||
}
|
||||
return 'gray'
|
||||
})
|
||||
|
||||
// 选择筛选选项
|
||||
function selectFilter(value: string) {
|
||||
subscribeStatusFilter.value = value
|
||||
filterSubscribeDialog.value = false
|
||||
}
|
||||
|
||||
// VMenu activator选择器
|
||||
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
|
||||
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
|
||||
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
|
||||
|
||||
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub')
|
||||
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && userStore.superUser)
|
||||
const showShareStatisticsAction = computed(() => activeTab.value === 'share')
|
||||
|
||||
function openDefaultRuleDialog() {
|
||||
subscribeEditDialog.value = true
|
||||
}
|
||||
|
||||
function openSubscribeHistoryDialog() {
|
||||
subscribeListViewRef.value?.openHistoryDialog()
|
||||
}
|
||||
|
||||
function openShareStatisticsDialog() {
|
||||
shareStatisticsDialog.value = true
|
||||
}
|
||||
|
||||
function toggleSubscribeSortMode() {
|
||||
subscribeSortMode.value = !subscribeSortMode.value
|
||||
}
|
||||
|
||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||
shareKeyword.value = keyword.trim()
|
||||
}, 300)
|
||||
|
||||
watch(shareKeywordInput, newKeyword => {
|
||||
shareKeywordUpdater(newKeyword || '')
|
||||
})
|
||||
|
||||
watch(activeTab, newTab => {
|
||||
if (newTab !== 'share') {
|
||||
searchShareDialog.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
shareKeywordUpdater.cancel()
|
||||
})
|
||||
|
||||
const subscribeDynamicMenuItems = computed(() => {
|
||||
if (!appMode.value) return undefined
|
||||
|
||||
if (activeTab.value === 'mysub') {
|
||||
const items: Array<{
|
||||
titleKey: string
|
||||
titleParams?: Record<string, unknown>
|
||||
icon: string
|
||||
action: () => void
|
||||
}> = []
|
||||
|
||||
if (showSubscribeHistoryAction.value) {
|
||||
items.push({
|
||||
titleKey: 'dialog.subscribeHistory.title',
|
||||
titleParams: { type: subType },
|
||||
icon: 'mdi-history',
|
||||
action: openSubscribeHistoryDialog,
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
titleKey: 'dialog.subscribeEdit.titleDefault',
|
||||
icon: 'mdi-clipboard-edit-outline',
|
||||
action: openDefaultRuleDialog,
|
||||
})
|
||||
|
||||
return items.length > 1 ? items : undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const subscribeDynamicIcon = computed(() => {
|
||||
if (showShareStatisticsAction.value) return 'mdi-chart-line'
|
||||
if (showSubscribeHistoryAction.value) return 'mdi-history'
|
||||
return 'mdi-clipboard-edit-outline'
|
||||
})
|
||||
|
||||
function handleSubscribeDynamicAction() {
|
||||
if (showShareStatisticsAction.value) {
|
||||
openShareStatisticsDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (showSubscribeHistoryAction.value) {
|
||||
openSubscribeHistoryDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (showDefaultRuleAction.value) {
|
||||
openDefaultRuleDialog()
|
||||
}
|
||||
}
|
||||
|
||||
useDynamicButton({
|
||||
icon: subscribeDynamicIcon,
|
||||
onClick: handleSubscribeDynamicAction,
|
||||
menuItems: subscribeDynamicMenuItems,
|
||||
show: computed(() => appMode.value && (showDefaultRuleAction.value || showShareStatisticsAction.value)),
|
||||
})
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
@@ -113,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',
|
||||
@@ -126,37 +248,16 @@ registerHeaderTab({
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-chart-line',
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'statistics-btn',
|
||||
action: () => {
|
||||
shareStatisticsDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-movie-search-outline',
|
||||
variant: 'text',
|
||||
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'search-btn',
|
||||
dataAttr: 'share-filter-btn',
|
||||
action: () => {
|
||||
searchShareDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
},
|
||||
{
|
||||
icon: 'mdi-clipboard-edit-outline',
|
||||
variant: 'text',
|
||||
color: 'gray',
|
||||
class: 'settings-icon-button',
|
||||
action: () => {
|
||||
subscribeEditDialog.value = true
|
||||
},
|
||||
show: computed(() => activeTab.value === 'mysub'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -176,10 +277,13 @@ onMounted(() => {
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeListView
|
||||
ref="subscribeListViewRef"
|
||||
:type="subType"
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -194,50 +298,58 @@ onMounted(() => {
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeShareView :keyword="shareKeyword" :key="shareViewKey" />
|
||||
<SubscribeShareView :keyword="shareKeyword" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<!-- 订阅过滤弹窗 -->
|
||||
<!-- 订阅过滤下拉菜单 -->
|
||||
<Teleport to="body" v-if="filterSubscribeDialog">
|
||||
<VMenu
|
||||
v-model="filterSubscribeDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="filterActivator"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
|
||||
{{ t('subscribe.filterSubscriptions') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="filterSubscribeDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<!-- 名称筛选 -->
|
||||
<VCol cols="6">
|
||||
<VTextField v-model="subscribeFilter" :label="t('subscribe.name')" clearable density="comfortable" />
|
||||
</VCol>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<VCol cols="6">
|
||||
<VSelect
|
||||
v-model="subscribeStatusFilter"
|
||||
:items="filterOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="t('common.status')"
|
||||
density="comfortable"
|
||||
clearable
|
||||
<VCard min-width="220">
|
||||
<!-- 名称搜索 -->
|
||||
<div class="pa-3">
|
||||
<VTextField
|
||||
v-model="subscribeFilter"
|
||||
:placeholder="t('subscribe.name')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<VDivider class="mt-2" />
|
||||
<!-- 状态筛选列表 -->
|
||||
<VList density="compact" class="px-2 py-1">
|
||||
<VListSubheader>{{ t('common.status') }}</VListSubheader>
|
||||
<VListItem
|
||||
v-for="option in filterOptions"
|
||||
:key="option.value"
|
||||
:active="(subscribeStatusFilter || 'all') === option.value"
|
||||
@click="selectFilter(option.value)"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="option.icon" :color="option.color" size="small" />
|
||||
</template>
|
||||
<VListItemTitle>{{ option.label }}</VListItemTitle>
|
||||
<template #append>
|
||||
<VIcon
|
||||
v-if="(subscribeStatusFilter || 'all') === option.value"
|
||||
icon="mdi-check"
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
@@ -246,30 +358,56 @@ onMounted(() => {
|
||||
<Teleport to="body" v-if="searchShareDialog">
|
||||
<VMenu
|
||||
v-model="searchShareDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="searchActivator"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
|
||||
{{ t('subscribe.searchShares') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="searchShareDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="shareKeyword" :label="t('subscribe.keyword')" clearable density="comfortable">
|
||||
<template #append>
|
||||
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardText>
|
||||
<VCard min-width="260" max-width="320">
|
||||
<div class="pa-3">
|
||||
<VTextField
|
||||
v-model="shareKeywordInput"
|
||||
:placeholder="t('subscribe.keyword')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && route.path.startsWith(`/subscribe/${subType === '电影' ? 'movie' : 'tv'}`)">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
v-if="showSubscribeHistoryAction"
|
||||
icon="mdi-history"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
appear
|
||||
class="compact-fab compact-fab--secondary"
|
||||
@click="openSubscribeHistoryDialog"
|
||||
/>
|
||||
<VFab
|
||||
v-if="showDefaultRuleAction"
|
||||
icon="mdi-clipboard-edit-outline"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openDefaultRuleDialog"
|
||||
/>
|
||||
<VFab
|
||||
v-if="showShareStatisticsAction"
|
||||
icon="mdi-chart-line"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openShareStatisticsDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- 订阅编辑弹窗 -->
|
||||
<SubscribeEditDialog
|
||||
v-if="subscribeEditDialog"
|
||||
|
||||
@@ -1,42 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import WorkflowListView from '@/views/workflow/WorkflowListView.vue'
|
||||
import WorkflowShareView from '@/views/workflow/WorkflowShareView.vue'
|
||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { getWorkflowTabs } from '@/router/i18n-menu'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const { appMode } = usePWA()
|
||||
|
||||
const activeTab = ref((route.query.tab as string) || 'list')
|
||||
const shareViewKey = ref(0)
|
||||
const listViewKey = ref(0)
|
||||
const workflowListViewRef = ref<InstanceType<typeof WorkflowListView> | null>(null)
|
||||
|
||||
// 获取标签页
|
||||
const workflowTabs = computed(() => {
|
||||
return getWorkflowTabs(t)
|
||||
})
|
||||
|
||||
// 新增工作流对话框
|
||||
const addWorkflowDialog = ref(false)
|
||||
|
||||
// 分享搜索词
|
||||
const shareKeyword = ref('')
|
||||
const shareKeywordInput = ref('')
|
||||
|
||||
// 搜索分享对话框
|
||||
const searchShareDialog = ref(false)
|
||||
|
||||
// 搜索分享激活器
|
||||
const searchActivator = computed(() => '[data-menu-activator="search-btn"]')
|
||||
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
|
||||
|
||||
// 搜索分享
|
||||
const searchShares = () => {
|
||||
shareViewKey.value++
|
||||
function openAddWorkflowDialog() {
|
||||
workflowListViewRef.value?.openAddDialog()
|
||||
}
|
||||
|
||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||
shareKeyword.value = keyword.trim()
|
||||
}, 300)
|
||||
|
||||
watch(shareKeywordInput, newKeyword => {
|
||||
shareKeywordUpdater(newKeyword || '')
|
||||
})
|
||||
|
||||
watch(activeTab, newTab => {
|
||||
if (newTab !== 'share') {
|
||||
searchShareDialog.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
shareKeywordUpdater.cancel()
|
||||
})
|
||||
|
||||
useDynamicButton({
|
||||
icon: 'mdi-plus',
|
||||
onClick: openAddWorkflowDialog,
|
||||
show: computed(() => appMode.value && activeTab.value === 'list'),
|
||||
})
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
|
||||
@@ -46,11 +70,11 @@ registerHeaderTab({
|
||||
modelValue: activeTab,
|
||||
appendButtons: [
|
||||
{
|
||||
icon: 'mdi-search',
|
||||
icon: 'mdi-filter-multiple-outline',
|
||||
variant: 'text',
|
||||
color: computed(() => (shareKeyword.value ? 'primary' : 'gray')),
|
||||
color: computed(() => (shareKeywordInput.value ? 'primary' : 'gray')),
|
||||
class: 'settings-icon-button',
|
||||
dataAttr: 'search-btn',
|
||||
dataAttr: 'share-filter-btn',
|
||||
show: computed(() => activeTab.value === 'share'),
|
||||
action: () => {
|
||||
searchShareDialog.value = true
|
||||
@@ -74,54 +98,54 @@ onMounted(() => {
|
||||
<VWindowItem value="list">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowListView :key="listViewKey" />
|
||||
<WorkflowListView ref="workflowListViewRef" :key="listViewKey" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" :key="shareViewKey" @update="listViewKey++" />
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="listViewKey++" />
|
||||
</div>
|
||||
</transition>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<!-- 新增工作流对话框 -->
|
||||
<WorkflowAddEditDialog
|
||||
v-if="addWorkflowDialog"
|
||||
v-model="addWorkflowDialog"
|
||||
@close="addWorkflowDialog = false"
|
||||
@save="addWorkflowDialog = false"
|
||||
/>
|
||||
|
||||
<!-- 搜索工作流分享弹窗 -->
|
||||
<Teleport to="body" v-if="searchShareDialog">
|
||||
<VMenu
|
||||
v-model="searchShareDialog"
|
||||
width="25rem"
|
||||
:close-on-content-click="false"
|
||||
:activator="searchActivator"
|
||||
location="bottom end"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-movie-search-outline" class="mr-2" />
|
||||
{{ t('workflow.searchShares') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn @click="searchShareDialog = false" />
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VTextField v-model="shareKeyword" :label="t('workflow.searchShares')" clearable density="comfortable">
|
||||
<template #append>
|
||||
<VBtn prepend-icon="mdi-magnify" color="primary" @click="searchShares">{{ t('common.search') }}</VBtn>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCardText>
|
||||
<VCard min-width="260" max-width="320">
|
||||
<div class="pa-3">
|
||||
<VTextField
|
||||
v-model="shareKeywordInput"
|
||||
:placeholder="t('workflow.searchShares')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VMenu>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body" v-if="!appMode && route.path === '/workflow' && activeTab === 'list'">
|
||||
<div class="compact-fab-stack">
|
||||
<VFab
|
||||
icon="mdi-plus"
|
||||
color="primary"
|
||||
appear
|
||||
class="compact-fab compact-fab--primary"
|
||||
@click="openAddWorkflowDialog"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -185,12 +185,6 @@ export function getSettingTabs(t: Composer['t']) {
|
||||
tab: 'subscribe',
|
||||
description: t('settingTabs.subscribe.description'),
|
||||
},
|
||||
{
|
||||
title: t('settingTabs.scheduler.title'),
|
||||
icon: 'mdi-list-box',
|
||||
tab: 'scheduler',
|
||||
description: t('settingTabs.scheduler.description'),
|
||||
},
|
||||
{
|
||||
title: t('settingTabs.notification.title'),
|
||||
icon: 'mdi-bell',
|
||||
@@ -289,3 +283,20 @@ export function getWorkflowTabs(t: Composer['t']) {
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 插件侧栏分组(与后端 get_sidebar_nav 的 section 一致) */
|
||||
export type PluginSidebarSection = 'start' | 'discovery' | 'subscribe' | 'organize' | 'system'
|
||||
|
||||
/**
|
||||
* 将插件声明的 section 映射为与 getNavMenus 一致的已翻译 header(用于 NavMenu.header)
|
||||
*/
|
||||
export function pluginSidebarSectionToHeaderKey(section: string, t: Composer['t']): string {
|
||||
const map: Record<string, string> = {
|
||||
start: 'menu.start',
|
||||
discovery: 'menu.discovery',
|
||||
subscribe: 'menu.subscribe',
|
||||
organize: 'menu.organize',
|
||||
system: 'menu.system',
|
||||
}
|
||||
return t(map[section] ?? 'menu.system')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -140,6 +141,14 @@ const router = createRouter({
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/plugin-app/:pluginId/:navKey?',
|
||||
name: 'plugin-app',
|
||||
component: () => import('../pages/plugin-app.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/setting',
|
||||
component: () => import('../pages/setting.vue'),
|
||||
@@ -152,7 +161,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/browse.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -161,7 +169,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/credits.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -170,7 +177,6 @@ const router = createRouter({
|
||||
component: () => import('../pages/person.vue'),
|
||||
props: true,
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -178,7 +184,6 @@ const router = createRouter({
|
||||
path: '/media',
|
||||
component: () => import('../pages/media.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
@@ -195,6 +200,7 @@ const router = createRouter({
|
||||
path: '/apps',
|
||||
component: () => import('../pages/appcenter.vue'),
|
||||
meta: {
|
||||
keepAlive: true,
|
||||
requiresAuth: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,7 +12,18 @@ declare let self: ServiceWorkerGlobalScope & {
|
||||
|
||||
// 缓存版本控制
|
||||
const RESOURCE_VERSION = 'V2'
|
||||
const CACHE_VERSION = `${__APP_VERSION__}-${__BUILD_TIME__}` // 开发环境下无法使用此环境变量,生产环境正常
|
||||
// 开发态 dev-sw 可能拿不到 Vite define 注入;仅在开发环境做 dev 兜底
|
||||
const hasAppVersion = typeof __APP_VERSION__ !== 'undefined'
|
||||
const hasBuildTime = typeof __BUILD_TIME__ !== 'undefined'
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
if (!isDev && (!hasAppVersion || !hasBuildTime)) {
|
||||
throw new Error('[SW] Missing __APP_VERSION__ or __BUILD_TIME__ in production build')
|
||||
}
|
||||
|
||||
const appVersion = hasAppVersion ? __APP_VERSION__ : 'dev'
|
||||
const buildTime = hasBuildTime ? __BUILD_TIME__ : 'dev'
|
||||
const CACHE_VERSION = `${appVersion}-${buildTime}`
|
||||
|
||||
// 启用导航预载
|
||||
navigationPreload.enable()
|
||||
@@ -136,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实时日志流
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { authState } from '@/stores/types'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): authState => ({
|
||||
@@ -31,6 +32,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
logout() {
|
||||
this.clearToken()
|
||||
this.setOriginalPath(null)
|
||||
usePluginSidebarNavStore().reset()
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ export const useGlobalSettingsStore = defineStore('globalSettings', {
|
||||
|
||||
// 检查版本更新
|
||||
if (result.FRONTEND_VERSION) {
|
||||
const isBackendDev = Boolean(result.BACKEND_DEV)
|
||||
const skipVersionCheck = import.meta.env.DEV || isBackendDev
|
||||
|
||||
if (skipVersionCheck) {
|
||||
console.log('[VersionChecker] 开发环境下跳过版本一致性检查')
|
||||
return
|
||||
}
|
||||
|
||||
const { checkVersion } = useVersionChecker()
|
||||
await checkVersion(result.FRONTEND_VERSION)
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ export default pinia
|
||||
import { useAuthStore } from './auth'
|
||||
import { useUserStore } from './user'
|
||||
import { useGlobalSettingsStore } from './global'
|
||||
import { usePluginSidebarNavStore } from './pluginSidebarNav'
|
||||
|
||||
export { useAuthStore, useUserStore, useGlobalSettingsStore }
|
||||
export { useAuthStore, useUserStore, useGlobalSettingsStore, usePluginSidebarNavStore }
|
||||
|
||||
49
src/stores/pluginSidebarNav.ts
Normal file
49
src/stores/pluginSidebarNav.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import api from '@/api'
|
||||
import type { PluginSidebarNavItem } from '@/api/types'
|
||||
|
||||
/**
|
||||
* 缓存 GET plugin/sidebar_nav 结果,供 DefaultLayout 与 appcenter 等共用,避免重复请求。
|
||||
*/
|
||||
export const usePluginSidebarNavStore = defineStore('pluginSidebarNav', {
|
||||
state: () => ({
|
||||
items: [] as PluginSidebarNavItem[],
|
||||
/** 是否已成功拉取过一次(含空数组) */
|
||||
loaded: false,
|
||||
/** 并发去重:同一时刻只进行一次请求 */
|
||||
inflight: null as Promise<void> | null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* 确保侧栏导航数据已加载;已缓存则直接返回,并发调用共享同一请求。
|
||||
* @param force 为 true 时忽略缓存重新请求(如登出后再登录可配合 reset + ensure)
|
||||
*/
|
||||
async ensureSidebarNav(force = false): Promise<void> {
|
||||
if (!force && this.loaded) {
|
||||
return
|
||||
}
|
||||
if (this.inflight) {
|
||||
return this.inflight
|
||||
}
|
||||
this.inflight = (async () => {
|
||||
try {
|
||||
const res = await api.get('plugin/sidebar_nav')
|
||||
this.items = Array.isArray(res) ? res : []
|
||||
} catch {
|
||||
this.items = []
|
||||
} finally {
|
||||
this.loaded = true
|
||||
this.inflight = null
|
||||
}
|
||||
})()
|
||||
return this.inflight
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.items = []
|
||||
this.loaded = false
|
||||
this.inflight = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -48,6 +48,143 @@ html.v-overlay-scroll-blocked body {
|
||||
}
|
||||
}
|
||||
|
||||
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
|
||||
.app-card-shell {
|
||||
position: relative;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
|
||||
.app-card-top-action {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.app-card-summary {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
block-size: 7.5rem;
|
||||
min-block-size: 7.5rem;
|
||||
}
|
||||
|
||||
.app-card-summary__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
|
||||
.app-card-summary__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
column-gap: 0.25rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.app-card-summary__title-row > .v-badge {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.app-card-summary__subtitle,
|
||||
.app-card-summary__meta-item {
|
||||
overflow: hidden;
|
||||
min-inline-size: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-card-summary__title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
line-height: 1.35;
|
||||
max-block-size: calc(1.35em * 2);
|
||||
min-inline-size: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-card-summary__title-row .app-card-summary__title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.app-card-summary__meta {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
min-block-size: 1.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.app-card-summary--single-action .app-card-summary__content {
|
||||
padding-inline-end: 3.75rem;
|
||||
}
|
||||
|
||||
.app-card-summary--double-action .app-card-summary__content {
|
||||
padding-inline-end: 5rem;
|
||||
}
|
||||
|
||||
.app-card-summary--title-subtitle {
|
||||
padding-block: 0.75rem !important;
|
||||
}
|
||||
|
||||
.app-card-summary--title-subtitle .app-card-summary__content {
|
||||
justify-content: space-between;
|
||||
block-size: 100%;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
.app-card-summary--title-subtitle .app-card-summary__title {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.app-card-summary--title-subtitle .app-card-summary__subtitle {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-card-summary__media {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
inset-block-end: 0.75rem;
|
||||
inset-inline-end: 1rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-card-summary--single-action .app-card-summary__media,
|
||||
.app-card-summary--double-action .app-card-summary__media {
|
||||
inset-inline-end: 1rem;
|
||||
}
|
||||
|
||||
.app-card-summary__image {
|
||||
flex-shrink: 0;
|
||||
block-size: 3.5rem;
|
||||
inline-size: 3.5rem;
|
||||
max-block-size: 3.5rem;
|
||||
max-inline-size: 3.5rem;
|
||||
min-block-size: 3.5rem;
|
||||
min-inline-size: 3.5rem;
|
||||
}
|
||||
|
||||
.app-card-summary__image .v-img__img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
// Toast通知样式
|
||||
.Vue-Toastification__container {
|
||||
z-index: 2500;
|
||||
@@ -238,6 +375,122 @@ html.v-overlay-scroll-blocked body {
|
||||
opacity:0.75;
|
||||
}
|
||||
|
||||
// 紧凑型悬浮操作按钮
|
||||
.compact-fab-stack {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
inset-block-end: max(1rem, calc(env(safe-area-inset-bottom) + 1rem));
|
||||
inset-inline-end: max(1rem, calc(env(safe-area-inset-right) + 1rem));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.compact-fab-stack > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.compact-fab-stack--history {
|
||||
inset-block-end: max(4.5rem, calc(env(safe-area-inset-bottom) + 4.5rem));
|
||||
}
|
||||
|
||||
.compact-fab.v-fab {
|
||||
display: inline-flex;
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
min-inline-size: 0 !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.compact-fab .v-fab__container {
|
||||
position: static;
|
||||
display: inline-flex;
|
||||
overflow: visible;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.compact-fab .v-btn {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow:
|
||||
0 16px 34px rgb(15 23 42 / 16%),
|
||||
0 6px 16px rgb(15 23 42 / 10%);
|
||||
opacity: 0.98;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
filter 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.compact-fab--primary .v-btn {
|
||||
block-size: 3rem !important;
|
||||
box-shadow:
|
||||
0 20px 40px rgb(15 23 42 / 20%),
|
||||
0 8px 18px rgb(15 23 42 / 12%);
|
||||
inline-size: 3rem !important;
|
||||
}
|
||||
|
||||
.compact-fab--secondary .v-btn {
|
||||
block-size: 3rem !important;
|
||||
inline-size: 3rem !important;
|
||||
}
|
||||
|
||||
.compact-fab--primary .v-icon {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
.compact-fab--secondary .v-icon {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.compact-fab .v-btn:hover {
|
||||
box-shadow:
|
||||
0 22px 42px rgb(15 23 42 / 22%),
|
||||
0 8px 18px rgb(15 23 42 / 12%);
|
||||
filter: saturate(1.03);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.compact-fab--primary .v-btn:hover {
|
||||
box-shadow:
|
||||
0 26px 46px rgb(15 23 42 / 24%),
|
||||
0 10px 22px rgb(15 23 42 / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
.compact-fab .v-btn:active {
|
||||
box-shadow:
|
||||
0 10px 22px rgb(15 23 42 / 16%),
|
||||
0 3px 8px rgb(15 23 42 / 10%);
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.compact-fab-stack {
|
||||
gap: 0.625rem;
|
||||
inset-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.875rem));
|
||||
inset-inline-end: max(0.875rem, calc(env(safe-area-inset-right) + 0.875rem));
|
||||
}
|
||||
|
||||
.compact-fab-stack--history {
|
||||
inset-block-end: max(4rem, calc(env(safe-area-inset-bottom) + 4rem));
|
||||
}
|
||||
|
||||
.compact-fab--primary .v-btn {
|
||||
block-size: 3.5rem !important;
|
||||
inline-size: 3.5rem !important;
|
||||
}
|
||||
|
||||
.compact-fab--secondary .v-btn {
|
||||
block-size: 3rem !important;
|
||||
inline-size: 3rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.apexcharts-title-text {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
|
||||
}
|
||||
@@ -262,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){
|
||||
@@ -311,7 +565,28 @@ html.v-overlay-scroll-blocked body {
|
||||
|
||||
.settings-icon-button {
|
||||
flex-shrink: 0;
|
||||
min-inline-size: auto;
|
||||
border-radius: 0.95rem;
|
||||
block-size: 2.75rem;
|
||||
inline-size: 2.75rem;
|
||||
margin-inline-start: 0.25rem;
|
||||
min-inline-size: 2.75rem;
|
||||
}
|
||||
|
||||
.settings-icon-button .v-icon {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.settings-icon-button {
|
||||
border-radius: 0.825rem;
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
min-inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.settings-icon-button .v-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.v-infinite-scroll__side {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,58 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 nav_key 转为联邦暴露名的 Pascal 片段(如 settings -> Settings,my-tool -> MyTool)
|
||||
*/
|
||||
function navKeyToPascalSegment(navKey: string): string {
|
||||
return navKey
|
||||
.trim()
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载插件全页组件(支持同一插件多界面)。
|
||||
*
|
||||
* 解析顺序(nav_key 为 main 或空时):
|
||||
* `AppPage` → `Page`
|
||||
*
|
||||
* 其它 nav_key(例如 settings、my_tool):
|
||||
* `AppPage{Pascal}` → `AppPage` → `Page`
|
||||
* 例:nav_key=settings → 尝试 `AppPageSettings`,再回退 `AppPage`、`Page`
|
||||
*
|
||||
* 也可在单个 `AppPage.vue` 内根据 `navKey` prop 分支渲染,无需多文件。
|
||||
*/
|
||||
export async function loadRemoteAppPageComponent(id: string, navKey: string = 'main') {
|
||||
const raw = (navKey || 'main').trim()
|
||||
const isMain = raw === '' || raw.toLowerCase() === 'main'
|
||||
|
||||
const candidateNames: string[] = []
|
||||
if (isMain) {
|
||||
candidateNames.push('AppPage', 'Page')
|
||||
} else {
|
||||
const pascal = navKeyToPascalSegment(raw)
|
||||
if (pascal) {
|
||||
candidateNames.push(`AppPage${pascal}`)
|
||||
}
|
||||
candidateNames.push('AppPage', 'Page')
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
for (const name of candidateNames) {
|
||||
try {
|
||||
return await loadRemoteComponent(id, name)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
console.debug(`[federation] 插件 ${id} 全页尝试 ./${name} 失败,回退下一候选`)
|
||||
}
|
||||
}
|
||||
console.warn(`[federation] 插件 ${id} 全页均加载失败 (navKey=${raw})`, lastError)
|
||||
throw lastError ?? new Error(`无法加载插件 ${id} 的全页组件`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载远程组件
|
||||
* @param id 远程模块ID
|
||||
@@ -80,9 +132,9 @@ async function fetchRemoteModules(): Promise<RemoteModule[]> {
|
||||
* @param modules 远程模块列表
|
||||
*/
|
||||
function injectRemoteModule(module: RemoteModule): void {
|
||||
// 从浏览器地址栏获取当前地址前缀
|
||||
// 与 API 请求一致:使用 origin + pathname 作为前缀,子路径代理时 pathname 含 /mp 等
|
||||
const baseUrl = new URL(window.location.href)
|
||||
// 环境变量
|
||||
const pathBase = baseUrl.pathname.replace(/\/$/, '') || ''
|
||||
let apiBase = import.meta.env.VITE_API_BASE_URL
|
||||
if (apiBase.startsWith('/')) {
|
||||
apiBase = apiBase.slice(1)
|
||||
@@ -90,8 +142,10 @@ function injectRemoteModule(module: RemoteModule): void {
|
||||
if (apiBase.endsWith('/')) {
|
||||
apiBase = apiBase.slice(0, -1)
|
||||
}
|
||||
const pathWithoutLeadingSlash = module.url.startsWith('/') ? module.url.slice(1) : module.url
|
||||
const remoteEntryUrl = `${baseUrl.origin}${pathBase}/${apiBase}/${pathWithoutLeadingSlash}`
|
||||
__federation_method_setRemote(module.id, {
|
||||
url: () => Promise.resolve(`${baseUrl.origin}/${apiBase}${module.url}`),
|
||||
url: () => Promise.resolve(remoteEntryUrl),
|
||||
format: 'esm',
|
||||
from: 'vite',
|
||||
})
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
// 导入所有 logo 图标
|
||||
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 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 telegramLogo from '@/assets/images/logos/telegram.webp'
|
||||
import slackLogo from '@/assets/images/logos/slack.webp'
|
||||
@@ -29,15 +31,18 @@ import pluginLogo from '@/assets/images/logos/plugin.png'
|
||||
import siteLogo from '@/assets/images/logos/site.webp'
|
||||
import bangumiLogo from '@/assets/images/logos/bangumi.png'
|
||||
import doubanBlackLogo from '@/assets/images/logos/douban-black.png'
|
||||
import qqLogo from '@/assets/images/logos/qq.png'
|
||||
|
||||
// 图标映射表
|
||||
const logoMap: Record<string, string> = {
|
||||
qbittorrent: qbittorrentLogo,
|
||||
transmission: transmissionLogo,
|
||||
rtorrent: rtorrentLogo,
|
||||
emby: embyLogo,
|
||||
jellyfin: jellyfinLogo,
|
||||
plex: plexLogo,
|
||||
trimemedia: trimemediaLogo,
|
||||
ugreen: ugreenLogo,
|
||||
wechat: wechatLogo,
|
||||
telegram: telegramLogo,
|
||||
slack: slackLogo,
|
||||
@@ -57,6 +62,7 @@ const logoMap: Record<string, string> = {
|
||||
site: siteLogo,
|
||||
bangumi: bangumiLogo,
|
||||
'douban-black': doubanBlackLogo,
|
||||
qq: qqLogo,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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)
|
||||
}
|
||||
54
src/utils/pluginSidebarNav.ts
Normal file
54
src/utils/pluginSidebarNav.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Composer } from 'vue-i18n'
|
||||
import type { NavMenu } from '@/@layouts/types'
|
||||
import type { PluginSidebarNavItem } from '@/api/types'
|
||||
import { pluginSidebarSectionToHeaderKey } from '@/router/i18n-menu'
|
||||
import { filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
export type PluginNavMenuEntry = {
|
||||
navMenu: NavMenu & { permission?: string }
|
||||
section: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 sidebar_nav 单项转为侧栏 / 应用中心 共用的 NavMenu
|
||||
*/
|
||||
export function navMenuFromPluginSidebarItem(
|
||||
item: PluginSidebarNavItem,
|
||||
t: Composer['t'],
|
||||
): NavMenu & { permission?: string } {
|
||||
const section = item.section || 'system'
|
||||
const header = pluginSidebarSectionToHeaderKey(section, t)
|
||||
return {
|
||||
title: item.title,
|
||||
icon: item.icon,
|
||||
to: {
|
||||
name: 'plugin-app',
|
||||
params: {
|
||||
pluginId: item.plugin_id,
|
||||
navKey: item.nav_key,
|
||||
},
|
||||
},
|
||||
header,
|
||||
permission: item.permission ?? undefined,
|
||||
} as NavMenu & { permission?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤有权限的插件导航项,并保留 section 供 DefaultLayout 分栏插入
|
||||
*/
|
||||
export function filterPluginSidebarNavEntries(
|
||||
items: PluginSidebarNavItem[],
|
||||
t: Composer['t'],
|
||||
userPermissions: Record<string, unknown>,
|
||||
): PluginNavMenuEntry[] {
|
||||
const out: PluginNavMenuEntry[] = []
|
||||
for (const item of items) {
|
||||
const section = item.section || 'system'
|
||||
const navMenu = navMenuFromPluginSidebarItem(item, t)
|
||||
if (!filterMenusByPermission([navMenu], userPermissions).length) {
|
||||
continue
|
||||
}
|
||||
out.push({ navMenu, section })
|
||||
}
|
||||
return out
|
||||
}
|
||||
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 }
|
||||
@@ -28,7 +28,7 @@ async function loadMediaStatistic() {
|
||||
},
|
||||
{
|
||||
title: t('dashboard.episodes'),
|
||||
stats: res.episode_count.toLocaleString(),
|
||||
stats: res.episode_count == null ? t('common.notFetched') : res.episode_count.toLocaleString(),
|
||||
icon: 'mdi-television-classic',
|
||||
color: 'warning',
|
||||
},
|
||||
|
||||
@@ -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,9 +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 = new Set<string>()
|
||||
|
||||
// 拼装参数
|
||||
function getParams() {
|
||||
@@ -41,6 +44,46 @@ function getParams() {
|
||||
return params
|
||||
}
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
'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.has(key)) {
|
||||
return false
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取列表数据
|
||||
async function fetchData({ done }: { done: any }) {
|
||||
try {
|
||||
@@ -59,20 +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
|
||||
}
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -83,17 +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 {
|
||||
// 合并数据
|
||||
dataList.value = [...dataList.value, ...currData.value]
|
||||
appendData(uniqueData)
|
||||
// 页码+1
|
||||
page.value++
|
||||
// 返回加载成功
|
||||
@@ -115,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user