Compare commits

...

97 Commits

Author SHA1 Message Date
jxxghp
ba343ce5fa style: adjust login page layout spacing and formatting for improved visual alignment 2026-04-21 22:20:04 +08:00
jxxghp
60495668a6 Simplify LLM settings connectivity test 2026-04-21 22:14:19 +08:00
jxxghp
f2ac624dbb fix: refine ai assistant settings feedback 2026-04-21 21:25:21 +08:00
jxxghp
6238849d3f refactor: optimize ai assistant settings actions 2026-04-21 21:11:29 +08:00
笨笨
82cb903c1f fix: localize llm test result labels 2026-04-21 20:41:39 +08:00
笨笨
5e5eb95b55 feat: add llm test button 2026-04-21 20:41:39 +08:00
jxxghp
74e6f8b03e bump version to 2.10.3 2026-04-21 08:55:50 +08:00
jxxghp
a2bf0d2b16 refine settings card layouts 2026-04-21 08:50:14 +08:00
jxxghp
7532d39978 style: update plugin repository icon and increase compact FAB icon size 2026-04-19 14:45:35 +08:00
jxxghp
5cc9bf7418 style: reduce compact-fab size and standardize padding across filter menus 2026-04-19 13:35:25 +08:00
jxxghp
20bdb940cd refactor: standardize floating action buttons with a compact stack layout and migrate menu items to key-based i18n resolution 2026-04-19 13:00:04 +08:00
jxxghp
e9b214cff8 refactor: enhance dynamic button system to support menus, reactive properties, and improved PWA floating action button integration 2026-04-19 12:29:02 +08:00
jxxghp
54f5fb2877 更新 login.vue 2026-04-19 07:21:25 +08:00
jxxghp
e86cb9e1cc Merge pull request #461 from InfinityPacer/codex/feat/local-plugin-paths 2026-04-19 07:07:55 +08:00
InfinityPacer
3f258b9016 fix(plugin): resort market list when statistics load 2026-04-19 04:21:00 +08:00
InfinityPacer
b54e144d0e feat(plugin): show local repo paths in repository filter 2026-04-19 04:20:49 +08:00
InfinityPacer
7b20a7b775 refactor(setting): rename local repo paths setting 2026-04-19 03:03:22 +08:00
InfinityPacer
df66b3e917 fix(plugin): local source label and detection 2026-04-19 02:54:09 +08:00
jxxghp
a919622d08 Document nettest target loading flow 2026-04-18 17:52:01 +08:00
jxxghp
2a9ce950b7 Use backend-managed nettest targets 2026-04-18 17:43:38 +08:00
InfinityPacer
48c12b765d feat(setting): expose local plugin paths 2026-04-18 03:11:55 +08:00
InfinityPacer
1120055eed feat(plugin): support local plugin sources 2026-04-18 03:01:16 +08:00
jxxghp
c66b6649e2 feat: enable drag sorting for plugin market repos 2026-04-17 21:01:33 +08:00
jxxghp
8479099926 fix: simplify plugin market repo display 2026-04-17 20:42:11 +08:00
jxxghp
cab65be1c9 feat: update plugin market settings UI layout and refine localization strings 2026-04-17 15:25:36 +08:00
jxxghp
6689e976c2 更新 package.json 2026-04-17 15:12:00 +08:00
jxxghp
712dfa3fe1 feat: improve transfer history footer actions and plugin market settings 2026-04-17 15:02:56 +08:00
jxxghp
346121f3c2 chore: bump version to 2.10.1 2026-04-16 19:51:02 +08:00
jxxghp
61c073ad6c refactor: remove recognize source selection and hardcode to themoviedb in setup wizard 2026-04-16 19:50:37 +08:00
jxxghp
4b3733bc19 feat: add site auth step to setup wizard 2026-04-16 19:21:17 +08:00
jxxghp
b29c6bd83f feat: add AI agent configuration step and expand basic settings with OCR and recognition source options 2026-04-16 17:36:27 +08:00
jxxghp
b40fc4bd30 更新 package.json 2026-04-16 10:42:02 +08:00
jxxghp
a225ba6075 feat: implement responsive filter panel with collapsible search for mobile layout 2026-04-15 17:59:35 +08:00
jxxghp
303fe39c01 更新 package.json 2026-04-15 17:17:09 +08:00
jxxghp
d343cbcf71 Add AI redo progress viewer 2026-04-15 17:10:18 +08:00
jxxghp
0eef8c5174 Optimize site resource dialog layout 2026-04-15 14:18:02 +08:00
jxxghp
46fe257585 feat: add LLM image input support toggle to system settings 2026-04-15 08:46:51 +08:00
jxxghp
f69a57863e feat: add AI-powered reorganization option to transfer history records 2026-04-14 15:39:15 +08:00
jxxghp
8876aadcfa 更新 package.json 2026-04-13 18:42:07 +08:00
jxxghp
485e9691a0 Merge pull request #460 from InfinityPacer/codex/fix/dev-version-check-skip 2026-04-13 06:55:33 +08:00
InfinityPacer
a0e7283ae6 fix(version): skip mismatch toast in dev and harden sw fallback 2026-04-12 23:38:56 +08:00
jxxghp
b44c0647f1 更新 package.json 2026-04-12 10:24:07 +08:00
jxxghp
7e60ab9064 Merge pull request #459 from PKC278/v2 2026-04-12 10:23:49 +08:00
PKC278
f05c1f42b5 fix: 修复推荐和探索页面电视剧已入库标识不显示的问题 2026-04-12 09:52:18 +08:00
jxxghp
672bbb4265 feat:资源渐进式搜索 2026-04-10 16:48:29 +08:00
jxxghp
10c1041b06 Improve resource search loading UI 2026-04-10 16:07:17 +08:00
jxxghp
59c73facfe fix: expand path directories on row click 2026-04-10 15:44:13 +08:00
jxxghp
ba7d4cd392 fix: avoid closing cron menu while selecting options 2026-04-10 15:33:08 +08:00
jxxghp
d76a50c216 fix media detail transparent backdrop 2026-04-10 14:33:38 +08:00
jxxghp
617223777b refactor: 统一过滤图标为 mdi-filter-multiple-outline,插件市场筛选改为下拉多选 2026-04-09 13:03:58 +08:00
jxxghp
6ef047050d refactor: 将订阅和插件过滤弹窗改为站点管理风格的下拉列表菜单 2026-04-09 12:28:30 +08:00
jxxghp
942ecc4c04 Merge pull request #458 from DDSRem-Dev/v2 2026-04-09 08:06:48 +08:00
DDSRem
e72f9a8374 feat(plugin): 侧栏全页 AppPage、多 nav_key 联邦加载与 sidebar_nav 缓存
- 新增路由 plugin-app 与壳页,按 nav_key 尝试 AppPage{Pascal}/AppPage/Page
- DefaultLayout 与 appcenter 合并插件侧栏项;plugin/sidebar_nav 经 Pinia 去重缓存
- 工具 pluginSidebarNav、联邦 loader 与文档/示例更新;登出时清空侧栏缓存

Made-with: Cursor
2026-04-09 07:59:40 +08:00
jxxghp
9cf782eb5b style: remove background color from search bar container 2026-04-08 15:15:16 +08:00
jxxghp
660338688a refactor: adjust TransferHistoryView layout offset and apply code style improvements 2026-04-08 13:47:04 +08:00
jxxghp
2d50bd7536 refactor: optimize useAvailableHeight to improve resize event handling and performance 2026-04-08 13:23:29 +08:00
jxxghp
b02a4f1347 refactor: extract available height calculation logic into a reusable useAvailableHeight composable 2026-04-08 13:08:51 +08:00
jxxghp
1748fdea34 feat: optimize search bar UI with responsive capsule trigger and mobile-friendly dialog footer 2026-04-08 10:09:38 +08:00
jxxghp
6bbaf43671 feat: redesign search dialog UI with a custom input layout and OS-aware keyboard shortcuts 2026-04-08 08:28:42 +08:00
jxxghp
4a66aaadad rollback footer 2026-04-07 13:18:27 +08:00
jxxghp
e2e239f6d9 fix: 消息中心纯文本换行丢失、登录页优化、底部导航液态玻璃效果 2026-04-07 13:10:19 +08:00
jxxghp
fe22403e66 feat: 新增文件整理失败智能接管设置项(AI_AGENT_RETRY_TRANSFER) 2026-04-03 13:39:57 +08:00
jxxghp
3313c71805 feat: 更新日志弹窗支持Markdown渲染 2026-04-03 07:10:12 +08:00
jxxghp
1e60e83514 feat: 新增智能体(Agent)消息类型,优化通知开关加载逻辑自动合并新增类型 2026-04-02 19:11:42 +08:00
jxxghp
9c893abcdf fix: 优化登录页面密码管理器自动填充 2026-03-30 12:56:17 +08:00
jxxghp
ead891ca2f 更新 AccountSettingSystem.vue 2026-03-27 22:22:17 +08:00
jxxghp
8713e3cc86 feat: Add AI agent verbose mode, rename scheduled wake setting to scheduled wake, and update system settings layout. 2026-03-27 20:59:53 +08:00
jxxghp
3cc83d10d3 refactor: Relocate scheduler service settings from the main settings page to a new dedicated system view accessible via the shortcut bar. 2026-03-25 13:37:59 +08:00
jxxghp
192ded374a feat:增加 AI_AGENT_JOB_INTERVAL 设置项 2026-03-25 13:06:44 +08:00
jxxghp
13997c7e74 Merge pull request #457 from wikrin/style/settings-ui 2026-03-20 21:30:54 +08:00
jxxghp
71b0dd4cc2 更新 package.json 2026-03-19 22:25:59 +08:00
Attente
a58a0cdffe refactor(AccountSettingSystem): 重构按钮图标结构样式 2026-03-19 21:47:52 +08:00
jxxghp
6aeb040db4 Merge pull request #456 from wikrin/refactor/scraping-switch-to-policy 2026-03-19 21:35:15 +08:00
Attente
fef20e361e refactor(setting): 更新刮削策略设置界面 2026-03-19 20:13:16 +08:00
jxxghp
a63a07701d 更新 package.json 2026-03-14 18:03:02 +08:00
jxxghp
5dd56f2db3 Merge pull request #455 from EkkoG/wechat_bot 2026-03-14 18:02:18 +08:00
EkkoG
275b095574 feat(wechat): implement AI bot mode configuration and localization updates
- Added functionality to enable AI bot mode for WeChat notifications, including default configuration settings.
- Introduced new input fields for bot-specific settings such as Bot ID, Bot Secret, and WebSocket URL.
- Updated localization files for English, Simplified Chinese, and Traditional Chinese to include new bot-related labels and hints.
2026-03-14 16:18:02 +08:00
jxxghp
05eae71fba Merge pull request #454 from YuF-9468/fix-issue-438-episode-zero 2026-03-13 22:39:25 +08:00
YuF-9468
777b3c9445 refactor(media): remove any cast for episode count fields 2026-03-13 15:31:24 +08:00
YuF-9468
a214168b1e fix(media): avoid false in-library badge for TV seasons with zero episodes 2026-03-13 14:38:58 +08:00
jxxghp
9d55d02557 Merge pull request #453 from DDSRem-Dev/v2 2026-03-11 15:28:30 +08:00
DDSRem
16c084ba80 fix(plugin): build remote entry URL with origin+pathname to fix subpath proxy 404
- Use pathBase (pathname) when building remoteEntry URL so it matches API request base
- Fixes plugin static assets 404 when app is under subpath (e.g. /mp/)

Made-with: Cursor
2026-03-11 15:12:42 +08:00
jxxghp
b0f4ccc186 Merge pull request #451 from WongWang/feat-plugin-priority 2026-03-10 12:55:00 +08:00
jxxghp
96d0606b4d chore: bump version to 2.9.14 2026-03-08 08:52:53 +08:00
jxxghp
450b9ec28a feat: Add QQ logo for qqbot notifications, update Ugreen logo to PNG, and adjust VImg styling in media server card. 2026-03-08 08:52:29 +08:00
jxxghp
2ccf03fc1b Merge pull request #452 from EkkoG/qqbot 2026-03-08 07:51:02 +08:00
EkkoG
38dfb3af07 feat: add QQ notification channel support with validation and localization 2026-03-07 23:21:09 +08:00
Castell
ae4c59bfdb feat: 新增优先使用插件识别的功能 2026-03-02 21:04:43 +08:00
jxxghp
c9f4fdbee8 Merge pull request #450 from baozaodetudou/v2 2026-03-01 08:08:35 +08:00
doumao
d21f461dda Merge branch 'v2' of https://github.com/jxxghp/MoviePilot-Frontend into v2 2026-02-28 22:58:29 +08:00
doumao
28a5a83315 feat: 前端支持绿联SSL证书校验开关配置 2026-02-28 22:54:27 +08:00
jxxghp
11d11b88bf Merge pull request #449 from baozaodetudou/v2 2026-02-28 22:45:12 +08:00
doumao
ff7658b5ba feat: 完成绿联媒体服务前端接入与展示优化 2026-02-28 22:09:09 +08:00
jxxghp
351faf2891 更新 package.json 2026-02-28 12:52:34 +08:00
jxxghp
7d66229bad Merge pull request #448 from wumode/fix-progress-displaying 2026-02-28 12:34:17 +08:00
wumode
2b08be1e7d fix(reorganize): add progress tracking for log transfer 2026-02-28 01:17:18 +08:00
wumode
8255cfd479 fix(reorganize): dynamically update progress SSE connection based on item path 2026-02-27 16:20:04 +08:00
84 changed files with 6856 additions and 2277 deletions

View File

@@ -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. 构建和部署
### 构建项目

View File

@@ -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和模块联邦配置

View 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>

View 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>

View File

@@ -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: {

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.9.12",
"version": "2.10.3",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -80,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) => {
@@ -278,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: '其它',

View File

@@ -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 }

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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>

View File

@@ -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" />

View File

@@ -101,19 +101,21 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<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

View File

@@ -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

View File

@@ -252,6 +252,7 @@ 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 }"
>
@@ -261,9 +262,9 @@ onUnmounted(() => {
<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
@@ -271,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>

View File

@@ -45,7 +45,7 @@ onMounted(() => {
</script>
<template>
<VCard variant="tonal">
<VCard variant="tonal" class="app-card-shell">
<span class="absolute top-3 right-12">
<IconBtn>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -53,7 +53,7 @@ onMounted(() => {
</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

View File

@@ -205,22 +205,24 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<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

View File

@@ -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前缀

View File

@@ -18,9 +18,14 @@ import { hasPermission } from '@/utils/permission'
// 国际化
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,
})

View File

@@ -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

View File

@@ -24,6 +24,7 @@ const imageLoadError = ref(false)
// 初始化 markdown-it
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})

View File

@@ -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">
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<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

View File

@@ -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 += '/'

View File

@@ -216,11 +216,17 @@ onMounted(() => {
<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': 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,11 +237,11 @@ 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 class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
@@ -254,7 +260,7 @@ onMounted(() => {
<!-- 中间部分网址 -->
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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&amp;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&amp;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>

View File

@@ -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,7 @@ const transferItems = ref<FileItem[]>([])
// 当前图片地址
const currentImgLink = ref('')
// 计算列表可用高度
const listAvailableHeight = computed(() => {
// 获取视口高度
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
// 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)
})
// 是否为图片文件
const isImage = computed(() => {

View File

@@ -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({

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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,
}
}

View File

@@ -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 })
}
// 返回控制函数和状态

View File

@@ -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,22 @@ export interface WizardData {
config: any
switchs: any[]
}
agent: {
enabled: boolean
global: boolean
verbose: boolean
provider: string
model: string
supportImageInput: boolean
apiKey: string
baseUrl: string
maxContextTokens: number
jobInterval: number
retryTransfer: boolean
recommendEnabled: boolean
recommendUserPreference: string
recommendMaxItems: number
}
preferences: {
quality: string
subtitle: string
@@ -67,6 +90,10 @@ export interface ConnectivityTestState {
}
export interface ValidationErrorState {
siteAuth: {
site: boolean
[key: string]: boolean
}
downloader: {
name: boolean
host: boolean
@@ -85,11 +112,18 @@ export interface ValidationErrorState {
name: boolean
[key: string]: boolean
}
agent: {
provider: boolean
apiKey: boolean
model: boolean
maxContextTokens: boolean
recommendMaxItems: boolean
}
}
// 全局状态,所有组件共享
const currentStep = ref(1)
const totalSteps = 6
const totalSteps = 8
// 加载状态
const isLoading = ref(false)
@@ -97,6 +131,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 +155,16 @@ const wizardData = ref<WizardData>({
username: '',
password: '',
confirmPassword: '',
recognizeSource: 'themoviedb',
ocrHost: '',
proxyHost: '',
githubToken: '',
},
siteAuth: {
auxiliaryAuthEnable: false,
site: '',
params: {},
},
storage: {
downloadPath: '',
libraryPath: '',
@@ -133,6 +190,22 @@ const wizardData = ref<WizardData>({
config: {},
switchs: [],
},
agent: {
enabled: false,
global: false,
verbose: false,
provider: 'deepseek',
model: 'deepseek-chat',
supportImageInput: true,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
maxContextTokens: 64,
jobInterval: 0,
retryTransfer: false,
recommendEnabled: false,
recommendUserPreference: '',
recommendMaxItems: 50,
},
preferences: {
quality: '4K',
subtitle: 'chinese',
@@ -151,6 +224,9 @@ const connectivityTest = ref<ConnectivityTestState>({
// 验证错误状态
const validationErrors = ref<ValidationErrorState>({
siteAuth: {
site: false,
},
downloader: {
name: false,
host: false,
@@ -168,6 +244,13 @@ const validationErrors = ref<ValidationErrorState>({
notification: {
name: false,
},
agent: {
provider: false,
apiKey: false,
model: false,
maxContextTokens: false,
recommendMaxItems: false,
},
})
export function useSetupWizard() {
@@ -181,6 +264,7 @@ export function useSetupWizard() {
downloader: {
'qbittorrent': 'QbittorrentModule',
'transmission': 'TransmissionModule',
'rtorrent': 'RtorrentModule',
},
// 媒体服务器映射
mediaServer: {
@@ -188,6 +272,7 @@ export function useSetupWizard() {
'jellyfin': 'JellyfinModule',
'plex': 'PlexModule',
'trimemedia': 'TrimeMediaModule',
'ugreen': 'UgreenModule',
},
// 通知映射
notification: {
@@ -195,6 +280,7 @@ export function useSetupWizard() {
'wechat': 'WechatModule',
'slack': 'SlackModule',
'synologychat': 'SynologyChatModule',
'qqbot': 'QQBotModule',
'vocechat': 'VoceChatModule',
'webpush': 'WebPushModule',
},
@@ -203,20 +289,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,6 +413,9 @@ export function useSetupWizard() {
// 清除验证错误状态
function clearValidationErrors() {
validationErrors.value.siteAuth = {
site: false,
}
validationErrors.value.downloader = {
name: false,
host: false,
@@ -340,6 +433,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 +501,11 @@ export function useSetupWizard() {
}
// 根据下载器类型验证其他必输项
if (wizardData.value.downloader.type === 'qbittorrent' || wizardData.value.downloader.type === 'transmission') {
if (
wizardData.value.downloader.type === 'qbittorrent'
|| 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 +550,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 +631,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()) {
errors.push(t('setupWizard.agent.apiKeyRequired'))
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 +724,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 +739,7 @@ export function useSetupWizard() {
}
break
case 3: // 下载器设置
case 4: // 下载器设置
if (wizardData.value.downloader.type) {
// 如果选择了下载器,则验证必输项
const validation = validateDownloaderFields()
@@ -536,7 +747,7 @@ export function useSetupWizard() {
}
break
case 4: // 媒体服务器设置
case 5: // 媒体服务器设置
if (wizardData.value.mediaServer.type) {
// 如果选择了媒体服务器,则验证必输项
const validation = validateMediaServerFields()
@@ -544,7 +755,7 @@ export function useSetupWizard() {
}
break
case 5: // 通知设置
case 6: // 通知设置
if (wizardData.value.notification.type) {
// 如果选择了通知,则验证必输项
const validation = validateNotificationFields()
@@ -552,7 +763,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 +785,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 +812,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 +1005,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 +1028,8 @@ export function useSetupWizard() {
currentStep.value++
connectivityTest.value.showResult = false
}
return true
}
// 上一步
@@ -818,35 +1045,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 +1129,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 +1138,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 +1193,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 +1250,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 +1280,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 +1310,46 @@ 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_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
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 +1378,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 +1414,30 @@ 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.model = result.data.LLM_MODEL || ''
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
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 +1449,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 +1540,8 @@ export function useSetupWizard() {
isLoading.value = true
try {
await loadSystemSettings()
await loadAuthSites()
await loadSiteAuthSettings()
await loadStorageSettings()
await loadDownloaderSettings()
await loadMediaServerSettings()
@@ -1206,6 +1558,7 @@ export function useSetupWizard() {
stepTitles,
stepDescriptions,
wizardData,
authSites,
selectedPreset,
connectivityTest,
validationErrors,
@@ -1220,9 +1573,11 @@ export function useSetupWizard() {
selectPreset,
updatePreferences,
validateCurrentStep,
validateSiteAuthFields,
validateDownloaderFields,
validateMediaServerFields,
validateNotificationFields,
validateAgentFields,
clearValidationErrors,
testConnectivity,
nextStep,

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -7,6 +7,7 @@ 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'
@@ -49,6 +50,9 @@ const wordsDialog = ref(false)
// 缓存管理弹窗
const cacheDialog = ref(false)
// 定时服务弹窗
const schedulerDialog = ref(false)
// 输入消息
const user_message = ref('')
@@ -108,6 +112,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'),
@@ -275,10 +286,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">
@@ -420,6 +431,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"

View File

@@ -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}',
@@ -89,6 +90,7 @@ export default {
mediaServer: 'Media Server',
manual: 'Manual',
plugin: 'Plugin',
agent: 'Agent',
other: 'Other',
},
actionStep: {
@@ -256,6 +258,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 +317,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 +437,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 +455,15 @@ 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 +534,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 +584,10 @@ export default {
title: 'Cache',
subtitle: 'Manage Cache',
},
scheduler: {
title: 'Services',
subtitle: 'Scheduled Services',
},
},
workflow: {
components: 'Action Components',
@@ -1300,13 +1334,33 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
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',
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.',
@@ -1341,6 +1395,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: 'Ugreen',
reloadSuccess: 'System configuration has taken effect',
reloadFailed: 'Failed to reload system!',
auxAuthEnable: 'User Auxiliary Authentication',
@@ -1383,6 +1438,8 @@ 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",
githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -1414,6 +1471,9 @@ export default {
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
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',
@@ -1497,6 +1557,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',
@@ -1594,6 +1659,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: 'Custom Notification',
},
words: {
@@ -1949,7 +2015,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',
@@ -1969,6 +2035,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',
@@ -2186,6 +2255,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',
@@ -2536,6 +2609,7 @@ export default {
settings: 'Settings',
projectHome: 'Project Home',
updateHistory: 'Update History',
local: 'Local',
installToLocal: 'Install to Local',
totalDownloads: 'Total {count} downloads',
viewData: 'View Data',
@@ -2725,10 +2799,18 @@ 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...',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',
batchDelete: 'Batch Delete',
},
batchOperationTitle: 'Batch Operation',
progress: {
processing: 'Processing',
pleaseWait: 'Please wait...',
@@ -2853,6 +2935,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',
@@ -3107,6 +3198,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',
@@ -3128,6 +3221,17 @@ 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',
@@ -3160,7 +3264,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',
@@ -3191,6 +3296,18 @@ 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',
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',

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消订阅',
media: '媒体',
unknown: '未知',
notFetched: '未获取',
notice: '注意',
itemsPerPage: '每页条数',
pageText: '{0}-{1} 共 {2} 条',
@@ -89,6 +90,7 @@ export default {
mediaServer: '媒体服务器',
manual: '手动处理',
plugin: '插件',
agent: '智能体',
other: '其它',
},
actionStep: {
@@ -255,6 +257,7 @@ export default {
serverError: '登录失败,服务器错误!',
loginFailed: '登录失败',
secondaryVerification: '二次验证',
orDivider: '或',
loginWithPasskey: '使用通行密钥登录',
loginWithOtp: '使用验证码登录',
orUsePasskey: '或使用通行密钥进行验证',
@@ -313,7 +316,7 @@ export default {
settingTabs: {
system: {
title: '系统',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex',
description: '基础设置、下载器Qbittorrent、Transmission、媒体服务器Emby、Jellyfin、Plex、飞牛影视、绿联影视',
},
directory: {
title: '存储 & 目录',
@@ -432,6 +435,8 @@ export default {
config: '配置',
wechat: {
name: '企业微信',
useBotMode: '使用智能机器人',
useBotModeHint: '开启后使用智能机器人长连接,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企业ID',
corpIdHint: '企业微信后台企业信息中的企业ID',
corpIdRequired: '企业ID不能为空',
@@ -447,6 +452,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 +531,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 +581,10 @@ export default {
title: '缓存',
subtitle: '管理缓存',
},
scheduler: {
title: '服务',
subtitle: '定时服务',
},
},
workflow: {
components: '动作组件',
@@ -1288,6 +1321,9 @@ export default {
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmSupportImageInput: '模型支持图片输入',
llmSupportImageInputHint:
'启用后,消息中的图片会按多模态图片发送给 LLM关闭后图片会作为附件保存到本地并将文件路径提供给智能助手处理',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
@@ -1296,12 +1332,31 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
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:
'启用搜索结果智能推荐功能,开启后将在搜索结果页面显示智能推荐按钮,可根据用户偏好智能推荐资源',
@@ -1336,6 +1391,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '绿联影视',
reloadSuccess: '系统配置已生效',
reloadFailed: '重载系统失败!',
auxAuthEnable: '用户辅助认证',
@@ -1375,6 +1431,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的图片数据',
fanartLang: 'Fanart语言',
fanartLangHint: '设置Fanart图片的语言偏好多选时按优先级顺序排列',
recognizePluginFirst: "优先使用插件识别",
recognizePluginFirstHint: "优先调用插件识别媒体信息,若插件命中则不再调用原生识别",
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github访问速度',
@@ -1405,6 +1463,8 @@ export default {
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
pluginAutoReload: '插件热加载',
pluginAutoReloadHint: '修改插件文件后自动重新加载,开发插件时使用',
pluginLocalRepoPaths: '本地插件仓库路径',
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
transferThreads: '文件整理线程数',
@@ -1485,6 +1545,11 @@ export default {
episodeThumb: '缩略图',
scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}',
scrapingSwitchSaveError: '刮削开关设置保存失败',
policy: {
skipDesc: '跳过刮削,不生成该文件',
missingOnlyDesc: '仅在缺失时刮削,已存在则保持不变',
overwriteDesc: '始终刮削,已存在则覆盖',
}
},
site: {
siteSync: '站点同步',
@@ -1579,6 +1644,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定义通知',
},
words: {
@@ -1923,7 +1989,7 @@ export default {
},
searchBar: {
search: '搜索',
searchPlaceholder: '搜索功能、订阅、设置...',
searchPlaceholder: '搜索电影、剧集以及更多...',
recentSearches: '最近搜索',
noRecentSearches: '没有最近搜索记录',
functions: '功能',
@@ -1943,6 +2009,9 @@ export default {
searchInSites: '在站点中搜索种子资源',
relatedResources: '相关资源',
searchTip: '可搜索电影、电视剧、演员、资源等',
emptySearchHint: '输入关键字开始搜索',
escClose: '关闭',
openSearch: '打开搜索',
},
searchSite: {
selectSites: '选择站点',
@@ -2156,6 +2225,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: '插件仓库保存成功',
@@ -2506,6 +2579,7 @@ export default {
settings: '设置',
projectHome: '项目主页',
updateHistory: '更新说明',
local: '本地',
installToLocal: '安装到本地',
totalDownloads: '共 {count} 次下载',
viewData: '查看数据',
@@ -2689,10 +2763,18 @@ export default {
loading: '加载中...',
pageSize: '每页条数',
pageInfo: '{begin} - {end} / {total}',
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任务:{title}',
aiRedoFailed: '提交智能助手整理任务失败',
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',
batchDelete: '批量删除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '处理中',
pleaseWait: '请稍候...',
@@ -2817,6 +2899,15 @@ export default {
password: '密码',
syncLibraries: '同步媒体库',
syncLibrariesHint: '只有选中的媒体库才会被同步',
scanMode: '扫描模式',
scanModeHint: '用于全库刷新和按库刷新:新添加和修改 / 补充缺失 / 覆盖扫描',
verifySsl: '校验 SSL 证书',
verifySslHint: '开启后会校验 HTTPS 证书;如使用自签名证书可关闭',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '补充缺失',
fullOverride: '覆盖扫描',
},
nameExists: '【{name}】已存在,请替换为其他名称',
hostRequired: '地址不能为空',
apiKeyRequired: 'API密钥不能为空',
@@ -3070,6 +3161,8 @@ export default {
saveMediaServerSettingsFailed: '保存媒体服务器设置失败',
notificationSettingsSaved: '通知设置保存成功',
saveNotificationSettingsFailed: '保存通知设置失败',
saveSiteAuthSettingsFailed: '保存用户站点认证设置失败:{message}',
saveAgentSettingsFailed: '保存智能助手设置失败',
preferenceSettingsSaved: '偏好设置保存成功',
savePreferenceSettingsFailed: '保存偏好设置失败',
passwordUpdateSuccess: '密码更新成功',
@@ -3091,6 +3184,16 @@ export default {
confirmPasswordHint: '确认新密码',
apiTokenRequired: 'API Token不能为空',
},
siteAuth: {
title: '用户认证',
description: '配置用户站点认证与辅助认证',
info: '用户站点认证说明',
infoDesc: '完成站点认证后可解锁站点能力与部分插件权限。此步骤可选,后续也可在个人菜单中继续配置。',
selectSiteHint: '选择一个支持认证的站点,并填写该站点要求的认证参数',
submitHint: '点击下一步时将立即向认证站点发起校验,认证成功后会保存当前参数。',
siteConfigNotExist: '认证站点配置不存在',
fieldRequired: '请输入{name}',
},
storage: {
title: '存储',
description: '配置下载目录和媒体库目录',
@@ -3123,7 +3226,7 @@ export default {
title: '媒体服务器',
description: '配置媒体服务器',
info: '媒体服务器配置说明',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex',
infoDesc: '配置媒体服务器用于媒体库管理可选择Emby、JellyfinPlex、飞牛影视或绿联影视',
type: '媒体服务器类型',
typeHint: '选择要使用的媒体服务器类型',
name: '服务器名称',
@@ -3154,6 +3257,17 @@ export default {
senderPassword: '发送密码',
receiverEmail: '接收邮箱',
},
agent: {
title: '智能助手',
description: '配置 Agent 助手与 LLM 参数',
info: '智能助手配置说明',
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
providerRequired: 'LLM 提供商不能为空',
apiKeyRequired: 'LLM API 密钥不能为空',
modelRequired: 'LLM 模型名称不能为空',
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',
},
preferences: {
title: '资源偏好',
description: '设置资源下载偏好',
@@ -3196,7 +3310,3 @@ export default {
},
},
}
// Apply patch to add category strings
// This is a temporary placeholder command to show intent.
// I will use replace_file_content to actually edit the file safely.

View File

@@ -46,6 +46,7 @@ export default {
unsubscribe: '取消訂閱',
media: '媒體',
unknown: '未知',
notFetched: '未獲取',
notice: '注意',
itemsPerPage: '每頁條數',
pageText: '{0}-{1} 共 {2} 條',
@@ -89,6 +90,7 @@ export default {
mediaServer: '媒體伺服器',
manual: '手動處理',
plugin: '插件',
agent: '智能體',
other: '其它',
},
actionStep: {
@@ -255,6 +257,7 @@ export default {
noPermission: '登錄失敗,您沒有任何功能權限,請聯繫管理員!',
loginFailed: '登錄失敗',
secondaryVerification: '二次驗證',
orDivider: '或',
loginWithPasskey: '使用通行密鑰登錄',
loginWithOtp: '使用驗證碼登錄',
orUsePasskey: '或使用通行密鑰進行驗證',
@@ -313,7 +316,8 @@ export default {
settingTabs: {
system: {
title: '系統',
description: '基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex',
description:
'基礎設置、下載器Qbittorrent、Transmission、媒體服務器Emby、Jellyfin、Plex、飛牛影視、綠聯影視',
},
directory: {
title: '存儲 & 目錄',
@@ -432,6 +436,8 @@ export default {
config: '配置',
wechat: {
name: '企業微信',
useBotMode: '使用智能機器人',
useBotModeHint: '開啟後使用智能機器人長連線,固定 dmPolicy=open、groupPolicy=disabled',
corpId: '企業ID',
corpIdHint: '企業微信後台企業信息中的企業ID',
corpIdRequired: '企業ID不能為空',
@@ -447,6 +453,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 +532,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 +582,10 @@ export default {
title: '緩存',
subtitle: '管理緩存',
},
scheduler: {
title: '服務',
subtitle: '定時服務',
},
},
workflow: {
components: '動作組件',
@@ -1289,6 +1323,9 @@ export default {
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型如gpt-3.5-turbo、deepseek-chat等',
llmSupportImageInput: '模型支援圖片輸入',
llmSupportImageInputHint:
'啟用後,消息中的圖片會按多模態圖片發送給 LLM關閉後圖片會作為附件保存到本地並將檔案路徑提供給智能助手處理',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
@@ -1297,12 +1334,31 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
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:
'啟用搜索結果智能推薦功能,開啟後將在搜索結果頁面顯示智能推薦按鈕,可根據用戶偏好智能推薦資源',
@@ -1337,6 +1393,7 @@ export default {
emby: 'Emby',
jellyfin: 'Jellyfin',
plex: 'Plex',
ugreen: '綠聯影視',
reloadSuccess: '系統配置已生效',
reloadFailed: '重載系統失敗!',
auxAuthEnable: '用戶輔助認證',
@@ -1376,6 +1433,8 @@ export default {
fanartEnableHint: '使用 fanart.tv 的圖片數據',
fanartLang: 'Fanart語言',
fanartLangHint: '設定Fanart圖片的語言偏好多選時按優先級順序排列',
recognizePluginFirst: '優先使用插件識別',
recognizePluginFirstHint: '優先調用插件識別媒體信息,若插件命中則不再調用原生識別',
githubProxy: 'Github加速代理',
githubProxyPlaceholder: '留空表示不使用代理',
githubProxyHint: '使用代理加速Github訪問速度',
@@ -1406,6 +1465,8 @@ export default {
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
pluginAutoReload: '插件熱加載',
pluginAutoReloadHint: '修改插件文件後自動重新加載,開發插件時使用',
pluginLocalRepoPaths: '本地插件倉庫路徑',
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
transferThreads: '文件整理線程數',
@@ -1486,6 +1547,11 @@ export default {
episodeThumb: '縮略圖',
scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}',
scrapingSwitchSaveError: '刮削開關設定保存失敗',
policy: {
skipDesc: '跳過刮削,不生成該文件',
missingOnlyDesc: '僅在缺失時刮削,已存在則保持不變',
overwriteDesc: '始終刮削,已存在則覆蓋',
},
},
site: {
siteSync: '站點同步',
@@ -1580,6 +1646,7 @@ export default {
synologyChat: 'SynologyChat',
voceChat: 'VoceChat',
webPush: 'WebPush',
qq: 'QQ',
custom: '自定義通知',
},
words: {
@@ -1924,7 +1991,7 @@ export default {
},
searchBar: {
search: '搜索',
searchPlaceholder: '搜索功能、訂閱、設置...',
searchPlaceholder: '搜索電影、劇集以及更多...',
recentSearches: '最近搜索',
noRecentSearches: '沒有最近搜索記錄',
functions: '功能',
@@ -1944,6 +2011,9 @@ export default {
searchInSites: '在站點中搜索種子資源',
relatedResources: '相關資源',
searchTip: '可搜索電影、電視劇、演員、資源等',
emptySearchHint: '輸入關鍵字開始搜索',
escClose: '關閉',
openSearch: '打開搜索',
},
searchSite: {
selectSites: '選擇站點',
@@ -2157,6 +2227,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: '插件倉庫儲存成功',
@@ -2507,6 +2581,7 @@ export default {
settings: '設置',
projectHome: '項目主頁',
updateHistory: '更新說明',
local: '本地',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
viewData: '查看數據',
@@ -2690,10 +2765,18 @@ export default {
loading: '加載中...',
pageSize: '每頁條數',
pageInfo: '{begin} - {end} / {total}',
aiRedoDisabled: '請先在系統設置中啟用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任務:{title}',
aiRedoFailed: '提交智能助手整理任務失敗',
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
redo: '重新整理',
delete: '刪除',
batchRedo: '批量重新整理',
batchDelete: '批量刪除',
},
batchOperationTitle: '批量操作',
progress: {
processing: '處理中',
pleaseWait: '請稍候...',
@@ -2823,6 +2906,15 @@ export default {
password: '密碼',
syncLibraries: '同步媒體庫',
syncLibrariesHint: '只有選中的媒體庫才會被同步',
scanMode: '掃描模式',
scanModeHint: '用於全庫刷新和按庫刷新:新添加和修改 / 補充缺失 / 覆蓋掃描',
verifySsl: '校驗 SSL 憑證',
verifySslHint: '開啟後會校驗 HTTPS 憑證;如使用自簽憑證可關閉',
scanModeOptions: {
newAndModified: '新添加和修改',
supplementMissing: '補充缺失',
fullOverride: '覆蓋掃描',
},
nameExists: '【{name}】已存在,請替換為其他名稱',
},
bangumi: {
@@ -3071,6 +3163,8 @@ export default {
saveMediaServerSettingsFailed: '保存媒體服務器設置失敗',
notificationSettingsSaved: '通知設置保存成功',
saveNotificationSettingsFailed: '保存通知設置失敗',
saveSiteAuthSettingsFailed: '保存用戶站點認證設置失敗:{message}',
saveAgentSettingsFailed: '保存智能助手設置失敗',
preferenceSettingsSaved: '偏好設置保存成功',
savePreferenceSettingsFailed: '保存偏好設置失敗',
passwordUpdateSuccess: '密碼更新成功',
@@ -3092,6 +3186,16 @@ export default {
confirmPasswordHint: '確認新密碼',
apiTokenRequired: 'API Token 不能為空',
},
siteAuth: {
title: '用戶認證',
description: '配置用戶站點認證與輔助認證',
info: '用戶站點認證說明',
infoDesc: '完成站點認證後可解鎖站點能力與部分插件權限。此步驟可選,後續也可在個人選單中繼續配置。',
selectSiteHint: '選擇一個支援認證的站點,並填寫該站點要求的認證參數',
submitHint: '點擊下一步時將立即向認證站點發起校驗,認證成功後會保存當前參數。',
siteConfigNotExist: '認證站點配置不存在',
fieldRequired: '請輸入{name}',
},
storage: {
title: '儲存',
description: '設定下載目錄和媒體庫目錄',
@@ -3124,7 +3228,7 @@ export default {
title: '媒體伺服器',
description: '設定媒體伺服器',
info: '媒體伺服器設定說明',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex',
infoDesc: '設定媒體伺服器用於媒體庫管理可選擇Emby、JellyfinPlex、飛牛影視或綠聯影視',
type: '媒體伺服器類型',
typeHint: '選擇要使用的媒體伺服器類型',
name: '伺服器名稱',
@@ -3155,6 +3259,17 @@ export default {
senderPassword: '發送密碼',
receiverEmail: '接收信箱',
},
agent: {
title: '智能助手',
description: '配置 Agent 助手與 LLM 參數',
info: '智能助手配置說明',
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
providerRequired: 'LLM 提供商不能為空',
apiKeyRequired: 'LLM API 密鑰不能為空',
modelRequired: 'LLM 模型名稱不能為空',
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',
},
preferences: {
title: '資源偏好',
description: '設定資源下載偏好',

View File

@@ -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"

View File

@@ -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>
<!-- 弹窗根据配置生成选项 -->

View File

@@ -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
View 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>

View File

@@ -7,7 +7,6 @@ import TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores/global'
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
@@ -15,7 +14,6 @@ import { useToast } from 'vue-toastification'
// 国际化
const { t } = useI18n()
const { useProgressSSE } = useBackgroundOptimization()
// 提示框
const toast = useToast()
@@ -109,12 +107,43 @@ const progressEnabled = ref(false)
// 进度是否激活
const progressActive = ref(false)
// 是否显示搜索进度
const isSearchProgressVisible = computed(
() => progressActive.value || (!isRefreshed.value && (progressEnabled.value || progressValue.value > 0)),
)
// 是否显示搜索中的页面态
const isSearchLoading = computed(
() => !isRefreshed.value && isSearchProgressVisible.value && rawDataList.value.length === 0,
)
// 归一化搜索进度,避免 SSE 异常值影响显示
const searchProgressPercent = computed(() => Math.min(100, Math.max(0, Math.ceil(Number(progressValue.value) || 0))))
// 搜索进度文案
const searchProgressLabel = computed(() =>
progressEnabled.value || progressValue.value > 0 ? `${searchProgressPercent.value}%` : '...',
)
// 进度未返回前使用不确定态
const searchProgressIndeterminate = computed(() => !progressEnabled.value && searchProgressPercent.value <= 0)
// 错误标题
const errorTitle = ref(t('resource.noData'))
// 错误描述
const errorDescription = ref(t('resource.noResourceFound'))
let searchEventSource: EventSource | null = null
const streamPreviewLimit = 24
const streamTotalCount = ref(0)
const displayResourceCount = computed(() =>
progressActive.value ? streamTotalCount.value : torrentFilter.totalFilteredCount.value,
)
// 监听筛选条件变化,重新筛选数据
watch(
[() => torrentFilter.filterForm, () => torrentFilter.sortField.value, () => torrentFilter.sortType.value],
@@ -169,39 +198,19 @@ const watchProgressValue = watch(
}, 60_000),
)
// 进度SSE消息处理函数
function handleProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (progress) {
progressText.value = progress.text
progressValue.value = progress.value
progressEnabled.value = progress.enable
}
}
// 使用优化的进度SSE连接
const progressSSE = useProgressSSE(
`${import.meta.env.VITE_API_BASE_URL}system/progress/search`,
handleProgressMessage,
'resource-search-progress',
progressActive,
)
// 使用SSE监听加载进度
function startLoadingProgress() {
watchProgressValue.resume()
progressText.value = t('resource.searching')
progressValue.value = 0
progressEnabled.value = false
progressEnabled.value = true
progressActive.value = true
progressSSE.start()
}
// 停止监听加载进度
function stopLoadingProgress() {
watchProgressValue.pause()
progressActive.value = false
progressSSE.stop()
// 确保进度显示100%,然后再渐进清零
progressValue.value = 100
@@ -211,6 +220,203 @@ function stopLoadingProgress() {
}, 1500)
}
// 关闭SSE连接
function closeSearchEventSource() {
if (searchEventSource) {
searchEventSource.close()
searchEventSource = null
}
}
// 获取API URL
function getApiUrl(path: string) {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
const normalizedBaseUrl = apiBaseUrl.startsWith('http')
? apiBaseUrl
: `${window.location.origin}${apiBaseUrl.startsWith('/') ? apiBaseUrl : `/${apiBaseUrl}`}`
return new URL(path, normalizedBaseUrl.endsWith('/') ? normalizedBaseUrl : `${normalizedBaseUrl}/`)
}
// 设置搜索参数
function setSearchParam(params: URLSearchParams, key: string, value: unknown) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value))
}
}
// 构建搜索流URL
function buildSearchStreamUrl() {
const isMediaSearch = /^[a-zA-Z]+:/.test(keyword)
const url = getApiUrl(isMediaSearch ? `search/media/${encodeURIComponent(keyword)}/stream` : 'search/title/stream')
if (isMediaSearch) {
setSearchParam(url.searchParams, 'mtype', type)
setSearchParam(url.searchParams, 'area', area)
setSearchParam(url.searchParams, 'title', title)
setSearchParam(url.searchParams, 'year', year)
setSearchParam(url.searchParams, 'season', season)
setSearchParam(url.searchParams, 'sites', sites)
} else {
setSearchParam(url.searchParams, 'keyword', keyword)
setSearchParam(url.searchParams, 'sites', sites)
}
return url.toString()
}
// 重置搜索结果
function resetSearchResults() {
rawDataList.value = []
originalDataList.value = []
streamTotalCount.value = 0
aiRecommended.value = false
showingAiResults.value = false
aiRecommendedList.value = []
savedFilterState.value = null
aiStatusChecked.value = false
torrentFilter.clearAllFilters()
applyFilter()
}
// 更新搜索进度
function updateSearchProgress(eventData: { [key: string]: any }) {
if (eventData.text) {
progressText.value = eventData.text
}
if (typeof eventData.value === 'number') {
progressValue.value = eventData.value
}
if (typeof eventData.total_items === 'number') {
streamTotalCount.value = eventData.total_items
}
progressEnabled.value = true
}
// 设置流式搜索结果
function setStreamResults(items: Context[]) {
rawDataList.value = items
originalDataList.value = items
if (!progressActive.value) {
streamTotalCount.value = items.length
}
isRefreshed.value = true
applyFilter()
}
// 追加流式搜索结果
function appendStreamResults(items: Context[]) {
if (!items.length) return
const nextItems = [...items, ...rawDataList.value]
setStreamResults(progressActive.value ? nextItems.slice(0, streamPreviewLimit) : nextItems)
}
// 获取磁力链接的key
function getTorrentItemKey(item: Context, index: number) {
return (
item.torrent_info?.page_url ||
item.torrent_info?.enclosure ||
`${item.torrent_info?.site_name || ''}-${item.torrent_info?.title || ''}-${item.torrent_info?.description || ''}` ||
`torrent-${index}`
)
}
// 处理搜索流消息
function handleSearchStreamMessage(eventData: { [key: string]: any }) {
updateSearchProgress(eventData)
if (eventData.type === 'error') {
errorDescription.value = eventData.message || t('resource.noResourceFound')
return
}
const items = Array.isArray(eventData.items) ? (eventData.items as Context[]) : []
if (eventData.type === 'append') {
appendStreamResults(items)
} else if (eventData.type === 'replace' || eventData.type === 'done') {
setStreamResults(items)
}
}
// 按请求搜索
async function searchByRequest() {
let result: { [key: string]: any }
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符则按照媒体ID格式搜索
if (/^[a-zA-Z]+:/.test(keyword)) {
result = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
title,
year,
season,
sites,
},
})
} else {
// 按标题模糊查询
result = await api.get(`search/title`, {
params: {
keyword,
sites,
},
})
}
if (result && result.success) {
streamTotalCount.value = result.data?.length || 0
setStreamResults(result.data || [])
} else {
errorDescription.value = result?.message || t('resource.noResourceFound')
streamTotalCount.value = 0
setStreamResults([])
}
}
// 按流搜索
function searchByStream() {
return new Promise<void>((resolve, reject) => {
closeSearchEventSource()
let settled = false
const source = new EventSource(buildSearchStreamUrl())
searchEventSource = source
source.onmessage = event => {
try {
const eventData = JSON.parse(event.data)
handleSearchStreamMessage(eventData)
if (eventData.type === 'error') {
settled = true
closeSearchEventSource()
resolve()
return
}
if (eventData.type === 'done') {
settled = true
closeSearchEventSource()
resolve()
}
} catch (error) {
settled = true
closeSearchEventSource()
reject(error)
}
}
source.onerror = () => {
if (settled) return
settled = true
closeSearchEventSource()
reject(new Error(t('resource.noResourceFound')))
}
})
}
// 设置视图类型
function changeViewType(newType: string) {
if (viewType.value !== newType) {
@@ -233,38 +439,13 @@ async function fetchData() {
rawDataList.value = (results as unknown as Context[]) || []
originalDataList.value = (results as unknown as Context[]) || []
} else {
resetSearchResults()
startLoadingProgress()
let result: { [key: string]: any }
// 如果keyword的格式是 xxxx:xxxxx 且:前面的xxxx为字符则按照媒体ID格式搜索
if (/^[a-zA-Z]+:/.test(keyword)) {
result = await api.get(`search/media/${keyword}`, {
params: {
mtype: type,
area,
title,
year,
season,
sites,
},
})
} else {
// 按标题模糊查询
result = await api.get(`search/title`, {
params: {
keyword,
sites,
},
})
}
if (result && result.success) {
rawDataList.value = result.data || []
originalDataList.value = result.data || []
// 重置智能推荐状态
aiRecommended.value = false
showingAiResults.value = false
aiRecommendedList.value = []
} else if (result && result.message) {
errorDescription.value = result.message
try {
await searchByStream()
} catch (error) {
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
await searchByRequest()
}
stopLoadingProgress()
// 从浏览器历史中删除当前搜索
@@ -276,6 +457,7 @@ async function fetchData() {
isRefreshed.value = true
} catch (error) {
console.error(error)
closeSearchEventSource()
stopLoadingProgress()
isRefreshed.value = true
return Promise.reject(error)
@@ -542,7 +724,12 @@ const hasData = computed(() => {
// 使用 watchEffect 确保计算属性变化时立即响应
watchEffect(() => {
// 需要满足AI 功能启用、数据已加载、尚未检查
if (aiRecommendEnabled.value && originalDataList.value.length > 0 && !aiStatusChecked.value) {
if (
aiRecommendEnabled.value &&
originalDataList.value.length > 0 &&
!progressActive.value &&
!aiStatusChecked.value
) {
checkAiRecommendStatus()
}
})
@@ -554,6 +741,7 @@ onMounted(async () => {
// 卸载时停止轮询
onUnmounted(() => {
closeSearchEventSource()
stopLoadingProgress()
stopAiRecommendPolling()
})
@@ -561,24 +749,67 @@ onUnmounted(() => {
<template>
<div>
<!-- 加载进度条 -->
<!-- 搜索加载状态 -->
<VFadeTransition>
<div v-if="progressValue > 0 || progressEnabled" class="search-progress-container">
<VCard elevation="3" class="search-progress-card">
<div v-if="isSearchProgressVisible" class="search-loading-state mb-3" :class="{ 'is-empty-loading': isSearchLoading }">
<VCard elevation="0" class="search-progress-card">
<div class="progress-header">
<VIcon icon="mdi-movie-search" color="primary" size="small" class="me-2" />
<span class="progress-title">{{ progressText }}</span>
<div class="progress-icon-wrap">
<VProgressCircular
color="primary"
:indeterminate="searchProgressIndeterminate"
:model-value="searchProgressPercent"
:size="56"
:width="5"
>
<VIcon icon="mdi-movie-search" color="primary" size="24" />
</VProgressCircular>
</div>
<div class="progress-copy">
<span class="progress-title">{{ progressText }}</span>
<div v-if="hasSearchTags" class="progress-tags d-flex flex-wrap">
<VChip v-if="keyword" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
{{ t('resource.keyword') }}: {{ keyword }}
</VChip>
<VChip v-if="title" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
{{ t('resource.title') }}: {{ title }}
</VChip>
<VChip v-if="year" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
{{ t('resource.year') }}: {{ year }}
</VChip>
<VChip v-if="season" class="search-tag progress-tag" color="primary" size="small" variant="tonal">
{{ t('resource.season') }}: {{ season }}
</VChip>
</div>
</div>
<div class="progress-percentage">{{ searchProgressLabel }}</div>
</div>
<div class="progress-bar-container">
<VProgressLinear color="primary" rounded :model-value="progressValue" />
<div class="progress-percentage">{{ Math.ceil(progressValue) }}%</div>
<VProgressLinear
color="primary"
rounded
:indeterminate="searchProgressIndeterminate"
:model-value="searchProgressPercent"
/>
</div>
</VCard>
<div v-if="isSearchLoading && viewType === 'card'" class="search-skeleton-grid">
<VCard v-for="item in 6" :key="`search-card-skeleton-${item}`" class="search-skeleton-card" elevation="0">
<VSkeletonLoader type="image, article" />
</VCard>
</div>
<VCard v-else-if="isSearchLoading" class="search-skeleton-list" elevation="0">
<div v-for="item in 6" :key="`search-row-skeleton-${item}`" class="search-skeleton-row">
<VSkeletonLoader type="list-item-avatar-two-line" />
</div>
</VCard>
</div>
</VFadeTransition>
<!-- 精简标题栏 -->
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
<div class="search-info-container">
<div class="search-title text-moviepilot">
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
@@ -668,11 +899,12 @@ onUnmounted(() => {
<div v-if="isRefreshed && hasData" class="search-results-container">
<!-- 筛选栏 -->
<TorrentFilterBar
v-if="!progressActive"
:filter-form="torrentFilter.filterForm"
:filter-options="torrentFilter.filterOptions"
:sort-field="torrentFilter.sortField.value"
:sort-type="torrentFilter.sortType.value"
:total-filtered-count="torrentFilter.totalFilteredCount.value"
:total-filtered-count="displayResourceCount"
:filter-titles="torrentFilter.filterTitles"
:sort-titles="torrentFilter.sortTitles"
:enable-animation="enableFilterAnimation"
@@ -701,10 +933,11 @@ onUnmounted(() => {
<template #empty />
<div class="grid gap-4 grid-torrent-card items-start">
<TorrentCard
v-for="item in cardScroll.displayDataList.value"
:key="`${item.torrent_info.page_url}`"
v-for="(item, index) in cardScroll.displayDataList.value"
:key="getTorrentItemKey(item, index)"
:torrent="item"
:more="item.more"
class="stream-result-item"
/>
</div>
</VInfiniteScroll>
@@ -736,7 +969,8 @@ onUnmounted(() => {
<template #empty />
<div
v-for="(item, index) in rowScroll.displayDataList.value"
:key="`${item.torrent_info?.enclosure || ''}-${index}`"
:key="getTorrentItemKey(item, index)"
class="stream-result-item"
>
<TorrentItem :torrent="item" />
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
@@ -756,7 +990,7 @@ onUnmounted(() => {
</div>
<!-- 初始加载状态 -->
<LoadingBanner v-else-if="!isRefreshed && !(progressEnabled || progressValue > 0)" />
<LoadingBanner v-else-if="!isRefreshed && !isSearchLoading" />
<!-- 滚动到顶部按钮 -->
<Teleport to="body" v-if="route.path === '/resource'">
<VScrollToTopBtn />
@@ -765,51 +999,111 @@ onUnmounted(() => {
</template>
<style scoped>
.search-progress-container {
position: fixed;
z-index: 100;
.search-loading-state {
display: flex;
justify-content: center;
inset-block-start: env(safe-area-inset-top);
inset-inline: 0;
flex-direction: column;
gap: 16px;
}
.search-loading-state.is-empty-loading {
min-block-size: 50vh;
}
.search-progress-card {
padding: 16px;
border: 1px solid rgba(var(--v-theme-primary), 0.1);
border-radius: 12px;
border: 1px solid rgba(var(--v-theme-primary), 0.18);
border-radius: 8px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 10%);
inline-size: 90%;
max-inline-size: 400px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
inline-size: 100%;
}
.progress-header {
display: flex;
align-items: center;
margin-block-end: 12px;
gap: 12px;
}
.progress-icon-wrap {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
}
.progress-copy {
flex: 1 1 auto;
min-inline-size: 0;
}
.progress-title {
display: block;
overflow: hidden;
color: rgb(var(--v-theme-on-surface));
font-size: 0.9rem;
font-weight: 500;
font-size: 1rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-tags {
gap: 6px;
margin-block-start: 8px;
}
.progress-tag {
max-inline-size: 100%;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 12px;
margin-block-start: 14px;
}
.progress-percentage {
flex: 0 0 auto;
color: rgb(var(--v-theme-primary));
font-size: 0.8rem;
font-weight: 600;
min-inline-size: 36px;
font-size: 0.95rem;
font-weight: 700;
min-inline-size: 44px;
text-align: end;
}
.search-skeleton-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.search-skeleton-card,
.search-skeleton-list {
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
background: rgb(var(--v-theme-surface));
}
.search-skeleton-row + .search-skeleton-row {
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.stream-result-item {
animation: stream-result-in 0.28s ease-out both;
}
@keyframes stream-result-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 精简标题栏样式 */
.search-header {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
@@ -840,25 +1134,27 @@ onUnmounted(() => {
}
.view-toggle-buttons {
position: relative;
display: flex;
padding: 4px;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
position: relative;
isolation: isolate; /* Create new stacking context */
}
.active-indicator {
position: absolute;
top: 4px;
left: 4px;
width: 40px;
height: 36px;
background-color: rgb(var(--v-theme-surface));
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
border-radius: 6px;
background-color: rgb(var(--v-theme-surface));
block-size: 36px;
box-shadow:
0 1px 3px rgba(0, 0, 0, 12%),
0 1px 2px rgba(0, 0, 0, 24%);
inline-size: 40px;
inset-block-start: 4px;
inset-inline-start: 4px;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.active-indicator.row {
@@ -866,6 +1162,8 @@ onUnmounted(() => {
}
.view-toggle-btn {
position: relative;
z-index: 2; /* Sit on top of indicator */
display: flex;
align-items: center;
justify-content: center;
@@ -875,13 +1173,11 @@ onUnmounted(() => {
cursor: pointer;
inline-size: 40px;
transition: all 0.2s ease;
z-index: 2; /* Sit on top of indicator */
position: relative;
}
.view-toggle-btn:hover:not(.active) {
background-color: rgba(var(--v-theme-primary), 0.05);
border-radius: 6px;
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* AI按钮组样式 */
@@ -891,31 +1187,31 @@ onUnmounted(() => {
.ai-toggle-buttons {
display: flex;
overflow: hidden;
align-items: center;
padding: 0;
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
overflow: hidden;
height: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
block-size: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
}
.ai-recommend-btn {
transition: all 0.3s ease;
margin: 0;
height: 100% !important;
block-size: 100% !important;
transition: all 0.3s ease;
}
/* 仅为激活的按钮添加背景 */
.ai-recommend-btn.ai-active {
background-color: rgba(var(--v-theme-primary), 0.15);
z-index: 1;
background-color: rgba(var(--v-theme-primary), 0.15);
}
/* 图标基础样式 */
.ai-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateZ(0);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 激活状态图标:变色 + 辉光 */
@@ -927,10 +1223,10 @@ onUnmounted(() => {
/* 文字基础样式 */
.ai-text {
color: rgba(var(--v-theme-on-surface), 0.6);
font-weight: 600; /* 保持一致的字重防止位移 */
font-size: 0.85rem;
transition: color 0.3s ease;
font-weight: 600; /* 保持一致的字重防止位移 */
transform: translateZ(0);
transition: color 0.3s ease;
}
/* 激活状态文字 */
@@ -945,12 +1241,12 @@ onUnmounted(() => {
}
.ai-divider {
width: 0; /* 宽度设为0不占用空间 */
height: 20px;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
flex-shrink: 0;
transition: opacity 0.3s ease;
z-index: 0;
flex-shrink: 0;
block-size: 20px;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
inline-size: 0; /* 宽度设为0不占用空间 */
transition: opacity 0.3s ease;
}
.search-results-container {
@@ -1012,6 +1308,45 @@ onUnmounted(() => {
display: none;
}
.search-loading-state {
gap: 12px;
}
.search-progress-card {
padding: 12px;
}
.progress-header {
align-items: flex-start;
}
.progress-icon-wrap {
padding-block-start: 2px;
}
.progress-title {
white-space: normal;
}
.progress-percentage {
font-size: 0.85rem;
min-inline-size: 36px;
}
.progress-tags {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.progress-tags::-webkit-scrollbar {
display: none;
}
.search-skeleton-grid {
grid-template-columns: 1fr;
}
.view-toggle-container {
flex-shrink: 0;
}
@@ -1021,10 +1356,10 @@ onUnmounted(() => {
}
.active-indicator {
top: 2px;
left: 2px;
width: 36px;
height: 32px;
block-size: 32px;
inline-size: 36px;
inset-block-start: 2px;
inset-inline-start: 2px;
}
.active-indicator.row {
@@ -1037,7 +1372,7 @@ onUnmounted(() => {
}
.ai-toggle-buttons {
height: 36px;
block-size: 36px;
}
.ai-text {
@@ -1046,17 +1381,16 @@ onUnmounted(() => {
.ai-recommend-btn,
.ai-toggle-buttons .v-btn {
height: 36px !important;
min-width: unset !important;
block-size: 36px !important;
min-inline-size: unset !important;
}
.ai-recommend-btn {
padding-inline-start: 12px !important;
padding-inline-end: 8px !important;
padding-inline: 12px 8px !important;
}
.ai-toggle-buttons .v-btn:last-child {
min-width: 32px !important;
min-inline-size: 32px !important;
}
}
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(() => {
@@ -48,17 +54,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 +83,123 @@ 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
}
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()
@@ -126,37 +233,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,6 +262,7 @@ onMounted(() => {
<transition name="fade-slide" appear>
<div>
<SubscribeListView
ref="subscribeListViewRef"
:type="subType"
:subid="subId"
:keyword="subscribeFilter"
@@ -194,50 +281,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 +341,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"

View File

@@ -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>

View File

@@ -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')
}

View File

@@ -140,6 +140,15 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/plugin-app/:pluginId/:navKey?',
name: 'plugin-app',
component: () => import('../pages/plugin-app.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
{
path: '/setting',
component: () => import('../pages/setting.vue'),

View File

@@ -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()

View File

@@ -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()
},
},

View File

@@ -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)
}

View File

@@ -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 }

View 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
},
},
})

View File

@@ -48,6 +48,138 @@ html.v-overlay-scroll-blocked body {
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
.app-card-shell {
block-size: 100%;
}
.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;
filter: brightness(1.35) saturate(1.05);
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 +370,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;
}
@@ -311,7 +559,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 {

View File

@@ -29,6 +29,58 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
}
}
/**
* 将 nav_key 转为联邦暴露名的 Pascal 片段(如 settings -> Settingsmy-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',
})

View File

@@ -11,6 +11,7 @@ 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'
@@ -30,6 +31,7 @@ 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> = {
@@ -40,6 +42,7 @@ const logoMap: Record<string, string> = {
jellyfin: jellyfinLogo,
plex: plexLogo,
trimemedia: trimemediaLogo,
ugreen: ugreenLogo,
wechat: wechatLogo,
telegram: telegramLogo,
slack: slackLogo,
@@ -59,6 +62,7 @@ const logoMap: Record<string, string> = {
site: siteLogo,
bangumi: bangumiLogo,
'douban-black': doubanBlackLogo,
qq: qqLogo,
}
/**

View 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
}

View File

@@ -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',
},

View File

@@ -86,8 +86,8 @@ const searchType = ref('title')
const chooseSiteDialog = ref(false)
// 计算主题是否为透明
const isNonTransparentTheme = computed(() => {
return theme.name.value !== 'transparent'
const isTransparentTheme = computed(() => {
return theme.name.value === 'transparent'
})
// 查询所有站点
@@ -567,12 +567,16 @@ onBeforeMount(() => {
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
<template v-if="(getBackdropUrl || getPosterUrl) && isNonTransparentTheme">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<div
v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id"
class="max-w-8xl mx-auto px-4"
:class="{ 'media-detail-transparent': isTransparentTheme }"
>
<template v-if="getBackdropUrl || getPosterUrl">
<div class="vue-media-back vue-media-back-image absolute left-0 top-0 w-full h-96">
<VImg class="h-96" position="top" :src="getBackdropUrl || getPosterUrl" cover />
</div>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
<div class="vue-media-back vue-media-back-overlay absolute left-0 top-0 w-full h-96" />
</template>
<div class="media-page">
<div class="media-header">
@@ -1038,18 +1042,54 @@ onBeforeMount(() => {
<style lang="scss" scoped>
.vue-media-back {
--media-backdrop-edge-opacity: 1;
z-index: 0;
pointer-events: none;
background-image: linear-gradient(
180deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), 1) 100%
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
),
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
linear-gradient(
0deg,
rgba(var(--v-theme-background), 0) 80%,
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
),
linear-gradient(
90deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
),
linear-gradient(
270deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), var(--media-backdrop-edge-opacity)) 100%
);
margin-block-start: calc(-70px - env(safe-area-inset-top));
}
.vue-media-back-image {
background-image: none;
}
.media-detail-transparent .vue-media-back-overlay {
display: none;
}
.media-detail-transparent .vue-media-back-image {
opacity: 0.78;
mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
mask-composite: intersect;
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 58%, transparent 100%),
linear-gradient(to right, transparent 0%, #000 10%, #000 90%, transparent 100%);
-webkit-mask-composite: source-in;
}
.media-page {
position: relative;
z-index: 1;
background-position: 50%;
background-size: cover;
margin-block-start: calc(-4rem - env(safe-area-inset-top));

View File

@@ -34,6 +34,9 @@ const activeTab = ref('installed')
// 获取插件标签页
const pluginTabs = computed(() => getPluginTabs(t))
// 本地插件来源显示名称
const localRepoLabel = computed(() => t('plugin.local'))
// 使用动态标签页
const { registerHeaderTab } = useDynamicHeaderTab()
@@ -76,26 +79,6 @@ registerHeaderTab({
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-store-cog',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
MarketSettingDialog.value = true
},
show: computed(() => activeTab.value === 'market'),
},
{
icon: 'mdi-folder-plus',
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
action: () => {
showNewFolderDialog()
},
show: computed(() => activeTab.value === 'installed' && !currentFolder.value),
},
{
icon: 'mdi-arrow-left',
variant: 'text',
@@ -113,7 +96,7 @@ registerHeaderTab({
const pluginId = ref(route.query.id)
// 当前排序字段
const activeSort = ref(null)
const activeSort = ref<string | null>(null)
// 插件顺序配置
const orderConfig = ref<{ id: string; type?: string; order?: number }[]>([])
@@ -219,6 +202,16 @@ const isFilterFormEmpty = computed(() => {
)
})
// 切换市场过滤器多选项
function toggleMarketFilter(field: 'author' | 'label' | 'repo', value: string) {
const index = filterForm[field].indexOf(value)
if (index > -1) {
filterForm[field].splice(index, 1)
} else {
filterForm[field].push(value)
}
}
// 插件过滤条件
const installedFilter = ref(null)
@@ -600,15 +593,21 @@ async function saveFolderPluginOrder() {
// 初始化过滤选项
function initOptions(item: Plugin) {
const optionValue = (options: Array<string>, value: string | undefined) => {
value && !options.includes(value) && options.push(value)
const optionValue = (options: Array<string>, value: string | undefined, preferred = false) => {
if (!value || options.includes(value)) return
if (preferred) options.unshift(value)
else options.push(value)
}
const optionMutipleValue = (options: Array<string>, value: string | undefined) => {
value && value.split(',').forEach(v => !options.includes(v) && options.push(v))
}
optionValue(authorFilterOptions.value, item.plugin_author)
optionMutipleValue(labelFilterOptions.value, item.plugin_label)
optionValue(repoFilterOptions.value, handleRepoUrl(item.repo_url))
optionValue(
repoFilterOptions.value,
handleRepoUrl(item),
Boolean(item.is_local || item.repo_url?.startsWith('local://')),
)
}
// 关闭插件市场窗口
@@ -640,7 +639,7 @@ async function installPlugin(item: Plugin) {
enabledFilter.value = false
installedFilter.value = null
// 刷新
refreshData()
await refreshData()
} else {
$toast.error(t('plugin.installFailed', { name: item?.plugin_name, message: result.message }))
}
@@ -740,6 +739,7 @@ async function fetchUninstalledPlugins(force: boolean = false) {
// 排除已安装且有更新的,上面的问题在于"本地存在未安装的旧版本插件且云端有更新时"不会在插件市场展示
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed))
// 初始化过滤选项
repoFilterOptions.value = []
marketList.value.forEach(initOptions)
// 设置APP市场加载完成
isAppMarketLoaded.value = true
@@ -760,13 +760,14 @@ async function getPluginStatistics() {
// 加载所有数据
async function refreshData() {
await fetchInstalledPlugins()
fetchUninstalledPlugins()
await fetchUninstalledPlugins()
getPluginStatistics()
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
await loadPluginFolders()
}
// 对uninstalledList进行排序到sortedUninstalledList
watch([marketList, filterForm, activeSort], () => {
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
// 匹配过滤函数
const match = (filter: Array<string>, value: string | undefined) =>
filter.length === 0 || (value && filter.includes(value))
@@ -784,7 +785,7 @@ watch([marketList, filterForm, activeSort], () => {
filterText(filterForm.name, `${value.plugin_name} ${value.plugin_desc}`) &&
match(filterForm.author, value.plugin_author) &&
matchMultiple(filterForm.label, value.plugin_label) &&
match(filterForm.repo, handleRepoUrl(value.repo_url))
match(filterForm.repo, handleRepoUrl(value))
) {
sortedUninstalledList.value.push(value)
}
@@ -795,7 +796,7 @@ watch([marketList, filterForm, activeSort], () => {
if (!isNullOrEmptyObject(PluginStatistics.value)) {
if (!activeSort.value || activeSort.value === 'count') {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
return PluginStatistics.value[b.id || '0'] - PluginStatistics.value[a.id || '0']
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
})
} else if (activeSort.value) {
sortedUninstalledList.value = sortedUninstalledList.value.sort((a: any, b: any) => {
@@ -815,9 +816,9 @@ function pluginLabels(label: string | undefined) {
}
// 新安装了插件
function pluginInstalled() {
async function pluginInstalled() {
pluginDialogClose()
refreshData()
await refreshData()
}
// 插件市场设置完成
@@ -832,7 +833,7 @@ async function refreshMarket() {
isMarketRefreshing.value = true
try {
await fetchUninstalledPlugins(true)
await getPluginStatistics()
getPluginStatistics()
} catch (error) {
console.error(error)
} finally {
@@ -840,9 +841,22 @@ async function refreshMarket() {
}
}
function parseLocalRepoPath(repoUrl: string | undefined) {
if (!repoUrl?.startsWith('local://')) return ''
try {
return new URL(repoUrl).searchParams.get('path') || ''
} catch (error) {
return decodeURIComponent(repoUrl.match(/[?&]path=([^&]+)/)?.[1] || '')
}
}
// 处理掉github地址的前缀
function handleRepoUrl(url: string | undefined) {
function handleRepoUrl(item: Plugin | string | undefined) {
const url = typeof item === 'string' ? item : item?.repo_url
if (!url) return ''
if (url.startsWith('local://')) return parseLocalRepoPath(url) || localRepoLabel.value
if (typeof item !== 'string' && item?.is_local) return parseLocalRepoPath(url) || localRepoLabel.value
return url.replace('https://github.com/', '').replace('https://raw.githubusercontent.com/', '')
}
@@ -876,7 +890,6 @@ onMounted(async () => {
await loadPluginOrderConfig()
await loadPluginFolders() // 加载文件夹配置
await refreshData()
getPluginStatistics()
if (activeTab.value != 'market' && pluginId.value) {
// 找到这个插件
const plugin = dataList.value.find(item => item.id === pluginId.value)
@@ -886,12 +899,54 @@ onMounted(async () => {
}
})
// 使用动态按钮钩子
function openPluginSearchDialog() {
SearchDialog.value = true
}
function openMarketSettingDialog() {
MarketSettingDialog.value = true
}
const showSearchAction = computed(() => activeTab.value === 'installed' || activeTab.value === 'market')
const showNewFolderAction = computed(() => activeTab.value === 'installed' && !currentFolder.value)
const showMarketSettingAction = computed(() => activeTab.value === 'market')
const pluginDynamicMenuItems = computed(() => {
if (!appMode.value) return undefined
if (!showSearchAction.value) return undefined
const items = [
{
titleKey: 'plugin.searchPlugins',
icon: 'mdi-magnify',
action: openPluginSearchDialog,
},
]
if (showNewFolderAction.value) {
items.push({
titleKey: 'plugin.newFolder',
icon: 'mdi-folder-plus',
action: showNewFolderDialog,
})
}
if (showMarketSettingAction.value) {
items.push({
titleKey: 'dialog.pluginMarketSetting.title',
icon: 'mdi-store-cog',
action: openMarketSettingDialog,
})
}
return items.length > 1 ? items : undefined
})
useDynamicButton({
icon: 'mdi-magnify',
onClick: () => {
SearchDialog.value = true
},
onClick: openPluginSearchDialog,
menuItems: pluginDynamicMenuItems,
show: computed(() => appMode.value && showSearchAction.value && isRefreshed.value),
})
// 获取插件文件夹配置
@@ -1289,113 +1344,136 @@ function onDragStartPlugin(evt: any) {
<template>
<div>
<!-- 过滤弹窗 -->
<!-- 已安装插件过滤下拉菜单 -->
<Teleport to="body" v-if="filterInstalledPluginDialog">
<VMenu
v-model="filterInstalledPluginDialog"
width="20rem"
:close-on-content-click="false"
:activator="'[data-menu-activator=installed-filter-btn]'"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterInstalledPluginDialog = false" />
</VCardItem>
<VCardText>
<VRow>
<VCol cols="12">
<VCombobox
v-model="installedFilter"
:items="installedPluginNames"
:label="t('plugin.name')"
density="comfortable"
clearable
/>
</VCol>
<VCol cols="6">
<VSwitch v-model="enabledFilter" :label="t('plugin.running')" />
</VCol>
<VCol cols="6">
<VSwitch v-model="hasUpdateFilter" :label="t('plugin.hasNewVersion')" />
</VCol>
</VRow>
</VCardText>
<VCard min-width="220">
<!-- 名称搜索 -->
<div class="pa-3">
<VCombobox
v-model="installedFilter"
:items="installedPluginNames"
:placeholder="t('plugin.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.filter') }}</VListSubheader>
<VListItem :active="enabledFilter" @click="enabledFilter = !enabledFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-play-circle" color="success" size="small" />
</template>
<VListItemTitle>{{ t('plugin.running') }}</VListItemTitle>
<template #append>
<VIcon v-if="enabledFilter" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
<VListItem :active="hasUpdateFilter" @click="hasUpdateFilter = !hasUpdateFilter" density="compact">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle" color="info" size="small" />
</template>
<VListItemTitle>{{ t('plugin.hasNewVersion') }}</VListItemTitle>
<template #append>
<VIcon v-if="hasUpdateFilter" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</Teleport>
<!-- 插件市场过滤下拉菜单 -->
<Teleport to="body" v-if="filterMarketPluginDialog">
<VMenu
v-model="filterMarketPluginDialog"
width="25rem"
:close-on-content-click="false"
:activator="'[data-menu-activator=market-filter-btn]'"
location="bottom end"
>
<VCard>
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-filter-multiple-outline" class="mr-2" />
{{ t('plugin.filterPlugins') }}
</VCardTitle>
<VDialogCloseBtn @click="filterMarketPluginDialog = false" />
</VCardItem>
<VCardText>
<!-- 过滤表单 -->
<div v-if="isAppMarketLoaded">
<VRow>
<VCol cols="6">
<VTextField v-model="filterForm.name" density="comfortable" :label="t('plugin.name')" clearable />
</VCol>
<VCol v-if="authorFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.author"
:items="authorFilterOptions"
density="comfortable"
chips
:label="t('plugin.author')"
multiple
clearable
/>
</VCol>
<VCol v-if="labelFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.label"
:items="labelFilterOptions"
density="comfortable"
chips
:label="t('plugin.label')"
multiple
clearable
/>
</VCol>
<VCol v-if="repoFilterOptions.length > 0" cols="6">
<VSelect
v-model="filterForm.repo"
:items="repoFilterOptions"
density="comfortable"
chips
:label="t('plugin.repository')"
multiple
clearable
/>
</VCol>
<VCol v-if="sortOptions.length > 0" cols="6">
<VSelect
v-model="activeSort"
:items="sortOptions"
density="comfortable"
:label="t('plugin.sortTitle')"
/>
</VCol>
</VRow>
</div>
</VCardText>
<VCard min-width="260" max-width="320">
<!-- 名称搜索 -->
<div class="pa-3">
<VTextField
v-model="filterForm.name"
:placeholder="t('plugin.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('plugin.sortTitle') }}</VListSubheader>
<VListItem
v-for="option in sortOptions"
:key="option.value"
:active="(activeSort || 'count') === option.value"
@click="activeSort = option.value"
density="compact"
>
<VListItemTitle>{{ option.title }}</VListItemTitle>
<template #append>
<VIcon v-if="(activeSort || 'count') === option.value" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
<!-- 下拉多选筛选项 -->
<VDivider />
<div class="px-3 py-2 d-flex flex-column gap-2">
<VSelect
v-if="authorFilterOptions.length > 0"
v-model="filterForm.author"
:items="authorFilterOptions"
:label="t('plugin.author')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
<VSelect
v-if="labelFilterOptions.length > 0"
v-model="filterForm.label"
:items="labelFilterOptions"
:label="t('plugin.label')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
<VSelect
v-if="repoFilterOptions.length > 0"
v-model="filterForm.repo"
:items="repoFilterOptions"
:label="t('plugin.repository')"
multiple
chips
closable-chips
density="compact"
variant="outlined"
hide-details
clearable
/>
</div>
</VCard>
</VMenu>
</Teleport>
@@ -1525,18 +1603,31 @@ function onDragStartPlugin(evt: any) {
<!-- 插件搜索图标 -->
<Teleport to="body" v-if="route.path === '/plugins'">
<div v-if="isRefreshed">
<div v-if="isRefreshed && !appMode && showSearchAction" class="compact-fab-stack">
<VFab
v-if="!appMode"
icon="mdi-magnify"
color="info"
location="bottom"
size="x-large"
fixed
app
v-if="showMarketSettingAction"
icon="mdi-store-cog"
color="warning"
variant="tonal"
appear
@click="SearchDialog = true"
:class="{ 'mb-12': appMode }"
class="compact-fab compact-fab--secondary"
@click="openMarketSettingDialog"
/>
<VFab
v-if="showNewFolderAction"
icon="mdi-folder-plus"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="showNewFolderDialog"
/>
<VFab
icon="mdi-magnify"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openPluginSearchDialog"
/>
</div>
</Teleport>

View File

@@ -12,14 +12,26 @@ import { useDisplay } from 'vuetify'
import { formatFileSize } from '@/@core/utils/formatters'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useAvailableHeight } from '@/composables/useAvailableHeight'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useGlobalSettingsStore } from '@/stores'
// i18n
const { t } = useI18n()
// 全局设置
const globalSettingsStore = useGlobalSettingsStore()
// APP
const display = useDisplay()
// PWA模式检测
const { appMode } = usePWA()
const { useProgressSSE } = useBackgroundOptimization()
// 计算列表可用高度
// componentOffset = VCardItem搜索栏(68) + VDivider(1) + 分页栏(40) + VCard边距(2) = 111
const { availableHeight } = useAvailableHeight(125, 300)
// 提示框
const $toast = useToast()
@@ -39,6 +51,16 @@ const transferQueueDialog = ref(false)
// 当前操作记录
const currentHistory = ref<TransferHistory>()
// AI整理中的记录
const aiRedoIds = ref<number[]>([])
// AI整理进度
const aiRedoProgressDialog = ref(false)
const aiRedoProgressActive = ref(false)
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
const aiRedoProgressSSE = ref<any>(null)
const aiRedoProgressHistoryId = ref<number>()
// 重新整理IDS
const redoIds = ref<number[]>([])
const redoTargetStorage = ref<string>()
@@ -46,21 +68,21 @@ const redoTargetStorage = ref<string>()
// 已选中的数据
const selected = ref<TransferHistory[]>([])
const getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0);
const getNum = (s?: string) => (s ? parseInt(s.replace(/[^0-9]/g, ''), 10) || 0 : 0)
function sortByTitle(a: TransferHistory, b: TransferHistory) {
if (a.type !== b.type) {
return (a.type ?? '').localeCompare(b.type ?? '');
return (a.type ?? '').localeCompare(b.type ?? '')
}
if (a.title !== b.title) {
return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase());
return (a.title ?? '').toLocaleLowerCase().localeCompare((b.title ?? '').toLocaleLowerCase())
}
if (a.type === '电视剧') {
if (a.seasons !== b.seasons) {
return getNum(a.seasons) - getNum(b.seasons);
return getNum(a.seasons) - getNum(b.seasons)
}
if (a.episodes !== b.episodes) {
return getNum(a.episodes) - getNum(b.episodes);
return getNum(a.episodes) - getNum(b.episodes)
}
}
return 0
@@ -226,10 +248,13 @@ async function loadStorages() {
// 存储字典
const storageDict = computed(() => {
return storages.value.reduce((dict, item) => {
dict[item.type] = item.name
return dict
}, {} as Record<string, string>)
return storages.value.reduce(
(dict, item) => {
dict[item.type] = item.name
return dict
},
{} as Record<string, string>,
)
})
// 转移方式字典
@@ -242,30 +267,6 @@ const TransferDict: { [key: string]: string } = {
rclone_move: t('transferHistory.transferMode.rclone_move'),
}
// 计算列表可用高度
const availableHeight = computed(() => {
// 获取视口高度
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
// navbar高度
const navbarHeight = 72
// 工具栏高度
const toolbarHeight = 88
// 底部导航栏高度
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 - 48
// 确保最小高度
return Math.max(availableHeight, 300)
})
// 分页提示
const pageTip = computed(() => {
const begin = itemsPerPage.value * (currentPage.value - 1) + 1
@@ -418,7 +419,6 @@ async function removeHistoryBatch() {
// 打开确认弹窗
deleteConfirmDialog.value = true
}
// 批量重新整理
async function retransferBatch() {
if (selected.value.length === 0) return
@@ -441,32 +441,147 @@ function transferDone() {
fetchData()
}
// 弹出菜单
const dropdownItems = ref([
{
title: t('transferHistory.actions.redo'),
value: 1,
props: {
prependIcon: 'mdi-redo-variant',
click: (item: TransferHistory) => {
redoIds.value = [item.id]
redoTargetStorage.value = item.dest_storage
redoDialog.value = true
// AI助手是否启用
const aiAgentEnabled = computed(() => Boolean(globalSettingsStore.globalSettings.AI_AGENT_ENABLE))
const hasRunningAiRedo = computed(() => aiRedoIds.value.length > 0)
// AI整理中的记录
function isAiRedoing(historyId: number) {
return aiRedoIds.value.includes(historyId)
}
// 停止AI整理进度
function stopAiRedoProgress() {
aiRedoProgressActive.value = false
if (aiRedoProgressSSE.value) {
aiRedoProgressSSE.value.stop()
aiRedoProgressSSE.value = null
}
}
// AI整理完成
async function finishAiRedo(success: boolean, errorMessage?: string) {
const historyId = aiRedoProgressHistoryId.value
stopAiRedoProgress()
aiRedoProgressDialog.value = false
aiRedoProgressHistoryId.value = undefined
if (historyId !== undefined) {
aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)
}
await fetchData()
if (!success && errorMessage) {
$toast.error(errorMessage)
}
}
// 处理AI整理进度
async function handleAiRedoProgressMessage(event: MessageEvent) {
const progress = JSON.parse(event.data)
if (!progress) return
aiRedoProgressText.value = progress.text || t('transferHistory.actions.aiRedoPending')
if (progress.enable === false) {
await finishAiRedo(progress.data?.success !== false, progress.data?.error)
}
}
// 开始监听整理进度
function startAiRedoProgress(historyId: number, progressKey: string) {
stopAiRedoProgress()
aiRedoProgressHistoryId.value = historyId
aiRedoProgressDialog.value = true
aiRedoProgressActive.value = true
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
const url = `${import.meta.env.VITE_API_BASE_URL}system/progress/${progressKey}`
aiRedoProgressSSE.value = useProgressSSE(
url,
handleAiRedoProgressMessage,
`transfer-history-ai-redo-${progressKey}`,
aiRedoProgressActive,
)
aiRedoProgressSSE.value.start()
}
// 触发AI整理
async function triggerAiRedo(item: TransferHistory) {
if (!aiAgentEnabled.value) {
$toast.error(t('transferHistory.aiRedoDisabled'))
return
}
if (hasRunningAiRedo.value) return
aiRedoIds.value = [...aiRedoIds.value, item.id]
let progressStarted = false
try {
const result: { [key: string]: any } = await api.post(`history/transfer/${item.id}/ai-redo`)
const progressKey = result.data?.progress_key
if (!result.success || !progressKey) {
$toast.error(result.message || t('transferHistory.aiRedoFailed'))
return
}
startAiRedoProgress(item.id, progressKey)
progressStarted = true
} catch (error) {
console.error(error)
$toast.error(t('transferHistory.aiRedoFailed'))
} finally {
if (!progressStarted) {
aiRedoIds.value = aiRedoIds.value.filter(id => id !== item.id)
}
}
}
// 计算下拉菜单
function getDropdownItems(item: TransferHistory) {
return [
{
title: isAiRedoing(item.id) ? t('transferHistory.actions.aiRedoPending') : t('transferHistory.actions.aiRedo'),
value: 0,
props: {
prependIcon: 'mdi-robot-outline',
disabled: !aiAgentEnabled.value || (hasRunningAiRedo.value && !isAiRedoing(item.id)),
click: () => {
triggerAiRedo(item)
},
},
},
},
{
title: t('transferHistory.actions.delete'),
value: 2,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: (item: TransferHistory) => {
removeHistory(item)
{
title: t('transferHistory.actions.redo'),
value: 1,
props: {
prependIcon: 'mdi-redo-variant',
click: () => {
redoIds.value = [item.id]
redoTargetStorage.value = item.dest_storage
redoDialog.value = true
},
},
},
},
])
{
title: t('transferHistory.actions.delete'),
value: 2,
props: {
prependIcon: 'mdi-trash-can-outline',
color: 'error',
click: () => {
removeHistory(item)
},
},
},
]
}
// 添加url参数
function addUrlQuery(url: string, name: string, value: any) {
@@ -505,29 +620,75 @@ function ensureNumber(value: any, defaultValue: number = 0) {
// 按标题分组后的选中数量统计,键为标题,值为对应分组的选中数
const selectedCountsGroupedByTitle = computed(() => {
return selected.value.reduce((acc, item) => {
const title = item.title || '';
acc[title] = (acc[title] || 0) + 1;
return acc;
}, {} as Record<string, number>);
});
return selected.value.reduce(
(acc, item) => {
const title = item.title || ''
acc[title] = (acc[title] || 0) + 1
return acc
},
{} as Record<string, number>,
)
})
// 控制分组内所有子项的选中状态
const toggleGroupSelection = (checked: boolean | null, items: readonly any[]) => {
const values = items.map(item => item.value);
const values = items.map(item => item.value)
if (checked) {
selected.value = [...new Set([...selected.value, ...values])];
selected.value = [...new Set([...selected.value, ...values])]
} else {
const itemsSet = new Set(values);
selected.value = selected.value.filter(item => !itemsSet.has(item));
const itemsSet = new Set(values)
selected.value = selected.value.filter(item => !itemsSet.has(item))
}
};
}
const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chevron-up' : 'mdi-timer-sand-paused'))
const historyDynamicMenuItems = computed(() => {
if (selected.value.length === 0) return undefined
return [
{
titleKey: 'dialog.transferQueue.title',
icon: 'mdi-timer-sand-paused',
action: () => {
transferQueueDialog.value = true
},
},
{
titleKey: 'transferHistory.actions.batchRedo',
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
titleKey: 'transferHistory.actions.batchDelete',
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
]
})
useDynamicButton({
icon: historyDynamicIcon,
onClick: () => {
transferQueueDialog.value = true
},
menuItems: historyDynamicMenuItems,
show: computed(() => appMode.value),
})
// 初始加载数据
onMounted(() => {
loadStorages()
fetchData()
})
onUnmounted(() => {
stopAiRedoProgress()
})
</script>
<template>
@@ -557,7 +718,6 @@ onMounted(() => {
</VCol>
<VCol cols="4" md="6" class="text-end">
<VBtnGroup variant="outlined" divided rounded>
<VBtn icon="mdi-timer-sand-paused" @click="transferQueueDialog = true" />
<VBtn :icon="group ? 'mdi-format-list-bulleted' : 'mdi-format-list-group'" @click="group = !group" />
</VBtnGroup>
</VCol>
@@ -596,7 +756,7 @@ onMounted(() => {
<VCheckbox
:model-value="selectedCountsGroupedByTitle[item.value] == item.items.length"
:indeterminate="selectedCountsGroupedByTitle[item.value] < item.items.length"
@update:modelValue="(checked) => toggleGroupSelection(checked, item.items)"
@update:modelValue="checked => toggleGroupSelection(checked, item.items)"
/>
{{ item.value }}
</div>
@@ -655,10 +815,11 @@ onMounted(() => {
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
v-for="(menu, i) in getDropdownItems(item)"
:key="i"
:base-color="menu.props.color"
@click="menu.props.click(item)"
:disabled="menu.props.disabled"
@click="menu.props.click()"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
@@ -741,10 +902,11 @@ onMounted(() => {
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
v-for="(menu, i) in getDropdownItems(item)"
:key="i"
:base-color="menu.props.color"
@click="menu.props.click(item)"
:disabled="menu.props.disabled"
@click="menu.props.click()"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
@@ -775,32 +937,7 @@ onMounted(() => {
</div>
</VCard>
<!-- 底部操作按钮 -->
<Teleport to="body" v-if="route.path === '/history'">
<div v-if="isRefreshed && selected.length > 0">
<VFab
icon="mdi-trash-can-outline"
color="error"
location="bottom"
size="x-large"
fixed
app
appear
@click="removeHistoryBatch"
:class="appMode ? 'mb-28' : 'mb-16'"
/>
<VFab
:class="appMode ? 'mb-44' : 'mb-32'"
icon="mdi-redo-variant"
location="bottom"
size="x-large"
fixed
app
appear
@click="retransferBatch"
/>
</div>
</Teleport>
<!-- 底部弹窗 -->
<VBottomSheet v-model="deleteConfirmDialog" inset>
<VCard class="text-center">
@@ -826,6 +963,7 @@ onMounted(() => {
</VBottomSheet>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" :value="progressValue" />
<ProgressDialog v-if="aiRedoProgressDialog" v-model="aiRedoProgressDialog" :text="aiRedoProgressText" />
<!-- 文件整理弹窗 -->
<ReorganizeDialog
v-if="redoDialog"
@@ -837,6 +975,38 @@ onMounted(() => {
/>
<!-- 整理队列进度弹窗 -->
<TransferQueueDialog v-if="transferQueueDialog" v-model="transferQueueDialog" @close="transferQueueDialog = false" />
<!-- app 模式下的 FAB 按钮 -->
<Teleport to="body" v-if="!appMode && route.path === '/history'">
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
<VFab
v-if="selected.length > 0"
icon="mdi-trash-can-outline"
color="warning"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="removeHistoryBatch"
/>
<VFab
v-if="selected.length > 0"
icon="mdi-redo-variant"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="retransferBatch"
/>
<VFab
icon="mdi-timer-sand-paused"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="transferQueueDialog = true"
/>
</div>
</Teleport>
</template>
<style lang="scss">

View File

@@ -91,6 +91,10 @@ const notificationSwitchs = ref<NotificationSwitchConf[]>([
type: '插件',
action: 'admin',
},
{
type: '智能体',
action: 'admin',
},
{
type: '其它',
action: 'admin',
@@ -214,7 +218,17 @@ function changNotificationSetting(notification: NotificationConf, name: string)
async function loadNotificationSwitchs() {
try {
const result: { [key: string]: any } = await api.get('system/setting/NotificationSwitchs')
if (result.data?.value && result.data?.value.length > 0) notificationSwitchs.value = result.data?.value
if (result.data?.value && result.data?.value.length > 0) {
const savedSwitchs: NotificationSwitchConf[] = result.data.value
// 合并默认值中存在但后端数据中缺失的类型(如新增的类型)
const defaults = notificationSwitchs.value
for (const def of defaults) {
if (!savedSwitchs.find(item => item.type === def.type)) {
savedSwitchs.push(def)
}
}
notificationSwitchs.value = savedSwitchs
}
} catch (error) {
console.log(error)
}
@@ -300,6 +314,9 @@ onMounted(() => {
<VListItem @click="addNotification('synologychat')">
<VListItemTitle>{{ t('setting.notification.synologyChat') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('qqbot')">
<VListItemTitle>{{ t('setting.notification.qq') }}</VListItemTitle>
</VListItem>
<VListItem @click="addNotification('vocechat')">
<VListItemTitle>{{ t('setting.notification.voceChat') }}</VListItemTitle>
</VListItem>

View File

@@ -33,10 +33,14 @@ const SystemSettings = ref<any>({
CUSTOMIZE_WALLPAPER_API_URL: null,
AI_AGENT_ENABLE: false,
AI_AGENT_GLOBAL: false,
AI_AGENT_VERBOSE: false,
AI_AGENT_JOB_INTERVAL: 24,
LLM_PROVIDER: 'deepseek',
LLM_MODEL: 'deepseek-chat',
LLM_SUPPORT_IMAGE_INPUT: false,
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
AI_AGENT_RETRY_TRANSFER: false,
AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50,
@@ -55,6 +59,7 @@ const SystemSettings = ref<any>({
AUTO_UPDATE_RESOURCE: true,
MOVIEPILOT_AUTO_UPDATE: false,
// 媒体
RECOGNIZE_PLUGIN_FIRST: false,
TMDB_API_DOMAIN: null,
TMDB_IMAGE_DOMAIN: null,
TMDB_LOCALE: null,
@@ -79,33 +84,59 @@ const SystemSettings = ref<any>({
LOG_FILE_FORMAT: '【%(levelname)s】%(asctime)s - %(message)s',
// 实验室
PLUGIN_AUTO_RELOAD: false,
PLUGIN_LOCAL_REPO_PATHS: '',
ENCODING_DETECTION_PERFORMANCE_MODE: true,
TRANSFER_THREADS: 1,
},
})
// 刮削开关设
const ScrapingSwitchs = ref<any>({
movie_nfo: true, // 电影NFO
movie_poster: true, // 电影海报
movie_backdrop: true, // 电影背景图
movie_logo: true, // 电影Logo
movie_disc: true, // 电影光盘图
movie_banner: true, // 电影横幅图
movie_thumb: true, // 电影缩略图
tv_nfo: true, // 电视剧NFO
tv_poster: true, // 电视剧海报
tv_backdrop: true, // 电视剧背景图
tv_banner: true, // 电视剧横幅图
tv_logo: true, // 电视剧Logo
tv_thumb: true, // 电视剧缩略图
season_nfo: true, // 季NFO
season_poster: true, // 季海报
season_banner: true, // 季横幅图
season_thumb: true, // 季缩略图
episode_nfo: true, // 集NFO
episode_thumb: true, // 集缩略图
})
// 刮削
const scrapingConfig = [
{
section: 'movie',
items: [
{ key: 'movie_nfo', label: 'setting.system.movieNfo' },
{ key: 'movie_poster', label: 'setting.system.moviePoster' },
{ key: 'movie_backdrop', label: 'setting.system.movieBackdrop' },
{ key: 'movie_logo', label: 'setting.system.movieLogo' },
{ key: 'movie_disc', label: 'setting.system.movieDisc' },
{ key: 'movie_banner', label: 'setting.system.movieBanner' },
{ key: 'movie_thumb', label: 'setting.system.movieThumb' },
],
},
{
section: 'tv',
items: [
{ key: 'tv_nfo', label: 'setting.system.tvNfo' },
{ key: 'tv_poster', label: 'setting.system.tvPoster' },
{ key: 'tv_backdrop', label: 'setting.system.tvBackdrop' },
{ key: 'tv_banner', label: 'setting.system.tvBanner' },
{ key: 'tv_logo', label: 'setting.system.tvLogo' },
{ key: 'tv_thumb', label: 'setting.system.tvThumb' },
],
},
{
section: 'season',
items: [
{ key: 'season_nfo', label: 'setting.system.seasonNfo' },
{ key: 'season_poster', label: 'setting.system.seasonPoster' },
{ key: 'season_banner', label: 'setting.system.seasonBanner' },
{ key: 'season_thumb', label: 'setting.system.seasonThumb' },
],
},
{
section: 'episode',
items: [
{ key: 'episode_nfo', label: 'setting.system.episodeNfo' },
{ key: 'episode_thumb', label: 'setting.system.episodeThumb' },
],
},
]
// 刮削策略设置
const ScrapingPolicies = ref<Record<string, 'skip' | 'missingOnly' | 'overwrite'>>(
Object.fromEntries(scrapingConfig.flatMap(section => section.items.map(item => [item.key, 'missingOnly']))),
)
// 是否发送请求的总开关
const isRequest = ref(true)
@@ -128,6 +159,76 @@ const advancedDialog = ref(false)
// LLM 模型列表
const llmModels = ref<string[]>([])
const loadingModels = ref(false)
const savingBasic = ref(false)
const testingLlm = ref(false)
type LlmSettingsSnapshot = {
AI_AGENT_ENABLE: boolean
LLM_PROVIDER: string
LLM_MODEL: string
LLM_API_KEY: string
LLM_BASE_URL: string
}
let llmTestRequestId = 0
let llmTestAbortController: AbortController | null = null
function buildLlmSnapshot(): LlmSettingsSnapshot {
return {
AI_AGENT_ENABLE: Boolean(SystemSettings.value.Basic.AI_AGENT_ENABLE),
LLM_PROVIDER: String(SystemSettings.value.Basic.LLM_PROVIDER ?? ''),
LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
}
}
function buildLlmSnapshotKey(snapshot: LlmSettingsSnapshot) {
return JSON.stringify(snapshot)
}
function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
return {
enabled: snapshot.AI_AGENT_ENABLE,
provider: snapshot.LLM_PROVIDER.trim(),
model: snapshot.LLM_MODEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(),
}
}
function showLlmTestFailedToast(message?: string) {
const normalizedMessage = String(message ?? '').trim()
if (normalizedMessage) {
$toast.error(t('setting.system.llmTestFailedToastWithMessage', { message: normalizedMessage }))
return
}
$toast.error(t('setting.system.llmTestFailedToast'))
}
function invalidateLlmTestState() {
llmTestRequestId += 1
if (llmTestAbortController) {
llmTestAbortController.abort()
llmTestAbortController = null
}
testingLlm.value = false
}
const currentLlmSnapshot = computed(() => buildLlmSnapshot())
const currentLlmSnapshotKey = computed(() => buildLlmSnapshotKey(currentLlmSnapshot.value))
const canTestLlm = computed(() => {
const snapshot = currentLlmSnapshot.value
return (
snapshot.AI_AGENT_ENABLE &&
Boolean(snapshot.LLM_PROVIDER.trim()) &&
Boolean(snapshot.LLM_API_KEY.trim()) &&
Boolean(snapshot.LLM_MODEL.trim()) &&
!savingBasic.value &&
!testingLlm.value
)
})
const activeTab = ref('system')
@@ -269,6 +370,7 @@ async function saveMediaServerSetting() {
// 加载系统设置
async function loadSystemSettings() {
invalidateLlmTestState()
try {
const result: { [key: string]: any } = await api.get('system/env')
if (result.success) {
@@ -302,8 +404,56 @@ async function saveSystemSetting(value: { [key: string]: any }) {
// 保存基础设置
async function saveBasicSettings() {
if (await saveSystemSetting(SystemSettings.value.Basic)) {
$toast.success(t('setting.system.basicSaveSuccess'))
savingBasic.value = true
try {
if (await saveSystemSetting(SystemSettings.value.Basic)) {
$toast.success(t('setting.system.basicSaveSuccess'))
}
} finally {
savingBasic.value = false
}
}
async function testLlmConnection() {
if (!canTestLlm.value) return
const snapshot = buildLlmSnapshot()
const snapshotKey = buildLlmSnapshotKey(snapshot)
const payload = buildLlmTestPayload(snapshot)
const requestId = ++llmTestRequestId
if (llmTestAbortController) llmTestAbortController.abort()
const abortController = new AbortController()
llmTestAbortController = abortController
testingLlm.value = true
try {
const result: { [key: string]: any } = await api.post('system/llm-test', payload, {
signal: abortController.signal,
})
if (
requestId !== llmTestRequestId ||
abortController.signal.aborted ||
currentLlmSnapshotKey.value !== snapshotKey
) {
return
}
if (result?.success) $toast.success(t('setting.system.llmTestSuccessToast'))
else showLlmTestFailedToast(result?.message)
} catch (error) {
if (
requestId !== llmTestRequestId ||
abortController.signal.aborted ||
currentLlmSnapshotKey.value !== snapshotKey
) {
return
}
showLlmTestFailedToast(error instanceof Error ? error.message : String(error))
console.log(error)
} finally {
if (requestId !== llmTestRequestId) return
if (llmTestAbortController === abortController) llmTestAbortController = null
testingLlm.value = false
}
}
@@ -480,7 +630,14 @@ async function loadScrapingSwitchs() {
try {
const result: { [key: string]: any } = await api.get('system/setting/ScrapingSwitchs')
if (result.success && result.data?.value) {
ScrapingSwitchs.value = { ...ScrapingSwitchs.value, ...result.data.value }
const loadedSwitches = result.data.value
for (const key in loadedSwitches) {
if (typeof loadedSwitches[key] === 'boolean') {
// 兼容旧数据
loadedSwitches[key] = loadedSwitches[key] ? 'missingOnly' : 'skip'
}
}
ScrapingPolicies.value = { ...ScrapingPolicies.value, ...loadedSwitches }
}
} catch (error) {
console.log(error)
@@ -490,7 +647,7 @@ async function loadScrapingSwitchs() {
// 保存刮削开关设置
async function saveScrapingSwitchs() {
try {
const result: { [key: string]: any } = await api.post('system/setting/ScrapingSwitchs', ScrapingSwitchs.value)
const result: { [key: string]: any } = await api.post('system/setting/ScrapingSwitchs', ScrapingPolicies.value)
if (result.success) {
return true
} else {
@@ -519,6 +676,14 @@ onActivated(async () => {
onDeactivated(() => {
isRequest.value = false
})
onBeforeUnmount(() => {
invalidateLlmTestState()
})
watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
if (snapshotKey !== previousSnapshotKey) invalidateLlmTestState()
})
</script>
<template>
@@ -648,7 +813,7 @@ onDeactivated(() => {
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" md="6">
<VCol cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_ENABLE"
:label="t('setting.system.aiAgentEnable')"
@@ -656,7 +821,7 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_GLOBAL"
:label="t('setting.system.aiAgentGlobal')"
@@ -664,6 +829,14 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_VERBOSE"
:label="t('setting.system.aiAgentVerbose')"
:hint="t('setting.system.aiAgentVerboseHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_PROVIDER"
@@ -700,26 +873,43 @@ onDeactivated(() => {
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VCombobox
v-model="SystemSettings.Basic.LLM_MODEL"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:placeholder="t('setting.system.llmModelHint')"
persistent-hint
:items="llmModels"
:loading="loadingModels"
prepend-inner-icon="mdi-brain"
>
<template #append-inner>
<div>
<VCombobox
v-model="SystemSettings.Basic.LLM_MODEL"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:placeholder="t('setting.system.llmModelHint')"
persistent-hint
:items="llmModels"
:loading="loadingModels"
prepend-inner-icon="mdi-brain"
>
<template #append-inner>
<VBtn
variant="text"
icon="mdi-refresh"
size="small"
@click="loadLlmModels"
:disabled="!SystemSettings.Basic.LLM_API_KEY"
/>
</template>
</VCombobox>
<div class="d-flex justify-end mt-2">
<VBtn
variant="text"
icon="mdi-refresh"
size="small"
@click="loadLlmModels"
:disabled="!SystemSettings.Basic.LLM_API_KEY"
/>
</template>
</VCombobox>
color="info"
variant="tonal"
density="comfortable"
prepend-icon="mdi-connection"
:disabled="!canTestLlm"
:loading="testingLlm"
class="llm-test-trigger"
@click="testLlmConnection"
>
{{ t('setting.system.llmTestAction') }}
</VBtn>
</div>
</div>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VTextField
@@ -731,6 +921,41 @@ onDeactivated(() => {
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.AI_AGENT_JOB_INTERVAL"
:label="t('setting.system.aiAgentJobInterval')"
:hint="t('setting.system.aiAgentJobIntervalHint')"
persistent-hint
:items="[
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
]"
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.LLM_SUPPORT_IMAGE_INPUT"
:label="t('setting.system.llmSupportImageInput')"
:hint="t('setting.system.llmSupportImageInputHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSwitch
v-model="SystemSettings.Basic.AI_AGENT_RETRY_TRANSFER"
:label="t('setting.system.aiAgentRetryTransfer')"
:hint="t('setting.system.aiAgentRetryTransferHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
<VSwitch
v-model="SystemSettings.Basic.AI_RECOMMEND_ENABLED"
@@ -773,16 +998,23 @@ onDeactivated(() => {
</VCardText>
<VCardText>
<VForm @submit.prevent="() => {}">
<div class="d-flex flex-wrap gap-4 mt-4">
<VBtn type="submit" @click="saveBasicSettings" prepend-icon="mdi-content-save">
<div class="setting-actions mt-4">
<VBtn
type="submit"
@click="saveBasicSettings"
prepend-icon="mdi-content-save"
:loading="savingBasic"
:disabled="testingLlm"
class="text-no-wrap"
>
{{ t('common.save') }}
</VBtn>
<VSpacer />
<VBtn
color="error"
@click="advancedDialog = true"
prepend-icon="mdi-cog"
append-icon="mdi-dots-horizontal"
class="text-no-wrap setting-actions__secondary"
>
{{ t('setting.system.advancedSettings') }}
</VBtn>
@@ -792,6 +1024,7 @@ onDeactivated(() => {
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
@@ -1102,6 +1335,16 @@ onDeactivated(() => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.RECOGNIZE_PLUGIN_FIRST"
:label="t('setting.system.recognizePluginFirst')"
:hint="t('setting.system.recognizePluginFirstHint')"
persistent-hint
/>
</VCol>
</VRow>
<!-- 刮削开关设置 -->
<VRow class="mt-4">
@@ -1109,173 +1352,67 @@ onDeactivated(() => {
<VExpansionPanels>
<VExpansionPanel>
<VExpansionPanelTitle class="text-lg">
<template #default>
<VIcon icon="mdi-checkbox-multiple-outline" class="me-2" />
{{ t('setting.system.scrapingSwitchSettings') }}
</template>
<VIcon icon="mdi-checkbox-multiple-outline" class="me-2" />
{{ t('setting.system.scrapingSwitchSettings') }}
<!-- 帮助图标 -->
<VTooltip location="bottom" open-delay="200">
<template #activator="{ props: tooltipProps }">
<VBtn
v-bind="tooltipProps"
icon="mdi-help-circle"
size="small"
variant="text"
color="medium-emphasis"
class="ml-2"
@click.stop
/>
</template>
<div class="d-flex flex-column gap-2 py-2">
<div class="d-flex align-center">
<VIcon icon="mdi-file-remove" color="error" class="mr-2" />
<span>{{ t('setting.system.policy.skipDesc') }}</span>
</div>
<div class="d-flex align-center">
<VIcon icon="mdi-file-plus" color="success" class="mr-2" />
<span>{{ t('setting.system.policy.missingOnlyDesc') }}</span>
</div>
<div class="d-flex align-center">
<VIcon icon="mdi-file-replace" color="primary" class="mr-2" />
<span>{{ t('setting.system.policy.overwriteDesc') }}</span>
</div>
</div>
</VTooltip>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VRow>
<VRow v-for="section in scrapingConfig" :key="section.section">
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.movie') }}</VListSubheader>
<VListSubheader class="text-lg">
{{ t(`setting.system.${section.section}`) }}
</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_nfo"
:label="t('setting.system.movieNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_poster"
:label="t('setting.system.moviePoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_backdrop"
:label="t('setting.system.movieBackdrop')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_logo"
:label="t('setting.system.movieLogo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_disc"
:label="t('setting.system.movieDisc')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_banner"
:label="t('setting.system.movieBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.movie_thumb"
:label="t('setting.system.movieThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.tv') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_nfo"
:label="t('setting.system.tvNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_poster"
:label="t('setting.system.tvPoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_backdrop"
:label="t('setting.system.tvBackdrop')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_banner"
:label="t('setting.system.tvBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_logo"
:label="t('setting.system.tvLogo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.tv_thumb"
:label="t('setting.system.tvThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.season') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_nfo"
:label="t('setting.system.seasonNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_poster"
:label="t('setting.system.seasonPoster')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_banner"
:label="t('setting.system.seasonBanner')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.season_thumb"
:label="t('setting.system.seasonThumb')"
density="compact"
/>
</VCol>
</VRow>
<VDivider class="my-4" />
<VRow>
<VCol cols="12" class="pb-2">
<VListSubheader class="text-lg">{{ t('setting.system.episode') }}</VListSubheader>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.episode_nfo"
:label="t('setting.system.episodeNfo')"
density="compact"
/>
</VCol>
<VCol cols="6" md="3">
<VCheckbox
v-model="ScrapingSwitchs.episode_thumb"
:label="t('setting.system.episodeThumb')"
density="compact"
/>
<VCol v-for="item in section.items" :key="item.key" cols="12" md="4">
<div class="d-flex align-center">
<VBtnToggle
:model-value="ScrapingPolicies[item.key]"
@update:model-value="ScrapingPolicies[item.key] = $event"
color="primary"
variant="tonal"
rounded="lg"
>
<VBtn value="skip" color="error">
<VIcon icon="mdi-file-remove" />
</VBtn>
<VBtn value="missingOnly" color="success">
<VIcon icon="mdi-file-plus" />
</VBtn>
<VBtn value="overwrite" color="primary">
<VIcon icon="mdi-file-replace" />
</VBtn>
</VBtnToggle>
<span class="ml-2">{{ t(item.label) }}</span>
</div>
</VCol>
<VDivider v-if="section.section !== 'episode'" class="my-4" />
</VRow>
</VExpansionPanelText>
</VExpansionPanel>
@@ -1474,6 +1611,15 @@ onDeactivated(() => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.Advanced.PLUGIN_LOCAL_REPO_PATHS"
:label="t('setting.system.pluginLocalRepoPaths')"
:hint="t('setting.system.pluginLocalRepoPathsHint')"
persistent-hint
prepend-inner-icon="mdi-folder"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE"
@@ -1510,3 +1656,20 @@ onDeactivated(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.setting-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.setting-actions__secondary {
flex-shrink: 0;
}
.llm-test-trigger {
min-inline-size: 0;
}
</style>

View File

@@ -0,0 +1,262 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { wizardData, validationErrors } = useSetupWizard()
const llmModels = ref<string[]>([])
const loadingModels = ref(false)
const providerItems = [
{ title: 'OpenAI', value: 'openai' },
{ title: 'Google', value: 'google' },
{ title: 'DeepSeek', value: 'deepseek' },
]
const jobIntervalItems = computed(() => [
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
{ title: t('setting.system.aiAgentJobInterval1h'), value: 1 },
{ title: t('setting.system.aiAgentJobInterval3h'), value: 3 },
{ title: t('setting.system.aiAgentJobInterval6h'), value: 6 },
{ title: t('setting.system.aiAgentJobInterval12h'), value: 12 },
{ title: t('setting.system.aiAgentJobInterval24h'), value: 24 },
{ title: t('setting.system.aiAgentJobInterval1w'), value: 168 },
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
])
async function loadLlmModels() {
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
return
}
loadingModels.value = true
try {
const result: { [key: string]: any } = await api.get('system/llm-models', {
params: {
provider: wizardData.value.agent.provider,
api_key: wizardData.value.agent.apiKey,
base_url: wizardData.value.agent.baseUrl,
},
})
if (result.success) {
llmModels.value = result.data || []
if (!wizardData.value.agent.model && llmModels.value.length > 0) {
wizardData.value.agent.model = llmModels.value[0]
}
}
} catch (error) {
console.log('Load LLM models failed:', error)
} finally {
loadingModels.value = false
}
}
onMounted(() => {
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
loadLlmModels()
}
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.agent.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.agent.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.agent.info') }}</VAlertTitle>
{{ t('setupWizard.agent.infoDesc') }}
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.enabled"
:label="t('setting.system.aiAgentEnable')"
:hint="t('setting.system.aiAgentEnableHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.enabled">
<VCol cols="12" md="4">
<VSwitch
v-model="wizardData.agent.global"
:label="t('setting.system.aiAgentGlobal')"
:hint="t('setting.system.aiAgentGlobalHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="wizardData.agent.verbose"
:label="t('setting.system.aiAgentVerbose')"
:hint="t('setting.system.aiAgentVerboseHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="wizardData.agent.supportImageInput"
:label="t('setting.system.llmSupportImageInput')"
:hint="t('setting.system.llmSupportImageInputHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.provider"
:label="t('setting.system.llmProvider')"
:hint="t('setting.system.llmProviderHint')"
:items="providerItems"
:error="validationErrors.agent.provider"
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-robot-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.baseUrl"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"
placeholder="https://api.deepseek.com"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.apiKey"
:label="t('setting.system.llmApiKey')"
:hint="t('setting.system.llmApiKeyHint')"
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
:error="validationErrors.agent.apiKey"
:error-messages="validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="wizardData.agent.model"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:items="llmModels"
:loading="loadingModels"
:error="validationErrors.agent.model"
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-brain"
>
<template #append-inner>
<VBtn
variant="text"
icon="mdi-refresh"
size="small"
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
@click="loadLlmModels"
/>
</template>
</VCombobox>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="wizardData.agent.maxContextTokens"
:label="t('setting.system.llmMaxContextTokens')"
:hint="t('setting.system.llmMaxContextTokensHint')"
:error="validationErrors.agent.maxContextTokens"
:error-messages="
validationErrors.agent.maxContextTokens ? [t('setupWizard.agent.maxContextTokensRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-counter"
type="number"
min="1"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.jobInterval"
:label="t('setting.system.aiAgentJobInterval')"
:hint="t('setting.system.aiAgentJobIntervalHint')"
:items="jobIntervalItems"
persistent-hint
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.retryTransfer"
:label="t('setting.system.aiAgentRetryTransfer')"
:hint="t('setting.system.aiAgentRetryTransferHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.recommendEnabled"
:label="t('setting.system.aiRecommendEnabled')"
:hint="t('setting.system.aiRecommendEnabledHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol v-if="wizardData.agent.recommendEnabled" cols="12" md="6">
<VTextarea
v-model="wizardData.agent.recommendUserPreference"
:label="t('setting.system.aiRecommendUserPreference')"
:hint="t('setting.system.aiRecommendUserPreferenceHint')"
persistent-hint
prepend-inner-icon="mdi-account-heart-outline"
rows="2"
auto-grow
/>
</VCol>
<VCol v-if="wizardData.agent.recommendEnabled" cols="12" md="6">
<VTextField
v-model.number="wizardData.agent.recommendMaxItems"
:label="t('setting.system.aiRecommendMaxItems')"
:hint="t('setting.system.aiRecommendMaxItemsHint')"
:error="validationErrors.agent.recommendMaxItems"
:error-messages="
validationErrors.agent.recommendMaxItems ? [t('setupWizard.agent.recommendMaxItemsRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-format-list-numbered"
type="number"
min="1"
/>
</VCol>
</template>
</VRow>
</VCardText>
</VCard>
</template>

View File

@@ -119,6 +119,16 @@ const usernameErrorMessage = computed(() => {
clearable
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.ocrHost"
:label="t('setting.system.ocrHost')"
:hint="t('setting.system.ocrHostHint')"
placeholder="https://movie-pilot.org"
persistent-hint
prepend-inner-icon="mdi-text-recognition"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.basic.proxyHost"

View File

@@ -15,6 +15,23 @@ 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' },
])
function ensureUgreenConfig() {
if (wizardData.value.mediaServer.type !== 'ugreen') return
wizardData.value.mediaServer.config = wizardData.value.mediaServer.config || {}
if (!wizardData.value.mediaServer.config.scan_mode) {
wizardData.value.mediaServer.config.scan_mode = 'supplement_missing'
}
if (wizardData.value.mediaServer.config.verify_ssl === undefined) {
wizardData.value.mediaServer.config.verify_ssl = true
}
}
// 调用API查询媒体库
async function loadLibrary(server: string) {
try {
@@ -42,6 +59,7 @@ async function loadLibrary(server: string) {
// 选择媒体服务器并自动加载媒体库
async function selectMediaServerWithLibrary(type: string) {
selectMediaServer(type)
ensureUgreenConfig()
// 如果选择了媒体服务器类型,自动加载媒体库
if (type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -50,6 +68,7 @@ async function selectMediaServerWithLibrary(type: string) {
// 组件挂载时检查是否需要加载媒体库
onMounted(async () => {
ensureUgreenConfig()
// 如果已经有媒体服务器配置,自动加载媒体库
if (wizardData.value.mediaServer.type && wizardData.value.mediaServer.name) {
await loadLibrary(wizardData.value.mediaServer.name)
@@ -60,6 +79,7 @@ onMounted(async () => {
watch(
() => [wizardData.value.mediaServer.type, wizardData.value.mediaServer.name],
async ([type, name]) => {
ensureUgreenConfig()
console.log('Media server changed:', { type, name })
if (type && name) {
await loadLibrary(name)
@@ -141,6 +161,19 @@ watch(
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.mediaServer.type === 'ugreen' ? 'primary' : 'default'"
:variant="wizardData.mediaServer.type === 'ugreen' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectMediaServerWithLibrary('ugreen')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('ugreen')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">绿联影视</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</VCol>
@@ -380,6 +413,107 @@ watch(
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'ugreen'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.name"
:label="t('common.name')"
:placeholder="t('mediaserver.nameRequired')"
:hint="t('mediaserver.serverAlias')"
:error="validationErrors.mediaServer.name"
:error-messages="validationErrors.mediaServer.name ? [t('mediaserver.nameRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.host"
:label="t('mediaserver.host')"
:placeholder="t('mediaserver.hostPlaceholder')"
:hint="t('mediaserver.hostHint')"
:error="validationErrors.mediaServer.host"
:error-messages="validationErrors.mediaServer.host ? [t('mediaserver.hostRequired')] : []"
persistent-hint
active
prepend-inner-icon="mdi-server"
required
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="wizardData.mediaServer.config.play_host"
:label="t('mediaserver.playHost')"
:placeholder="t('mediaserver.playHostPlaceholder')"
:hint="t('mediaserver.playHostHint')"
persistent-hint
active
prepend-inner-icon="mdi-play-network"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.mediaServer.config.username"
:label="t('mediaserver.username')"
:error="validationErrors.mediaServer.username"
:error-messages="validationErrors.mediaServer.username ? [t('mediaserver.usernameRequired')] : []"
active
prepend-inner-icon="mdi-account"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
type="password"
v-model="wizardData.mediaServer.config.password"
:label="t('mediaserver.password')"
:error="validationErrors.mediaServer.password"
:error-messages="validationErrors.mediaServer.password ? [t('mediaserver.passwordRequired')] : []"
active
prepend-inner-icon="mdi-lock"
required
/>
</VCol>
<VCol cols="12">
<VAutocomplete
v-model="wizardData.mediaServer.sync_libraries"
:label="t('mediaserver.syncLibraries')"
:items="librariesOptions"
chips
multiple
clearable
:hint="t('mediaserver.syncLibrariesHint')"
persistent-hint
active
append-inner-icon="mdi-refresh"
prepend-inner-icon="mdi-library"
@click:append-inner="loadLibrary(wizardData.mediaServer.name)"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.mediaServer.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="wizardData.mediaServer.config.verify_ssl"
:label="t('mediaserver.verifySsl')"
:hint="t('mediaserver.verifySslHint')"
persistent-hint
color="primary"
inset
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.mediaServer.type === 'plex'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -15,6 +15,7 @@ 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') },
]
</script>
@@ -91,6 +92,19 @@ const notificationTypes = [
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'qqbot' ? 'primary' : 'default'"
:variant="wizardData.notification.type === 'qqbot' ? 'tonal' : 'outlined'"
class="cursor-pointer"
@click="selectNotification('qqbot')"
>
<VCardText class="text-center">
<VImg :src="getLogoUrl('notification')" height="48" width="48" class="mx-auto mb-2" />
<div class="text-h6">QQ</div>
</VCardText>
</VCard>
</VCol>
<VCol cols="12" md="3">
<VCard
:color="wizardData.notification.type === 'vocechat' ? 'primary' : 'default'"
@@ -312,6 +326,69 @@ const notificationTypes = [
/>
</VCol>
</VRow>
<VRow v-else-if="wizardData.notification.type === 'qqbot'">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.name"
:label="t('notification.name')"
:placeholder="t('notification.name')"
:hint="t('notification.nameHint')"
:error="validationErrors.notification.name"
:error-messages="validationErrors.notification.name ? [t('notification.nameRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-label"
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.QQ_APP_ID"
:label="t('notification.qqbot.appId')"
:hint="t('notification.qqbot.appIdHint')"
:error="validationErrors.notification.QQ_APP_ID"
:error-messages="
validationErrors.notification.QQ_APP_ID ? [t('notification.qqbot.appIdRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-application"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.config.QQ_APP_SECRET"
:label="t('notification.qqbot.appSecret')"
:hint="t('notification.qqbot.appSecretHint')"
:error="validationErrors.notification.QQ_APP_SECRET"
:error-messages="
validationErrors.notification.QQ_APP_SECRET
? [t('notification.qqbot.appSecretRequired')]
: []
"
persistent-hint
prepend-inner-icon="mdi-key"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.notification.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="wizardData.notification.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="wizardData.notification.type === 'slack'">
<VCol cols="12" md="6">
<VTextField

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSetupWizard } from '@/composables/useSetupWizard'
const { t } = useI18n()
const { wizardData, authSites, validationErrors } = useSetupWizard()
const siteItems = computed(() => {
return Object.keys(authSites.value).map(key => ({
key,
name: authSites.value[key].name,
prependAvatar: authSites.value[key].icon,
}))
})
const formFields = computed(() => {
const site = authSites.value[wizardData.value.siteAuth.site]
return Object.keys(site?.params || {})
.filter(key => site.params[key]?.name && site.params[key]?.type)
.map(key => ({
key,
site: wizardData.value.siteAuth.site,
name: site.params[key].name,
type: site.params[key].type,
placeholder: site.params[key].placeholder,
tooltip: site.params[key].tooltip,
}))
})
</script>
<template>
<VCard variant="outlined">
<VCardText>
<div class="text-center mb-6">
<h3 class="text-h4 mb-2">{{ t('setupWizard.siteAuth.title') }}</h3>
<p class="text-body-1 text-medium-emphasis">{{ t('setupWizard.siteAuth.description') }}</p>
</div>
<VRow>
<VCol cols="12">
<VAlert type="info" variant="tonal" class="mb-4">
<VAlertTitle>{{ t('setupWizard.siteAuth.info') }}</VAlertTitle>
{{ t('setupWizard.siteAuth.infoDesc') }}
</VAlert>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.siteAuth.auxiliaryAuthEnable"
:label="t('setting.system.auxAuthEnable')"
:hint="t('setting.system.auxAuthEnableHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSelect
v-model="wizardData.siteAuth.site"
:items="siteItems"
item-value="key"
item-title="name"
item-props
:label="t('dialog.userAuth.selectSite')"
:hint="t('setupWizard.siteAuth.selectSiteHint')"
:error="validationErrors.siteAuth.site"
:error-messages="validationErrors.siteAuth.site ? [t('dialog.userAuth.selectSiteRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-web"
clearable
/>
</VCol>
<template v-if="wizardData.siteAuth.site">
<VCol cols="12">
<VAlert type="warning" variant="tonal">
{{ t('setupWizard.siteAuth.submitHint') }}
</VAlert>
</VCol>
<VCol v-for="param in formFields" :key="param.key" cols="12" md="6">
<VTextField
v-model="wizardData.siteAuth.params[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:type="param.type"
:label="param.name"
:placeholder="param.placeholder"
:hint="param.tooltip"
:error="validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]"
:error-messages="
validationErrors.siteAuth[param.site.toUpperCase() + '_' + param.key.toUpperCase()]
? [t('setupWizard.siteAuth.fieldRequired', { name: param.name })]
: []
"
clearable
persistent-hint
/>
</VCol>
</template>
</VRow>
</VCardText>
</VCard>
</template>

View File

@@ -57,7 +57,7 @@ const filterOption = ref('all') // all, active, inactive, connected, slow, faile
// 筛选选项
const filterOptions = computed(() => [
{ value: 'all', label: t('common.all'), icon: 'mdi-format-list-bulleted' },
{ value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },
{ value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },
{ value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },
{ value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },
@@ -393,17 +393,15 @@ useDynamicButton({
/>
<!-- 新增站点按钮 -->
<Teleport to="body" v-if="route.path === '/site'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-web-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="siteAddDialog = true"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-web-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="siteAddDialog = true"
/>
</div>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog

View File

@@ -6,21 +6,13 @@ import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import { useUserStore } from '@/stores'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
// 国际化
const { t } = useI18n()
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 用户 Store
const userStore = useUserStore()
@@ -185,6 +177,10 @@ function historyDone() {
fetchData()
}
function openHistoryDialog() {
historyDialog.value = true
}
// 批量管理相关函数
// 切换批量模式
function toggleBatchMode() {
@@ -381,12 +377,8 @@ onActivated(async () => {
}
})
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-history',
onClick: () => {
historyDialog.value = true
},
defineExpose({
openHistoryDialog,
})
</script>
@@ -477,23 +469,6 @@ useDynamicButton({
:error-title="errorTitle"
:error-description="errorDescription"
/>
<!-- 底部操作按钮 -->
<Teleport to="body" v-if="route.path.startsWith(`/subscribe/${props.type === '电影' ? 'movie' : 'tv'}`)">
<div v-if="isRefreshed">
<VFab
v-if="userStore.superUser && !appMode"
icon="mdi-history"
color="info"
location="bottom"
:class="{ 'mb-12': appMode }"
size="x-large"
fixed
app
appear
@click="historyDialog = true"
/>
</div>
</Teleport>
<!-- 历史记录弹窗 -->
<SubscribeHistoryDialog
v-if="historyDialog"

View File

@@ -14,164 +14,28 @@ interface Status {
Doing?: string
}
interface TargetItem {
id: string
icon: string
name: string
}
interface Address {
id: string
image: string
name: string
url: string
proxy: boolean
status: keyof Status
time: string
message: string
btndisable: boolean
include?: string
}
// 测试集
const targets = ref<Address[]>([
{
image: getLogoUrl('tmdb'),
name: 'api.themoviedb.org',
url: 'https://api.themoviedb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
status: 'Normal',
time: '',
message: '',
btndisable: false,
},
{
image: getLogoUrl('tmdb'),
name: 'api.tmdb.org',
url: 'https://api.tmdb.org/3/movie/550?api_key={TMDBAPIKEY}',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('tmdb'),
name: 'www.themoviedb.org',
url: 'https://www.themoviedb.org',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: tvdb,
name: 'api.thetvdb.com',
url: 'https://api.thetvdb.com/series/81189',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('fanart'),
name: 'webservice.fanart.tv',
url: 'https://webservice.fanart.tv',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('telegram'),
name: 'api.telegram.org',
url: 'https://api.telegram.org',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('wechat'),
name: 'qyapi.weixin.qq.com',
url: 'https://qyapi.weixin.qq.com/cgi-bin/gettoken',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('douban'),
name: 'frodo.douban.com',
url: 'https://frodo.douban.com',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('slack'),
name: 'slack.com',
url: 'https://slack.com',
proxy: false,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('python'),
name: 'pypi.org',
url: '{PIP_PROXY}rsa/',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'pypi:repository-version',
},
{
image: getLogoUrl('github'),
name: 'github.com',
url: '{GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/blob/v2/README.md',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'MoviePilot',
},
{
image: getLogoUrl('github'),
name: 'codeload.github.com',
url: 'https://codeload.github.com',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('github'),
name: 'api.github.com',
url: 'https://api.github.com',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
},
{
image: getLogoUrl('github'),
name: 'raw.githubusercontent.com',
url: '{GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/README.md',
proxy: true,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
include: 'MoviePilot',
},
])
function resolveTargetImage(icon: string) {
if (icon === 'tvdb') return tvdb
return getLogoUrl(icon)
}
const targets = ref<Address[]>([])
const resolveStatusColor: Status = {
OK: 'success',
@@ -183,13 +47,36 @@ const resolveStatusColor: Status = {
const abortControllers = new Set<AbortController>()
const isUnmounting = ref(false)
async function loadTargets() {
// 测试项由后端下发,前端只负责展示,避免再把可测试目标和校验规则留在客户端。
const result: { [key: string]: any } = await api.get('system/nettest/targets')
if (!result.success || !Array.isArray(result.data)) {
targets.value = []
return
}
targets.value = result.data.map((item: TargetItem) => ({
id: item.id,
image: resolveTargetImage(item.icon),
name: item.name,
status: 'Normal',
time: '',
message: t('netTest.notTested'),
btndisable: false,
}))
}
// 调用API测试网络连接
async function netTest(index: number) {
const target = targets.value[index]
if (!target) return
// 页面切换时需要主动中止请求,否则自动轮询中的旧请求会回写已卸载页面状态。
const abortController = new AbortController()
abortControllers.add(abortController)
try {
const abortController = new AbortController()
abortControllers.add(abortController)
const { signal } = abortController
const target = targets.value[index]
target.btndisable = true
target.status = 'Doing'
@@ -197,15 +84,11 @@ async function netTest(index: number) {
const result: { [key: string]: any } = await api.get('system/nettest', {
params: {
url: target.url,
proxy: target.proxy,
include: target.include,
target_id: target.id,
},
signal,
})
abortControllers.delete(abortController)
if (result.success) {
target.status = 'OK'
target.message = t('netTest.normal')
@@ -216,13 +99,21 @@ async function netTest(index: number) {
target.time = result.data?.time
target.btndisable = false
} catch (error) {
console.error(error)
if (!isUnmounting.value) {
target.status = 'Fail'
target.message = error instanceof Error ? error.message : t('netTest.notTested')
target.btndisable = false
}
} finally {
abortControllers.delete(abortController)
}
}
// 加载时测试所有连接
onMounted(async () => {
isUnmounting.value = false
await loadTargets()
// 逐个串行测试,避免同时触发过多外部请求导致结果受限流或代理抖动影响。
for (let i = 0; !isUnmounting.value && i < targets.value.length; i++) await netTest(i)
})
onBeforeUnmount(() => {
@@ -236,7 +127,7 @@ onBeforeUnmount(() => {
<template>
<VList lines="two" rounded>
<template v-for="(target, index) of targets" :key="target.name">
<template v-for="(target, index) of targets" :key="target.id">
<VListItem>
<template #prepend>
<VAvatar :image="target.image" />

View File

@@ -70,10 +70,6 @@ useDataRefresh(
<template>
<VCard>
<VCardItem>
<VCardTitle>{{ t('setting.scheduler.title') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.scheduler.subtitle') }}</VCardSubtitle>
</VCardItem>
<VTable class="text-no-wrap">
<thead>
<tr>

View File

@@ -99,17 +99,15 @@ useDynamicButton({
<!-- 新增用户按钮 -->
<Teleport to="body" v-if="route.path === '/user'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-account-plus"
location="bottom"
size="x-large"
fixed
app
appear
@click="openAddUserDialog"
:class="{ 'mb-12': appMode }"
/>
<div v-if="isRefreshed && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-account-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openAddUserDialog"
/>
</div>
</Teleport>
<!-- 用户添加弹窗 -->

View File

@@ -5,8 +5,6 @@ import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useDynamicButton } from '@/composables/useDynamicButton'
// 国际化
const { t } = useI18n()
@@ -14,12 +12,6 @@ const { t } = useI18n()
// 是否刷新
const isRefreshed = ref(false)
// 路由
const route = useRoute()
// PWA模式检测
const { appMode } = usePWA()
// 新增对话框
const addDialog = ref(false)
@@ -54,14 +46,6 @@ function addDone() {
fetchData()
}
// 使用动态按钮钩子 新增
useDynamicButton({
icon: 'mdi-plus',
onClick: () => {
addDialog.value = true
},
})
onMounted(() => {
loadEventTypes()
fetchData()
@@ -70,6 +54,14 @@ onMounted(() => {
onActivated(() => {
fetchData()
})
function openAddDialog() {
addDialog.value = true
}
defineExpose({
openAddDialog,
})
</script>
<template>
<div>
@@ -83,20 +75,6 @@ onActivated(() => {
:error-title="t('workflow.noWorkflow')"
:error-description="t('workflow.noWorkflowDescription')"
/>
<!-- 新增按钮 -->
<Teleport to="body" v-if="route.path === '/workflow'">
<VFab
v-if="isRefreshed && !appMode"
icon="mdi-plus"
location="bottom"
size="x-large"
fixed
app
appear
:class="{ 'mb-12': appMode }"
@click="addDialog = true"
/>
</Teleport>
<!-- 新增对话框 -->
<WorkflowAddEditDialog v-if="addDialog" v-model="addDialog" @close="addDialog = false" @save="addDone" />
</div>

View File

@@ -30,6 +30,7 @@ const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
const currentKey = ref(0)
// 是否加载中
const loading = ref(false)
@@ -53,6 +54,17 @@ async function loadEventTypes() {
}
}
watch(
() => props.keyword,
newKeyword => {
keyword.value = newKeyword || ''
page.value = 1
dataList.value = []
isRefreshed.value = false
currentKey.value++
},
)
// 拼装参数
function getParams() {
let params = {
@@ -141,7 +153,7 @@ onActivated(() => {
<template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData">
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">

1003
yarn.lock

File diff suppressed because it is too large Load Diff