mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-28 11:01:41 +08:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0f12ac41 | ||
|
|
6789d63ca1 | ||
|
|
3202251f55 | ||
|
|
8e99ad9cf9 | ||
|
|
83dde400e7 | ||
|
|
1b57f925ee | ||
|
|
16428066b9 | ||
|
|
e211a80cf4 | ||
|
|
ea0b5b62d9 | ||
|
|
62dc2c4f66 | ||
|
|
b2a2c7080e | ||
|
|
05c2e7855a | ||
|
|
8d9c622dc5 | ||
|
|
bf0b17c314 | ||
|
|
37f31f6554 | ||
|
|
3de409fb07 | ||
|
|
7e9c0fd206 | ||
|
|
fb4f5658a8 | ||
|
|
a9f4ec963b | ||
|
|
542e33d7b4 | ||
|
|
39c250ba09 | ||
|
|
924fcef403 | ||
|
|
e586342b19 | ||
|
|
c795de9b2d | ||
|
|
6fa1cf28f4 | ||
|
|
3f70aafdad | ||
|
|
f8ceee39b3 | ||
|
|
0a22f33e34 | ||
|
|
cf88ed9a58 | ||
|
|
49dfd794c1 | ||
|
|
68f2f010d1 | ||
|
|
9eed2fea87 | ||
|
|
1f170030ee | ||
|
|
e78ed20936 | ||
|
|
b1787b207d | ||
|
|
fdb34732cc | ||
|
|
fc1f163a94 | ||
|
|
a771dc5354 | ||
|
|
d28360a161 | ||
|
|
a730abc437 | ||
|
|
5b72eda4fc | ||
|
|
6c49d7a59e | ||
|
|
8900366faf | ||
|
|
e8e0ac9084 | ||
|
|
c66ee881b1 | ||
|
|
c055740926 | ||
|
|
a5bc4e6baf | ||
|
|
15b4ee5893 | ||
|
|
8868403ff3 | ||
|
|
3abff72e25 | ||
|
|
0c56cf0be7 | ||
|
|
ce12d04648 | ||
|
|
efc0ae4df6 | ||
|
|
2530c3bcd9 | ||
|
|
60e2402aff | ||
|
|
1a478f97fb | ||
|
|
33666703af | ||
|
|
cd69172a99 | ||
|
|
61749e3595 | ||
|
|
b658533262 | ||
|
|
d8015b7def | ||
|
|
33599cc21d | ||
|
|
bf22a4809d | ||
|
|
4a6f7390e6 | ||
|
|
405e460ad6 | ||
|
|
18566c0e9d | ||
|
|
2c471a936f | ||
|
|
2efb07402f | ||
|
|
9434ef71e4 | ||
|
|
e06b9537ff | ||
|
|
2829e3b082 | ||
|
|
1a0fc10559 | ||
|
|
5a1aec3323 | ||
|
|
48913b8811 | ||
|
|
0a7d53b5c7 | ||
|
|
da0cd14af8 | ||
|
|
342c62c085 | ||
|
|
891274cc0e | ||
|
|
889a4b744a | ||
|
|
7fc5b74851 | ||
|
|
785cbcf81d | ||
|
|
364b660390 | ||
|
|
599ca912f4 | ||
|
|
2f66f0f1fc | ||
|
|
cd2f561194 | ||
|
|
c59a555a2d | ||
|
|
4413fedec5 | ||
|
|
d7562ea506 | ||
|
|
951d76481b | ||
|
|
68b0071009 | ||
|
|
0594d1d5b2 | ||
|
|
4f328add1b | ||
|
|
62c9a10377 | ||
|
|
d3d0d847f6 | ||
|
|
b7dd397664 | ||
|
|
c0276fca9f | ||
|
|
4691d12faa | ||
|
|
d0cac34d08 | ||
|
|
2f46c19826 | ||
|
|
b1cb07ae8c |
123
.github/workflows/pr-agent.yml
vendored
Normal file
123
.github/workflows/pr-agent.yml
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
name: PR Agent
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
# PR-Agent 通过 base repo 上下文读取 PR diff 并发布 Review,不 checkout 或执行 PR 分支代码。
|
||||||
|
# pull_request_target 允许 fork PR 使用仓库 secrets,因此 workflow 只运行固定 digest 的 PR-Agent 容器。
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
- review_requested
|
||||||
|
- synchronize
|
||||||
|
issue_comment:
|
||||||
|
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||||
|
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||||
|
types:
|
||||||
|
- created
|
||||||
|
- edited
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
# 读取仓库内容和 PR diff。
|
||||||
|
contents: read
|
||||||
|
# 更新 PR 描述、发布 PR Review 或修改 PR 相关元数据。
|
||||||
|
pull-requests: write
|
||||||
|
# PR 评论在 GitHub API 中属于 issue comments,手动命令和总结评论需要该权限。
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-agent:
|
||||||
|
name: PR-Agent review and describe
|
||||||
|
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
||||||
|
if: >-
|
||||||
|
github.event.sender.type != 'Bot' &&
|
||||||
|
(
|
||||||
|
github.event_name == 'pull_request_target' ||
|
||||||
|
(
|
||||||
|
github.event_name == 'issue_comment' &&
|
||||||
|
github.event.issue.pull_request != null &&
|
||||||
|
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
||||||
|
(
|
||||||
|
github.event.comment.body == '/review' ||
|
||||||
|
startsWith(github.event.comment.body, '/review ') ||
|
||||||
|
github.event.comment.body == '/describe' ||
|
||||||
|
startsWith(github.event.comment.body, '/describe ') ||
|
||||||
|
github.event.comment.body == '/improve' ||
|
||||||
|
startsWith(github.event.comment.body, '/improve ') ||
|
||||||
|
github.event.comment.body == '/ask' ||
|
||||||
|
startsWith(github.event.comment.body, '/ask ')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Run PR-Agent
|
||||||
|
id: pragent
|
||||||
|
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
||||||
|
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
|
||||||
|
env:
|
||||||
|
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
# 仓库设置中添加的 Secret:Settings -> Secrets and variables -> Actions。
|
||||||
|
# 该 key 只传给 PR-Agent 运行时,不写入仓库。
|
||||||
|
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
|
||||||
|
|
||||||
|
# 仓库设置中添加的 Secret。OpenAI 兼容服务通常需要填写以 "/v1" 结尾的 API 根地址。
|
||||||
|
OPENAI.API_BASE: ${{ secrets.OPENAI_API_BASE }}
|
||||||
|
|
||||||
|
# 模型、输出语言和大 diff 处理策略。
|
||||||
|
config.model: "gpt-5.5"
|
||||||
|
config.fallback_models: '["gpt-5.4"]'
|
||||||
|
config.reasoning_effort: "xhigh"
|
||||||
|
config.ai_timeout: "900"
|
||||||
|
config.response_language: "zh-CN"
|
||||||
|
config.large_patch_policy: "clip"
|
||||||
|
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||||
|
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||||
|
|
||||||
|
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
||||||
|
github_action_config.auto_review: "true"
|
||||||
|
github_action_config.auto_describe: "true"
|
||||||
|
github_action_config.auto_improve: "false"
|
||||||
|
|
||||||
|
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
||||||
|
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
||||||
|
|
||||||
|
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||||
|
github_action_config.enable_output: "true"
|
||||||
|
|
||||||
|
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
||||||
|
pr_description.generate_ai_title: "false"
|
||||||
|
pr_description.publish_labels: "false"
|
||||||
|
pr_description.enable_pr_diagram: "false"
|
||||||
|
pr_description.collapsible_file_list: "adaptive"
|
||||||
|
pr_description.add_original_user_description: "true"
|
||||||
|
|
||||||
|
# /review 输出策略,聚焦维护者需要处理的风险和缺口。
|
||||||
|
pr_reviewer.extra_instructions: |
|
||||||
|
请用中文输出。
|
||||||
|
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
||||||
|
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
|
||||||
|
pr_reviewer.num_max_findings: "5"
|
||||||
|
pr_reviewer.persistent_comment: "true"
|
||||||
|
pr_reviewer.publish_output_no_suggestions: "true"
|
||||||
|
pr_reviewer.require_tests_review: "true"
|
||||||
|
pr_reviewer.require_security_review: "true"
|
||||||
|
pr_reviewer.require_estimate_effort_to_review: "true"
|
||||||
|
pr_reviewer.require_can_be_split_review: "true"
|
||||||
|
pr_reviewer.require_todo_scan: "false"
|
||||||
|
pr_reviewer.enable_review_labels_effort: "false"
|
||||||
|
pr_reviewer.enable_review_labels_security: "true"
|
||||||
|
|
||||||
|
# /improve 和 /ask 的手动命令策略。
|
||||||
|
pr_code_suggestions.focus_only_on_problems: "true"
|
||||||
|
pr_code_suggestions.suggestions_score_threshold: "7"
|
||||||
|
pr_code_suggestions.commitable_code_suggestions: "false"
|
||||||
|
pr_questions.use_conversation_history: "true"
|
||||||
|
|
||||||
|
# 可选成本和噪音控制:
|
||||||
|
# github_action_config.auto_improve: "true"
|
||||||
|
# config.verbosity_level: "1"
|
||||||
|
# pr_reviewer.num_max_findings: "3"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,3 +37,4 @@ src/@iconify/*.js
|
|||||||
public/plugin_icon/**
|
public/plugin_icon/**
|
||||||
docs-lock/
|
docs-lock/
|
||||||
.trae/
|
.trae/
|
||||||
|
output/
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -11,15 +11,6 @@
|
|||||||
- 支持多语言(中文/英文)
|
- 支持多语言(中文/英文)
|
||||||
- 完整的插件系统支持,包括远程组件动态加载
|
- 完整的插件系统支持,包括远程组件动态加载
|
||||||
|
|
||||||
## 模块联邦功能
|
|
||||||
|
|
||||||
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
|
||||||
|
|
||||||
### 相关文档
|
|
||||||
|
|
||||||
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
|
||||||
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
|
||||||
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
|
||||||
|
|
||||||
## 开发部署
|
## 开发部署
|
||||||
|
|
||||||
@@ -58,3 +49,12 @@ yarn build
|
|||||||
```shell
|
```shell
|
||||||
node dist/service.js
|
node dist/service.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 模块联邦功能
|
||||||
|
|
||||||
|
MoviePilot 现已支持模块联邦(Module Federation)功能,允许插件开发者创建可动态加载的远程组件,实现更丰富的插件用户界面。
|
||||||
|
|
||||||
|
- [模块联邦开发指南](docs/module-federation-guide.md) - 如何开发远程组件插件
|
||||||
|
- [模块联邦问题排查指南](docs/federation-troubleshooting.md) - 常见问题和解决方案
|
||||||
|
- [插件远程组件示例](examples/plugin-component/) - 开发插件组件的完整示例项目
|
||||||
|
|||||||
16
README_EN.md
16
README_EN.md
@@ -11,15 +11,6 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
|
|||||||
- Multi-language support (Chinese/English)
|
- Multi-language support (Chinese/English)
|
||||||
- Complete plugin system with dynamic remote component loading
|
- Complete plugin system with dynamic remote component loading
|
||||||
|
|
||||||
## Module Federation
|
|
||||||
|
|
||||||
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
|
||||||
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Recommended IDE Setup
|
### Recommended IDE Setup
|
||||||
@@ -57,3 +48,10 @@ yarn build
|
|||||||
```shell
|
```shell
|
||||||
node dist/service.js
|
node dist/service.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Module Federation
|
||||||
|
|
||||||
|
MoviePilot now supports Module Federation, allowing plugin developers to create dynamically loadable remote components for richer plugin user interfaces.
|
||||||
|
|
||||||
|
- [Module Federation Troubleshooting Guide](docs/federation-troubleshooting.md) - Common issues and solutions
|
||||||
|
- [Plugin Remote Component Example](examples/plugin-component/) - Complete example project for developing plugin components
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||||
<meta name="viewport"
|
<meta name="viewport"
|
||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, interactive-widget=resizes-content" />
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
|
||||||
|
|
||||||
<!-- 防止缩放和选择,提供原生应用体验 -->
|
<!-- 防止缩放和选择,提供原生应用体验 -->
|
||||||
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
<meta name="format-detection" content="telephone=no, date=no, email=no, address=no" />
|
||||||
@@ -121,6 +121,12 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-launch-loading="true"] .footer-nav-container {
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
#loading-bg {
|
#loading-bg {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.13.6",
|
"version": "2.13.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ http {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /service-worker.js {
|
||||||
|
# Service Worker 必须保持稳定 URL 并每次重新验证,避免前端更新后继续注册旧版本。
|
||||||
|
expires off;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
root html;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
# Web App Manifest 参与 PWA 安装与资源发现,不能跟普通静态资源一起长缓存。
|
||||||
|
expires off;
|
||||||
|
default_type application/manifest+json;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
root html;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
# 静态资源
|
# 静态资源
|
||||||
expires 1y;
|
expires 1y;
|
||||||
@@ -44,8 +61,7 @@ http {
|
|||||||
|
|
||||||
location /assets {
|
location /assets {
|
||||||
# 静态资源
|
# 静态资源
|
||||||
expires 1y;
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
add_header Cache-Control "public";
|
|
||||||
root html;
|
root html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
scripts/check-season-label.ts
Normal file
12
scripts/check-season-label.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import { formatSeasonLabel } from '../src/@core/utils/season.ts'
|
||||||
|
|
||||||
|
assert.equal(formatSeasonLabel(0, '特别篇'), '特别篇')
|
||||||
|
assert.equal(formatSeasonLabel('0', 'Specials'), 'Specials')
|
||||||
|
assert.equal(formatSeasonLabel(1, '特别篇'), 'S01')
|
||||||
|
assert.equal(formatSeasonLabel('12', '特别篇'), 'S12')
|
||||||
|
assert.equal(formatSeasonLabel(null, '特别篇'), '')
|
||||||
|
assert.equal(formatSeasonLabel(undefined, '特别篇'), '')
|
||||||
|
|
||||||
|
console.log('season label checks passed')
|
||||||
15
src/@core/utils/season.ts
Normal file
15
src/@core/utils/season.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 格式化用户可见的季标签。
|
||||||
|
*
|
||||||
|
* TMDB 使用季号 0 表示特别季;调用方传入当前语言的特别季名称,
|
||||||
|
* 其余季号保持 MoviePilot 现有的 Sxx 展示口径。
|
||||||
|
*/
|
||||||
|
export function formatSeasonLabel(
|
||||||
|
season: number | string | null | undefined,
|
||||||
|
specialsLabel: string,
|
||||||
|
): string {
|
||||||
|
if (season === null || season === undefined || season === '') return ''
|
||||||
|
if (Number(season) === 0) return specialsLabel
|
||||||
|
|
||||||
|
return `S${String(season).padStart(2, '0')}`
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Transition } from 'vue'
|
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||||
import {
|
import {
|
||||||
@@ -110,9 +109,7 @@ export default defineComponent({
|
|||||||
const main = h(
|
const main = h(
|
||||||
'main',
|
'main',
|
||||||
{ class: 'layout-page-content' },
|
{ class: 'layout-page-content' },
|
||||||
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true }, () =>
|
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||||
h('section', { class: 'page-content-container' }, slots.default?.()),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 👉 根据路由 meta 决定 footer 高度
|
// 👉 根据路由 meta 决定 footer 高度
|
||||||
@@ -173,6 +170,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-wrapper.layout-nav-type-vertical {
|
.layout-wrapper.layout-nav-type-vertical {
|
||||||
|
--layout-navbar-block-size: calc(
|
||||||
|
env(safe-area-inset-top, 0px) + #{variables.$layout-vertical-nav-navbar-height} + var(--navbar-tab-height)
|
||||||
|
);
|
||||||
|
|
||||||
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
// TODO(v2): Check why we need height in vertical nav & min-height in horizontal nav
|
||||||
min-block-size: 100%;
|
min-block-size: 100%;
|
||||||
|
|
||||||
@@ -188,13 +189,16 @@ export default defineComponent({
|
|||||||
.layout-navbar {
|
.layout-navbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
z-index: variables.$layout-vertical-nav-layout-navbar-z-index;
|
||||||
|
// iOS Safari 在地址栏收起和惯性滚动时可能把 fixed 顶栏和页面滚动层合成到一起,
|
||||||
|
// 单独提升顶栏图层可避免导航栏短暂上移到安全区下方。
|
||||||
|
backface-visibility: hidden;
|
||||||
|
block-size: var(--layout-navbar-block-size);
|
||||||
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
inline-size: calc(100vw - variables.$layout-vertical-nav-width - 0.5rem);
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
|
||||||
.navbar-content-container {
|
.navbar-content-container {
|
||||||
block-size: calc(
|
block-size: var(--layout-navbar-block-size);
|
||||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@at-root {
|
@at-root {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ body {
|
|||||||
background: rgb(var(--v-theme-background));
|
background: rgb(var(--v-theme-background));
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
|
|
||||||
--webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
|
|||||||
2
src/@layouts/types.d.ts
vendored
2
src/@layouts/types.d.ts
vendored
@@ -1,5 +1,6 @@
|
|||||||
import type { Component, Ref, VNode } from 'vue'
|
import type { Component, Ref, VNode } from 'vue'
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||||
|
|
||||||
export interface UserConfig {
|
export interface UserConfig {
|
||||||
@@ -119,6 +120,7 @@ export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
|||||||
badgeContent?: string
|
badgeContent?: string
|
||||||
badgeClass?: string
|
badgeClass?: string
|
||||||
disable?: boolean
|
disable?: boolean
|
||||||
|
permission?: UserPermissionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavMenuTabItem {
|
export interface NavMenuTabItem {
|
||||||
|
|||||||
45
src/App.vue
45
src/App.vue
@@ -9,9 +9,11 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
|||||||
import { preloadImage } from './@core/utils/image'
|
import { preloadImage } from './@core/utils/image'
|
||||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||||
import PWAInstallPrompt from '@/components/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||||
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
import { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||||
|
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||||
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { themeManager } from '@/utils/themeManager'
|
import { themeManager } from '@/utils/themeManager'
|
||||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||||
@@ -45,6 +47,7 @@ setI18nLanguage(localeValue as SupportedLocale)
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isLogin = computed(() => authStore.token)
|
const isLogin = computed(() => authStore.token)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { initializePWA } = usePWA()
|
||||||
|
|
||||||
// 全局设置store
|
// 全局设置store
|
||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
@@ -56,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
|||||||
const backgroundImages = ref<string[]>([])
|
const backgroundImages = ref<string[]>([])
|
||||||
const activeImageIndex = ref(0)
|
const activeImageIndex = ref(0)
|
||||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||||
|
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||||
const shouldLoadBackgroundImages = computed(
|
const shouldLoadBackgroundImages = computed(
|
||||||
() => (!isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE) || (Boolean(isLogin.value) && isTransparentTheme.value),
|
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||||
)
|
)
|
||||||
let backgroundRetryTimer: number | null = null
|
let backgroundRetryTimer: number | null = null
|
||||||
let backgroundRequestController: AbortController | null = null
|
let backgroundRequestController: AbortController | null = null
|
||||||
@@ -98,7 +102,7 @@ const startHeartbeat = () => {
|
|||||||
heartbeatInterval = window.setInterval(async () => {
|
heartbeatInterval = window.setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
if (isLogin.value) {
|
if (isLogin.value) {
|
||||||
await api.get('dashboard/cpu')
|
await api.get('system/ping')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Heartbeat request failed:', error)
|
console.warn('Heartbeat request failed:', error)
|
||||||
@@ -245,19 +249,25 @@ function scheduleAuthenticatedStateInitialization() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加logo动画效果并延迟移除加载界面
|
// 添加logo动画效果并延迟移除加载界面
|
||||||
function animateAndRemoveLoader() {
|
async function animateAndRemoveLoader() {
|
||||||
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
const loadingBg = document.querySelector('#loading-bg') as HTMLElement
|
||||||
if (loadingBg) {
|
if (loadingBg) {
|
||||||
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
// 只收掉启动内容,背景层保持实色直到节点被移除,避免底部 safe area 先透出页面内容。
|
||||||
loadingBg.classList.add('loading-complete')
|
loadingBg.classList.add('loading-complete')
|
||||||
window.setTimeout(() => {
|
await new Promise<void>(resolve => {
|
||||||
removeEl('#loading-bg')
|
window.setTimeout(() => {
|
||||||
|
removeEl('#loading-bg')
|
||||||
|
|
||||||
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
// 启动阶段的根节点锁定只在 loader 存在时生效,移除后恢复正常页面与弹窗布局。
|
||||||
document.documentElement.removeAttribute('data-launch-loading')
|
document.documentElement.removeAttribute('data-launch-loading')
|
||||||
document.documentElement.style.removeProperty('overflow')
|
document.documentElement.style.removeProperty('overflow')
|
||||||
document.body.style.removeProperty('overflow')
|
document.body.style.removeProperty('overflow')
|
||||||
}, 120)
|
completeLaunchLoading()
|
||||||
|
resolve()
|
||||||
|
}, 120)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
completeLaunchLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,13 +284,15 @@ async function removeLoadingWithStateCheck() {
|
|||||||
}
|
}
|
||||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||||
|
|
||||||
|
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||||
|
await initializePWA()
|
||||||
await initializeAuthenticatedState()
|
await initializeAuthenticatedState()
|
||||||
|
|
||||||
// 等待所有加载完成
|
// 等待所有加载完成
|
||||||
await globalLoadingStateManager.waitForAllComplete()
|
await globalLoadingStateManager.waitForAllComplete()
|
||||||
|
|
||||||
// 移除加载界面
|
// 移除加载界面
|
||||||
animateAndRemoveLoader()
|
await animateAndRemoveLoader()
|
||||||
|
|
||||||
// 检查未读消息
|
// 检查未读消息
|
||||||
if (isLogin.value) {
|
if (isLogin.value) {
|
||||||
@@ -289,7 +301,7 @@ async function removeLoadingWithStateCheck() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 即使出错也要移除加载界面
|
// 即使出错也要移除加载界面
|
||||||
globalLoadingStateManager.reset()
|
globalLoadingStateManager.reset()
|
||||||
animateAndRemoveLoader()
|
await animateAndRemoveLoader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +435,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<VApp>
|
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||||
<SharedDialogHost />
|
<SharedDialogHost />
|
||||||
@@ -493,4 +505,9 @@ onUnmounted(() => {
|
|||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
inset-inline-start: 0;
|
inset-inline-start: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||||
|
.app-shell--login-wallpaper.v-application {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
124
src/api/types.ts
124
src/api/types.ts
@@ -49,7 +49,7 @@ export interface Subscribe {
|
|||||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||||
completed_episode?: number
|
completed_episode?: number
|
||||||
// 附加信息
|
// 附加信息
|
||||||
note?: string
|
note?: string | number[]
|
||||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||||
state: string
|
state: string
|
||||||
// 最后更新时间
|
// 最后更新时间
|
||||||
@@ -656,6 +656,8 @@ export interface Plugin {
|
|||||||
system_version_message?: string
|
system_version_message?: string
|
||||||
// 主系统版本限定范围
|
// 主系统版本限定范围
|
||||||
system_version?: string
|
system_version?: string
|
||||||
|
// 是否声明支持通过 GitHub Release 资产安装
|
||||||
|
release?: boolean
|
||||||
// 是否本地插件
|
// 是否本地插件
|
||||||
is_local?: boolean
|
is_local?: boolean
|
||||||
// 插件仓库地址
|
// 插件仓库地址
|
||||||
@@ -668,6 +670,38 @@ export interface Plugin {
|
|||||||
page_open?: boolean
|
page_open?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 插件 Release 可安装版本
|
||||||
|
export interface PluginReleaseVersion {
|
||||||
|
// 插件版本
|
||||||
|
version: string
|
||||||
|
// GitHub Release tag
|
||||||
|
tag_name: string
|
||||||
|
// Release 标题
|
||||||
|
name?: string
|
||||||
|
// 发布时间
|
||||||
|
published_at?: string
|
||||||
|
// Release 说明
|
||||||
|
body?: string
|
||||||
|
// 匹配到的资产文件名
|
||||||
|
asset_name?: string
|
||||||
|
// 是否为当前市场最新版本
|
||||||
|
is_latest?: boolean
|
||||||
|
// 是否为本地已安装版本
|
||||||
|
is_current?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插件 Release 可安装版本响应
|
||||||
|
export interface PluginReleaseVersionsResponse {
|
||||||
|
// 当前插件是否存在可直接安装的 Release 资产
|
||||||
|
release_supported: boolean
|
||||||
|
// 当前市场 package 声明的最新版本
|
||||||
|
latest_version?: string | null
|
||||||
|
// 本地已安装版本
|
||||||
|
current_version?: string | null
|
||||||
|
// 可安装版本列表
|
||||||
|
items: PluginReleaseVersion[]
|
||||||
|
}
|
||||||
|
|
||||||
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
// 插件侧栏全页导航项(与后端 PluginSidebarNavItem 对齐)
|
||||||
export interface PluginSidebarNavItem {
|
export interface PluginSidebarNavItem {
|
||||||
plugin_id: string
|
plugin_id: string
|
||||||
@@ -768,6 +802,58 @@ export interface TorrentInfo {
|
|||||||
category: string
|
category: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字幕信息
|
||||||
|
export interface SubtitleInfo {
|
||||||
|
// 站点ID
|
||||||
|
site?: number
|
||||||
|
// 站点名称
|
||||||
|
site_name?: string
|
||||||
|
// 站点Cookie
|
||||||
|
site_cookie?: string
|
||||||
|
// 站点UA
|
||||||
|
site_ua?: string
|
||||||
|
// 站点是否使用代理
|
||||||
|
site_proxy?: boolean
|
||||||
|
// 站点优先级
|
||||||
|
site_order?: number
|
||||||
|
// 字幕标题
|
||||||
|
title?: string
|
||||||
|
// 字幕描述
|
||||||
|
description?: string
|
||||||
|
// 字幕下载链接
|
||||||
|
enclosure?: string
|
||||||
|
// 详情页面
|
||||||
|
page_url?: string
|
||||||
|
// 语言
|
||||||
|
language?: string
|
||||||
|
// 语言图标
|
||||||
|
language_icon?: string
|
||||||
|
// 字幕大小
|
||||||
|
size?: number
|
||||||
|
// 发布时间
|
||||||
|
pubdate?: string
|
||||||
|
// 已过时间
|
||||||
|
date_elapsed?: string
|
||||||
|
// 点击/下载次数
|
||||||
|
grabs?: number
|
||||||
|
// 上传者
|
||||||
|
uploader?: string
|
||||||
|
// 举报页面
|
||||||
|
report_url?: string
|
||||||
|
// 种子ID
|
||||||
|
torrent_id?: string
|
||||||
|
// 字幕ID
|
||||||
|
subtitle_id?: string
|
||||||
|
// 下载文件名
|
||||||
|
file_name?: string
|
||||||
|
// 识别元数据
|
||||||
|
meta_info?: MetaInfo
|
||||||
|
// SxxExx
|
||||||
|
season_episode?: string
|
||||||
|
// 集列表
|
||||||
|
episode_list?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
// 识别元数据
|
// 识别元数据
|
||||||
export interface MetaInfo {
|
export interface MetaInfo {
|
||||||
// 是否处理的文件
|
// 是否处理的文件
|
||||||
@@ -1079,6 +1165,12 @@ export interface MediaServerLibrary {
|
|||||||
|
|
||||||
// 消息通知
|
// 消息通知
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
// 消息ID
|
||||||
|
id?: number
|
||||||
|
// 消息渠道
|
||||||
|
channel?: string
|
||||||
|
// 消息来源
|
||||||
|
source?: string
|
||||||
// 消息类型
|
// 消息类型
|
||||||
mtype?: string
|
mtype?: string
|
||||||
// 消息标题
|
// 消息标题
|
||||||
@@ -1098,19 +1190,15 @@ export interface Message {
|
|||||||
// 消息方向:0-接收,1-发送
|
// 消息方向:0-接收,1-发送
|
||||||
action?: number
|
action?: number
|
||||||
// JSON
|
// JSON
|
||||||
note?: string
|
note?: string | any[] | Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统通知
|
// 系统通知
|
||||||
export interface SystemNotification {
|
export interface SystemNotification extends Message {
|
||||||
// 通知类型 user/system/plugin
|
// 通知类型 user/system/plugin/notification
|
||||||
type: string
|
type?: string
|
||||||
// 通知标题
|
|
||||||
title: string
|
|
||||||
// 通知内容
|
|
||||||
text: string
|
|
||||||
// 通知时间
|
// 通知时间
|
||||||
date: string
|
date?: string
|
||||||
// 是否已读
|
// 是否已读
|
||||||
read?: boolean
|
read?: boolean
|
||||||
}
|
}
|
||||||
@@ -1300,7 +1388,7 @@ export interface TransferForm {
|
|||||||
// 历史ID
|
// 历史ID
|
||||||
logid: number
|
logid: number
|
||||||
// 目标存储
|
// 目标存储
|
||||||
target_storage: string
|
target_storage: string | null
|
||||||
// 目标路径
|
// 目标路径
|
||||||
target_path: string | null
|
target_path: string | null
|
||||||
// TMDB ID
|
// TMDB ID
|
||||||
@@ -1312,7 +1400,7 @@ export interface TransferForm {
|
|||||||
// 类型
|
// 类型
|
||||||
type_name?: string
|
type_name?: string
|
||||||
// 整理方式
|
// 整理方式
|
||||||
transfer_type: string
|
transfer_type: string | null
|
||||||
// 自定义格式
|
// 自定义格式
|
||||||
episode_format?: string
|
episode_format?: string
|
||||||
// 指定集数
|
// 指定集数
|
||||||
@@ -1324,13 +1412,13 @@ export interface TransferForm {
|
|||||||
// 最小文件大小
|
// 最小文件大小
|
||||||
min_filesize: number
|
min_filesize: number
|
||||||
// 刮削
|
// 刮削
|
||||||
scrape: boolean
|
scrape: boolean | null
|
||||||
// 复用历史识别信息
|
// 复用历史识别信息
|
||||||
from_history: boolean
|
from_history: boolean
|
||||||
// 媒体库类型子目录
|
// 媒体库类型子目录
|
||||||
library_type_folder?: boolean
|
library_type_folder?: boolean | null
|
||||||
// 媒体库类别子目录
|
// 媒体库类别子目录
|
||||||
library_category_folder?: boolean
|
library_category_folder?: boolean | null
|
||||||
// 剧集组编号
|
// 剧集组编号
|
||||||
episode_group?: string | null
|
episode_group?: string | null
|
||||||
// 预览模式
|
// 预览模式
|
||||||
@@ -1354,11 +1442,11 @@ export interface ManualTransferTargetPathData {
|
|||||||
// 整理方式
|
// 整理方式
|
||||||
transfer_type?: string | null
|
transfer_type?: string | null
|
||||||
// 刮削
|
// 刮削
|
||||||
scrape?: boolean
|
scrape?: boolean | null
|
||||||
// 媒体库类型子目录
|
// 媒体库类型子目录
|
||||||
library_type_folder?: boolean
|
library_type_folder?: boolean | null
|
||||||
// 媒体库类别子目录
|
// 媒体库类别子目录
|
||||||
library_category_folder?: boolean
|
library_category_folder?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 手动整理预览统计
|
// 手动整理预览统计
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2385
src/components/agent/AgentAssistantEntry.vue
Normal file
2385
src/components/agent/AgentAssistantEntry.vue
Normal file
File diff suppressed because it is too large
Load Diff
2654
src/components/agent/AgentAssistantPanel.vue
Normal file
2654
src/components/agent/AgentAssistantPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
26
src/components/agent/AgentAssistantWidget.vue
Normal file
26
src/components/agent/AgentAssistantWidget.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||||
|
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||||
|
|
||||||
|
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||||
|
|
||||||
|
const panelOpen = ref(false)
|
||||||
|
const thinking = ref(false)
|
||||||
|
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
panelOpen.value = true
|
||||||
|
entryRef.value?.clearBubbles()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssistantPreview(value: string) {
|
||||||
|
if (panelOpen.value) return
|
||||||
|
|
||||||
|
entryRef.value?.showAssistantReplyPreview(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AgentAssistantEntry ref="entryRef" :active="!panelOpen" :thinking="thinking" @open="openPanel" />
|
||||||
|
<AgentAssistantPanel v-model="panelOpen" @assistant-preview="handleAssistantPreview" @thinking-change="thinking = $event" />
|
||||||
|
</template>
|
||||||
@@ -10,7 +10,7 @@ import router from '@/router'
|
|||||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mediaTypeDict } from '@/api/constants'
|
import { mediaTypeDict } from '@/api/constants'
|
||||||
import { hasPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
import {
|
import {
|
||||||
getCachedMediaExistsStatus,
|
getCachedMediaExistsStatus,
|
||||||
@@ -45,6 +45,9 @@ const globalSettings = globalSettingsStore.globalSettings
|
|||||||
|
|
||||||
// 用户 Store
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
|
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||||
|
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -143,7 +146,7 @@ async function querySites() {
|
|||||||
// 查询用户选中的站点
|
// 查询用户选中的站点
|
||||||
async function querySelectedSites() {
|
async function querySelectedSites() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
|
const result: { [key: string]: any } = await api.get('system/setting/public/IndexerSites')
|
||||||
selectedSites.value = result.data?.value ?? []
|
selectedSites.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -336,12 +339,11 @@ async function checkSubscribe(season: number | null) {
|
|||||||
|
|
||||||
// 查询订阅弹窗规则
|
// 查询订阅弹窗规则
|
||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
// 非管理员不显示
|
if (!canSubscribe.value) return false
|
||||||
if (!userStore.superUser) return false
|
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -491,14 +493,14 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<div ref="mediaCardRef">
|
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||||
|
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
|
||||||
<VCard
|
<VCard
|
||||||
v-bind="hover.props"
|
|
||||||
:height="props.height"
|
:height="props.height"
|
||||||
:width="props.width"
|
:width="props.width"
|
||||||
class="outline-none ring-gray-500 media-card"
|
class="outline-none ring-gray-500 media-card"
|
||||||
:class="{
|
:class="{
|
||||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||||
'ring-1': isImageLoaded,
|
'ring-1': isImageLoaded,
|
||||||
}"
|
}"
|
||||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||||
@@ -534,7 +536,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||||
<div v-else class="flex align-center justify-between">
|
<div v-else class="flex align-center justify-between">
|
||||||
<IconBtn
|
<IconBtn
|
||||||
v-if="hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')"
|
v-if="canSearch"
|
||||||
icon="mdi-magnify"
|
icon="mdi-magnify"
|
||||||
color="white"
|
color="white"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -542,6 +544,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<IconBtn
|
<IconBtn
|
||||||
|
v-if="canSubscribe"
|
||||||
icon="mdi-heart"
|
icon="mdi-heart"
|
||||||
:color="isSubscribed ? 'error' : 'white'"
|
:color="isSubscribed ? 'error' : 'white'"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -574,6 +577,7 @@ onBeforeUnmount(() => {
|
|||||||
<!--来源图标-->
|
<!--来源图标-->
|
||||||
<VAvatar
|
<VAvatar
|
||||||
size="24"
|
size="24"
|
||||||
|
variant="plain"
|
||||||
density="compact"
|
density="compact"
|
||||||
class="absolute bottom-1 right-1"
|
class="absolute bottom-1 right-1"
|
||||||
tile
|
tile
|
||||||
@@ -587,6 +591,10 @@ onBeforeUnmount(() => {
|
|||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.media-card-hover-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.media-card-title {
|
.media-card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import MarkdownIt from 'markdown-it'
|
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
|
||||||
import type { Message } from '@/api/types'
|
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
|
||||||
|
|
||||||
// 输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
message: Object as PropType<Message>,
|
|
||||||
width: String,
|
|
||||||
height: String,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定义事件
|
|
||||||
const emit = defineEmits(['imageload'])
|
|
||||||
|
|
||||||
// 图片是否加载完成
|
|
||||||
const isImageLoaded = ref(false)
|
|
||||||
|
|
||||||
// 图片是否加载失败
|
|
||||||
const imageLoadError = ref(false)
|
|
||||||
|
|
||||||
// 初始化 markdown-it
|
|
||||||
const md = new MarkdownIt({
|
|
||||||
html: true,
|
|
||||||
breaks: true,
|
|
||||||
linkify: true,
|
|
||||||
typographer: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 插件:链接在新窗口打开
|
|
||||||
md.use(mdLinkAttributes, {
|
|
||||||
attrs: {
|
|
||||||
target: '_blank',
|
|
||||||
rel: 'noopener noreferrer',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 图片加载完成
|
|
||||||
async function imageLoaded() {
|
|
||||||
isImageLoaded.value = true
|
|
||||||
emit('imageload')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 链接打开新窗口
|
|
||||||
function openLink() {
|
|
||||||
if (props.message?.link) window.open(props.message.link, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将note转换为json
|
|
||||||
function noteToJson() {
|
|
||||||
if (props.message?.note) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(props.message.note)
|
|
||||||
} catch (error) {
|
|
||||||
return props.message.note
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染 Markdown
|
|
||||||
function renderMarkdown(value: string) {
|
|
||||||
if (!value) return ''
|
|
||||||
return md.render(value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VCard variant="tonal" :width="props.width" :height="props.height" @click="openLink" max-width="23rem">
|
|
||||||
<div v-if="props.message?.image" class="relative text-center card-cover-blurred">
|
|
||||||
<VImg
|
|
||||||
:src="props.message?.image"
|
|
||||||
aspect-ratio="3/2"
|
|
||||||
cover
|
|
||||||
position="top"
|
|
||||||
@load="imageLoaded"
|
|
||||||
@error="imageLoadError = true"
|
|
||||||
min-height="10rem"
|
|
||||||
>
|
|
||||||
<template #placeholder>
|
|
||||||
<div class="w-full h-full">
|
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VImg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
props.message?.title &&
|
|
||||||
!props.message?.text &&
|
|
||||||
!props.message?.image &&
|
|
||||||
isNullOrEmptyObject(props.message?.note) &&
|
|
||||||
props.message?.action === 0
|
|
||||||
"
|
|
||||||
class="rounded-md text-body-1 py-2 px-4 elevation-2 bg-primary text-white chat-right mb-1"
|
|
||||||
>
|
|
||||||
<p class="mb-0">{{ props.message?.title }}</p>
|
|
||||||
</div>
|
|
||||||
<VCardTitle v-else-if="props.message?.title" class="break-words whitespace-break-spaces">
|
|
||||||
{{ props.message?.title }}
|
|
||||||
</VCardTitle>
|
|
||||||
<div
|
|
||||||
v-if="props.message?.text && props.message?.action === 0"
|
|
||||||
class="rounded-md text-body-1 py-1 px-4 elevation-2 bg-primary text-white chat-right"
|
|
||||||
>
|
|
||||||
<div class="markdown-body" v-html="renderMarkdown(props.message?.text)" />
|
|
||||||
</div>
|
|
||||||
<VCardText
|
|
||||||
v-if="props.message?.text && props.message?.action === 1"
|
|
||||||
class="markdown-body"
|
|
||||||
v-html="renderMarkdown(props.message?.text)"
|
|
||||||
/>
|
|
||||||
<VCardText v-if="!isNullOrEmptyObject(props.message?.note)">
|
|
||||||
<VList>
|
|
||||||
<VListItem v-for="(value, key) in noteToJson()" :key="key" two-line>
|
|
||||||
<VListItemTitle v-if="value.title_year" class="font-bold break-words whitespace-break-spaces">
|
|
||||||
{{ Number(key) + 1 }}. {{ value.title_year }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemTitle v-if="value.enclosure" class="font-bold break-words whitespace-break-spaces">
|
|
||||||
{{ Number(key) + 1 }}. {{ value.title }} {{ value.volume_factor }} ↑{{ value.seeders }}
|
|
||||||
</VListItemTitle>
|
|
||||||
<VListItemSubtitle v-if="value.type">
|
|
||||||
类型:{{ value.type }} 评分:{{ value.vote_average }}
|
|
||||||
</VListItemSubtitle>
|
|
||||||
<VListItemSubtitle v-if="value.enclosure" class="whitespace-break-spaces">
|
|
||||||
{{ value.description }}
|
|
||||||
</VListItemSubtitle>
|
|
||||||
</VListItem>
|
|
||||||
</VList>
|
|
||||||
</VCardText>
|
|
||||||
</VCard>
|
|
||||||
<div class="text-end">
|
|
||||||
<span v-if="props.message?.action === 0" class="text-sm italic me-2">{{ props.message?.userid }}</span>
|
|
||||||
<span class="text-sm italic me-2">{{
|
|
||||||
formatDateDifference(props.message?.reg_time || props.message?.date || '')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.markdown-body {
|
|
||||||
word-break: break-all;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p:last-child {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style-type: disc;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol {
|
|
||||||
list-style-type: decimal;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: list-item;
|
|
||||||
margin-block-end: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: rgba(var(--v-border-color), 0.1);
|
|
||||||
font-family: monospace;
|
|
||||||
padding-block: 0.2rem;
|
|
||||||
padding-inline: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
overflow: auto;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background-color: rgba(var(--v-border-color), 0.1);
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
|
|
||||||
code {
|
|
||||||
padding: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
border-inline-start: 4px solid rgba(var(--v-border-color), 0.2);
|
|
||||||
font-style: italic;
|
|
||||||
margin-block-end: 0.5rem;
|
|
||||||
padding-inline-start: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
inline-size: 100%;
|
|
||||||
margin-block-end: 1rem;
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid rgba(var(--v-border-color), 0.1);
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: rgba(var(--v-border-color), 0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
block-size: auto;
|
|
||||||
max-inline-size: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import api from '@/api'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getDominantColor } from '@/@core/utils/image'
|
import { getDominantColor } from '@/@core/utils/image'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
|
||||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||||
)
|
)
|
||||||
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
|
|||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 背景颜色
|
// 背景颜色
|
||||||
const backgroundColor = ref('#28A9E1')
|
const backgroundColor = ref('#28A9E1')
|
||||||
|
|
||||||
@@ -48,6 +57,21 @@ const isImageLoaded = ref(false)
|
|||||||
// 图片是否加载失败
|
// 图片是否加载失败
|
||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
|
/** 打开插件安装进度弹窗。 */
|
||||||
|
function showInstallProgress(text: string) {
|
||||||
|
progressDialogController?.close()
|
||||||
|
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭插件安装进度弹窗。 */
|
||||||
|
function closeInstallProgress() {
|
||||||
|
progressDialogController?.close()
|
||||||
|
progressDialogController = null
|
||||||
|
}
|
||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
isImageLoaded.value = true
|
||||||
@@ -96,14 +120,69 @@ function visitPluginPage() {
|
|||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
function showUpdateHistory() {
|
function showUpdateHistory() {
|
||||||
openSharedDialog(
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin },
|
{ plugin: props.plugin, actionMode: 'install' },
|
||||||
{},
|
{
|
||||||
|
update: installPlugin,
|
||||||
|
},
|
||||||
{ closeOn: ['close', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从插件市场版本历史安装指定 Release;最新版本走普通安装路径以保留主程序兼容校验。 */
|
||||||
|
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||||
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseVersion) {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: t('common.confirm'),
|
||||||
|
content: t('plugin.confirmInstallOldRelease', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
version: releaseVersion,
|
||||||
|
}),
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isConfirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showInstallProgress(
|
||||||
|
t('plugin.installing', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
version: releaseVersion || props.plugin?.plugin_version,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
|
params: {
|
||||||
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
|
release_version: releaseVersion,
|
||||||
|
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
closeInstallProgress()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
emit('install')
|
||||||
|
} else {
|
||||||
|
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
closeInstallProgress()
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开共享插件市场详情弹窗。 */
|
/** 打开共享插件市场详情弹窗。 */
|
||||||
function showPluginDetail() {
|
function showPluginDetail() {
|
||||||
openSharedDialog(
|
openSharedDialog(
|
||||||
@@ -140,6 +219,11 @@ const dropdownItems = ref([
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
closeInstallProgress()
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -177,14 +261,14 @@ const dropdownItems = ref([
|
|||||||
{{ props.plugin?.plugin_desc }}
|
{{ props.plugin?.plugin_desc }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 插件标签 -->
|
<!-- 插件标签 -->
|
||||||
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2">
|
<div v-if="pluginLabels.length > 0" class="plugin-app-card__tags-section px-2 mb-2">
|
||||||
<VChip
|
<VChip
|
||||||
v-for="tag in pluginLabels"
|
v-for="tag in pluginLabels"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="info"
|
color="info"
|
||||||
class="me-1 mb-1"
|
class="plugin-app-card__tag"
|
||||||
tile
|
tile
|
||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
@@ -246,3 +330,25 @@ const dropdownItems = ref([
|
|||||||
</VHover>
|
</VHover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-app-card__tags-section {
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-app-card__tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-inline-size: 100%;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-app-card__tag :deep(.v-chip__content) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
|
|||||||
|
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||||
function showPluginProgress(text: string) {
|
function showPluginProgress(text: string) {
|
||||||
@@ -103,11 +104,12 @@ async function imageLoaded() {
|
|||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||||
openSharedDialog(
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = openSharedDialog(
|
||||||
PluginVersionHistoryDialog,
|
PluginVersionHistoryDialog,
|
||||||
{ plugin: props.plugin, showUpdateAction },
|
{ plugin: props.plugin, showUpdateAction },
|
||||||
{ update: updatePlugin },
|
{ update: updatePlugin },
|
||||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,19 +221,37 @@ async function resetPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 更新插件
|
// 更新插件
|
||||||
async function updatePlugin() {
|
async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||||
if (props.plugin?.system_version_compatible === false) {
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (releaseVersion) {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: t('common.confirm'),
|
||||||
|
content: t('plugin.confirmInstallOldRelease', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
version: releaseVersion,
|
||||||
|
}),
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isConfirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 显示等待提示框
|
// 显示等待提示框
|
||||||
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))
|
showPluginProgress(
|
||||||
|
releaseVersion
|
||||||
|
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||||
|
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||||
|
)
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
|
release_version: releaseVersion,
|
||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -241,6 +261,8 @@ async function updatePlugin() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
|
|
||||||
// 通知父组件刷新
|
// 通知父组件刷新
|
||||||
emit('save')
|
emit('save')
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { formatDateDifference, formatSeason } from '@/@core/utils/formatters'
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
|
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Subscribe } from '@/api/types'
|
import type { Subscribe } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
@@ -408,7 +409,6 @@ function handleCardClick() {
|
|||||||
:class="{
|
:class="{
|
||||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<VCard
|
<VCard
|
||||||
@@ -417,6 +417,7 @@ function handleCardClick() {
|
|||||||
class="flex flex-col h-full overflow-hidden"
|
class="flex flex-col h-full overflow-hidden"
|
||||||
:class="{
|
:class="{
|
||||||
'subscribe-card-paused': subscribeState === 'S',
|
'subscribe-card-paused': subscribeState === 'S',
|
||||||
|
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||||
'cursor-move': props.sortable,
|
'cursor-move': props.sortable,
|
||||||
}"
|
}"
|
||||||
min-height="150"
|
min-height="150"
|
||||||
@@ -478,7 +479,7 @@ function handleCardClick() {
|
|||||||
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
<div class="text-sm font-medium text-white sm:pt-1">{{ props.media?.year }}</div>
|
||||||
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
<div class="mr-2 min-w-0 text-lg font-bold text-white text-ellipsis overflow-hidden line-clamp-2 ...">
|
||||||
{{ props.media?.name }}
|
{{ props.media?.name }}
|
||||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
@@ -587,7 +588,7 @@ function handleCardClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||||
*/
|
*/
|
||||||
.subscribe-card-pending-tint {
|
.subscribe-card-pending-tint {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
213
src/components/cards/SubtitleCard.vue
Normal file
213
src/components/cards/SubtitleCard.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import api from '@/api'
|
||||||
|
import type { SubtitleInfo } from '@/api/types'
|
||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
|
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||||
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||||
|
|
||||||
|
// 多语言支持
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
subtitle: Object as PropType<SubtitleInfo>,
|
||||||
|
width: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字幕信息
|
||||||
|
const subtitle = ref(props.subtitle)
|
||||||
|
|
||||||
|
// 站点图标
|
||||||
|
const siteIcon = ref('')
|
||||||
|
|
||||||
|
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||||
|
|
||||||
|
// 查询站点图标
|
||||||
|
async function getSiteIcon() {
|
||||||
|
if (!subtitle.value?.site) {
|
||||||
|
siteIcon.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||||
|
|
||||||
|
return response?.data?.icon || ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
siteIcon.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加字幕下载成功
|
||||||
|
function addDownloadSuccess(url: string) {
|
||||||
|
markSubtitleDownloaded(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加字幕下载失败
|
||||||
|
function addDownloadError(error: string) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 询问并下载字幕
|
||||||
|
async function handleAddDownload() {
|
||||||
|
openSharedDialog(
|
||||||
|
AddSubtitleDownloadDialog,
|
||||||
|
{
|
||||||
|
title: subtitle.value?.title,
|
||||||
|
subtitle: subtitle.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
done: addDownloadSuccess,
|
||||||
|
error: addDownloadError,
|
||||||
|
},
|
||||||
|
{ closeOn: ['close', 'done', 'error'] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开字幕详情页面
|
||||||
|
function openSubtitleDetail() {
|
||||||
|
if (!subtitle.value?.page_url) return
|
||||||
|
window.open(subtitle.value.page_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开字幕举报页面
|
||||||
|
function openReportPage() {
|
||||||
|
if (!subtitle.value?.report_url) return
|
||||||
|
window.open(subtitle.value.report_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.subtitle,
|
||||||
|
value => {
|
||||||
|
subtitle.value = value
|
||||||
|
getSiteIcon()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-full">
|
||||||
|
<VCard
|
||||||
|
:width="props.width || '100%'"
|
||||||
|
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||||
|
@click="handleAddDownload"
|
||||||
|
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||||
|
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||||
|
hover
|
||||||
|
>
|
||||||
|
<VCardItem class="pt-3 pb-0">
|
||||||
|
<div class="d-flex justify-space-between align-center flex-wrap gap-2 mb-2">
|
||||||
|
<div class="d-flex align-center min-w-0">
|
||||||
|
<VImg
|
||||||
|
v-if="siteIcon"
|
||||||
|
:src="siteIcon"
|
||||||
|
:alt="subtitle?.site_name"
|
||||||
|
class="mr-2 rounded"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
/>
|
||||||
|
<VAvatar v-else size="20" class="mr-2 text-caption bg-surface-variant" color="surface-variant">
|
||||||
|
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||||
|
</VAvatar>
|
||||||
|
<span class="font-weight-bold text-body-2 text-truncate">{{ subtitle?.site_name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-center gap-2">
|
||||||
|
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||||
|
{{ subtitle.season_episode }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||||
|
<VImg
|
||||||
|
v-if="subtitle?.language_icon"
|
||||||
|
:src="subtitle.language_icon"
|
||||||
|
:alt="subtitle.language"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ subtitle.language }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||||
|
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardItem>
|
||||||
|
|
||||||
|
<VCardText class="d-flex flex-column flex-grow-1 pa-3 overflow-hidden">
|
||||||
|
<div class="text-subtitle-2 text-high-emphasis font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||||
|
{{ subtitle?.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="subtitle?.description"
|
||||||
|
class="text-body-2 text-medium-emphasis mb-2 break-all"
|
||||||
|
:title="subtitle?.description"
|
||||||
|
>
|
||||||
|
{{ subtitle.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-center gap-2 mb-2">
|
||||||
|
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle.grabs }}
|
||||||
|
</span>
|
||||||
|
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle.uploader }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
<VChip v-if="subtitle?.torrent_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||||
|
TID {{ subtitle.torrent_id }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="subtitle?.subtitle_id" size="x-small" variant="tonal" class="rounded-sm">
|
||||||
|
SID {{ subtitle.subtitle_id }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VCardActions class="border-t border-opacity-10 mt-auto pa-2">
|
||||||
|
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||||
|
{{ formatFileSize(subtitle.size) }}
|
||||||
|
</VChip>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn v-if="subtitle?.report_url" icon size="small" variant="text" color="warning" @click.stop="openReportPage">
|
||||||
|
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn v-if="subtitle?.page_url" icon size="small" variant="text" color="primary" @click.stop="openSubtitleDetail">
|
||||||
|
<VIcon icon="mdi-information-outline"></VIcon>
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subtitle-card {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-card:hover {
|
||||||
|
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
216
src/components/cards/SubtitleItem.vue
Normal file
216
src/components/cards/SubtitleItem.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
import { formatDateDifference, formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import api from '@/api'
|
||||||
|
import type { SubtitleInfo } from '@/api/types'
|
||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
|
import { downloadedSubtitleMap, markSubtitleDownloaded } from '@/utils/subtitleDownloadCache'
|
||||||
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const AddSubtitleDownloadDialog = defineAsyncComponent(() => import('../dialog/AddSubtitleDownloadDialog.vue'))
|
||||||
|
|
||||||
|
// 多语言支持
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
subtitle: Object as PropType<SubtitleInfo>,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 字幕信息
|
||||||
|
const subtitle = ref(props.subtitle)
|
||||||
|
|
||||||
|
// 站点图标
|
||||||
|
const siteIcon = ref('')
|
||||||
|
|
||||||
|
const isDownloaded = computed(() => Boolean(subtitle.value?.enclosure && downloadedSubtitleMap[subtitle.value.enclosure]))
|
||||||
|
|
||||||
|
// 查询站点图标
|
||||||
|
async function getSiteIcon() {
|
||||||
|
if (!subtitle.value?.site) {
|
||||||
|
siteIcon.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
siteIcon.value = await getCachedSiteIcon(subtitle.value.site, async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`site/icon/${subtitle.value?.site}`)
|
||||||
|
|
||||||
|
return response?.data?.icon || ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
siteIcon.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 询问并下载字幕
|
||||||
|
async function handleAddDownload() {
|
||||||
|
openSharedDialog(
|
||||||
|
AddSubtitleDownloadDialog,
|
||||||
|
{
|
||||||
|
title: subtitle.value?.title,
|
||||||
|
subtitle: subtitle.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
done: addDownloadSuccess,
|
||||||
|
error: addDownloadError,
|
||||||
|
},
|
||||||
|
{ closeOn: ['close', 'done', 'error'] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加字幕下载成功
|
||||||
|
function addDownloadSuccess(url: string) {
|
||||||
|
markSubtitleDownloaded(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加字幕下载失败
|
||||||
|
function addDownloadError(error: string) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开字幕详情页面
|
||||||
|
function openSubtitleDetail() {
|
||||||
|
if (!subtitle.value?.page_url) return
|
||||||
|
window.open(subtitle.value.page_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开字幕举报页面
|
||||||
|
function openReportPage() {
|
||||||
|
if (!subtitle.value?.report_url) return
|
||||||
|
window.open(subtitle.value.report_url, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.subtitle,
|
||||||
|
value => {
|
||||||
|
subtitle.value = value
|
||||||
|
getSiteIcon()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-100">
|
||||||
|
<VListItem
|
||||||
|
:value="subtitle?.enclosure"
|
||||||
|
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||||
|
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||||
|
@click="handleAddDownload"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<div class="d-flex flex-column align-center pr-3" :title="subtitle?.site_name">
|
||||||
|
<VImg
|
||||||
|
v-if="siteIcon"
|
||||||
|
:src="siteIcon"
|
||||||
|
:alt="subtitle?.site_name"
|
||||||
|
class="rounded mb-1 site-icon"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
/>
|
||||||
|
<VAvatar
|
||||||
|
v-else
|
||||||
|
size="32"
|
||||||
|
class="mb-1 text-caption bg-primary-lighten-4 text-primary font-weight-bold site-icon"
|
||||||
|
>
|
||||||
|
{{ subtitle?.site_name?.substring(0, 1) }}
|
||||||
|
</VAvatar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VListItemTitle class="whitespace-normal">
|
||||||
|
<div class="d-flex flex-row flex-wrap align-center gap-2 mb-2">
|
||||||
|
<span class="text-h6 font-weight-bold me-1">{{ subtitle?.site_name }}</span>
|
||||||
|
<VChip v-if="subtitle?.season_episode" size="x-small" color="secondary" variant="tonal" class="rounded-sm">
|
||||||
|
{{ subtitle.season_episode }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="subtitle?.language" size="x-small" color="info" variant="tonal" class="rounded-sm">
|
||||||
|
<VImg
|
||||||
|
v-if="subtitle?.language_icon"
|
||||||
|
:src="subtitle.language_icon"
|
||||||
|
:alt="subtitle.language"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
class="me-1"
|
||||||
|
/>
|
||||||
|
{{ subtitle.language }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="isDownloaded" size="x-small" color="success" variant="tonal" class="rounded-sm">
|
||||||
|
{{ t('dialog.addSubtitleDownload.downloaded') }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-subtitle-2 font-weight-medium mb-2 break-all" :title="subtitle?.title">
|
||||||
|
{{ subtitle?.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="subtitle?.description" class="text-body-2 text-medium-emphasis mb-2 break-all" :title="subtitle.description">
|
||||||
|
{{ subtitle.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
|
<span v-if="subtitle?.pubdate || subtitle?.date_elapsed" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="grey" icon="mdi-clock-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle?.date_elapsed || formatDateDifference(subtitle.pubdate || '') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="subtitle?.grabs !== undefined" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="primary" icon="mdi-download-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle.grabs }}
|
||||||
|
</span>
|
||||||
|
<span v-if="subtitle?.uploader" class="d-flex align-center text-sm text-medium-emphasis">
|
||||||
|
<VIcon size="small" color="grey" icon="mdi-account-outline" class="me-1"></VIcon>
|
||||||
|
{{ subtitle.uploader }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</VListItemTitle>
|
||||||
|
|
||||||
|
<template #append>
|
||||||
|
<div class="d-flex flex-column align-end gap-2">
|
||||||
|
<VChip v-if="subtitle?.size" color="primary" size="x-small" variant="elevated" class="rounded-sm">
|
||||||
|
{{ formatFileSize(subtitle.size) }}
|
||||||
|
</VChip>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<VBtn
|
||||||
|
v-if="subtitle?.report_url"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="warning"
|
||||||
|
@click.stop="openReportPage"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-alert-outline"></VIcon>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
v-if="subtitle?.page_url"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
@click.stop="openSubtitleDetail"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-information-outline"></VIcon>
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subtitle-item {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-item:hover {
|
||||||
|
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import type { Process as SystemProcess } from '@/api/types'
|
||||||
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
import { clearCacheAndReload } from '@/composables/useVersionChecker'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||||
@@ -37,6 +38,12 @@ md.use(mdLinkAttributes, {
|
|||||||
// 系统环境变量
|
// 系统环境变量
|
||||||
const systemEnv = ref<any>({})
|
const systemEnv = ref<any>({})
|
||||||
|
|
||||||
|
// 系统运行时间的基准秒数和同步时间,用于在弹窗打开后实时递增展示。
|
||||||
|
const systemUptimeBaseSeconds = ref<number | null>(null)
|
||||||
|
const systemUptimeSyncedAt = ref(0)
|
||||||
|
const systemUptimeNow = ref(Date.now())
|
||||||
|
let systemUptimeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// 所有Release
|
// 所有Release
|
||||||
const allRelease = ref<any>([])
|
const allRelease = ref<any>([])
|
||||||
|
|
||||||
@@ -102,6 +109,22 @@ const frontendVersionStatistics = computed(() => versionStatistic.value?.fronten
|
|||||||
// 活跃用户统计
|
// 活跃用户统计
|
||||||
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
|
||||||
|
|
||||||
|
// 系统运行秒数
|
||||||
|
const systemUptimeSeconds = computed(() => {
|
||||||
|
if (systemUptimeBaseSeconds.value === null) return null
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.floor((systemUptimeNow.value - systemUptimeSyncedAt.value) / 1000)
|
||||||
|
|
||||||
|
return Math.max(0, systemUptimeBaseSeconds.value + elapsedSeconds)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 友好的系统运行时间文本
|
||||||
|
const systemUptimeText = computed(() => {
|
||||||
|
if (systemUptimeSeconds.value === null) return ''
|
||||||
|
|
||||||
|
return formatUptimeDuration(systemUptimeSeconds.value)
|
||||||
|
})
|
||||||
|
|
||||||
/** 格式化版本安装统计数字为千分位展示。 */
|
/** 格式化版本安装统计数字为千分位展示。 */
|
||||||
function formatVersionStatisticNumber(value: unknown) {
|
function formatVersionStatisticNumber(value: unknown) {
|
||||||
const numberValue = Number(value ?? 0)
|
const numberValue = Number(value ?? 0)
|
||||||
@@ -111,6 +134,85 @@ function formatVersionStatisticNumber(value: unknown) {
|
|||||||
return numberValue.toLocaleString()
|
return numberValue.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将秒数保存为运行时间基准,并记录本地同步时间。 */
|
||||||
|
function syncSystemUptime(seconds: number | null) {
|
||||||
|
if (seconds === null) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
systemUptimeBaseSeconds.value = seconds
|
||||||
|
systemUptimeSyncedAt.value = now
|
||||||
|
systemUptimeNow.value = now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将接口返回值规范化为可展示的秒数。 */
|
||||||
|
function normalizeUptimeSeconds(value: unknown) {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue < 0) return null
|
||||||
|
|
||||||
|
return Math.floor(numberValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从进程创建时间推导运行秒数;兼容秒级和毫秒级时间戳。 */
|
||||||
|
function uptimeSecondsFromCreateTime(value: unknown) {
|
||||||
|
const timestamp = Number(value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= 0) return null
|
||||||
|
|
||||||
|
const timestampMs = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||||
|
|
||||||
|
return Math.max(0, Math.floor((Date.now() - timestampMs) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个进程的运行秒数,优先使用创建时间以保留跨天运行时长。 */
|
||||||
|
function getProcessUptimeSeconds(process: SystemProcess) {
|
||||||
|
return uptimeSecondsFromCreateTime(process.create_time) ?? normalizeUptimeSeconds(process.run_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从进程列表中挑选 MoviePilot 主进程,找不到时使用运行时间最长的进程兜底。 */
|
||||||
|
function resolveSystemUptimeSeconds(processes: SystemProcess[]) {
|
||||||
|
const availableProcesses = processes
|
||||||
|
.map(process => ({
|
||||||
|
process,
|
||||||
|
uptimeSeconds: getProcessUptimeSeconds(process),
|
||||||
|
}))
|
||||||
|
.filter((item): item is { process: SystemProcess; uptimeSeconds: number } => item.uptimeSeconds !== null)
|
||||||
|
|
||||||
|
if (!availableProcesses.length) return null
|
||||||
|
|
||||||
|
const preferredProcesses = availableProcesses.filter(({ process }) =>
|
||||||
|
/moviepilot|python|uvicorn|gunicorn|hypercorn/i.test(process.name ?? ''),
|
||||||
|
)
|
||||||
|
const targetProcesses = preferredProcesses.length ? preferredProcesses : availableProcesses
|
||||||
|
|
||||||
|
return targetProcesses.reduce((max, item) => (item.uptimeSeconds > max.uptimeSeconds ? item : max)).uptimeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化单个运行时间单位。 */
|
||||||
|
function formatUptimeUnit(value: number, unit: 'day' | 'hour' | 'minute' | 'second') {
|
||||||
|
const unitKey = value === 1 ? unit : `${unit}s`
|
||||||
|
|
||||||
|
return t(`setting.about.uptimeUnits.${unitKey}`, { count: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将运行秒数格式化为两段以内的友好文本,例如“3天 2小时”。 */
|
||||||
|
function formatUptimeDuration(totalSeconds: number) {
|
||||||
|
const normalizedSeconds = Math.max(0, Math.floor(totalSeconds))
|
||||||
|
const days = Math.floor(normalizedSeconds / 86400)
|
||||||
|
const hours = Math.floor((normalizedSeconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((normalizedSeconds % 3600) / 60)
|
||||||
|
const seconds = normalizedSeconds % 60
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
if (days > 0) parts.push(formatUptimeUnit(days, 'day'))
|
||||||
|
if (hours > 0) parts.push(formatUptimeUnit(hours, 'hour'))
|
||||||
|
if (minutes > 0 && parts.length < 2) parts.push(formatUptimeUnit(minutes, 'minute'))
|
||||||
|
if (!parts.length) parts.push(formatUptimeUnit(seconds, 'second'))
|
||||||
|
|
||||||
|
return parts.slice(0, 2).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
// 打开日志对话框
|
// 打开日志对话框
|
||||||
function showReleaseDialog(title: string, body: string) {
|
function showReleaseDialog(title: string, body: string) {
|
||||||
releaseDialogTitle.value = title
|
releaseDialogTitle.value = title
|
||||||
@@ -151,6 +253,17 @@ async function querySystemEnv() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询系统运行时间
|
||||||
|
async function querySystemUptime() {
|
||||||
|
try {
|
||||||
|
const processes: SystemProcess[] = await api.get('dashboard/processes')
|
||||||
|
|
||||||
|
syncSystemUptime(resolveSystemUptimeSeconds(processes))
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 查询所有Release
|
// 查询所有Release
|
||||||
async function queryAllRelease() {
|
async function queryAllRelease() {
|
||||||
try {
|
try {
|
||||||
@@ -192,8 +305,17 @@ async function clearCache() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
querySystemEnv()
|
querySystemEnv()
|
||||||
|
querySystemUptime()
|
||||||
queryAllRelease()
|
queryAllRelease()
|
||||||
querySupportingSites()
|
querySupportingSites()
|
||||||
|
|
||||||
|
systemUptimeTimer = setInterval(() => {
|
||||||
|
if (systemUptimeBaseSeconds.value !== null) systemUptimeNow.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (systemUptimeTimer) clearInterval(systemUptimeTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -321,6 +443,16 @@ onMounted(() => {
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="systemUptimeText">
|
||||||
|
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||||
|
<dt class="block text-sm font-bold">{{ t('setting.about.systemUptime') }}</dt>
|
||||||
|
<dd class="flex text-sm sm:col-span-2 sm:mt-0">
|
||||||
|
<span class="flex-grow flex flex-row items-center truncate">
|
||||||
|
<code class="truncate">{{ systemUptimeText }}</code>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
<div class="max-w-6xl py-4 sm:grid sm:grid-cols-3 sm:gap-4">
|
||||||
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
<dt class="block text-sm font-bold">{{ t('setting.about.supportingSites') }}</dt>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const buttonText = computed(() =>
|
|||||||
// 加载目录设置
|
// 加载目录设置
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||||
directories.value = result.data?.value ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
270
src/components/dialog/AddSubtitleDownloadDialog.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast } from 'vue-toastification'
|
||||||
|
import api from '@/api'
|
||||||
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
|
import type { SubtitleInfo, TransferDirectoryConf } from '@/api/types'
|
||||||
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
|
import { numberValidator } from '@/@validators'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
|
// 多语言支持
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 从 provide 中获取全局设置
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
const globalSettings = globalSettingsStore.globalSettings
|
||||||
|
|
||||||
|
// 当前识别类型
|
||||||
|
const mediaSource = ref(globalSettings.RECOGNIZE_SOURCE || 'themoviedb')
|
||||||
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
title: String,
|
||||||
|
subtitle: Object as PropType<SubtitleInfo>,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定义成功和失败事件
|
||||||
|
const emit = defineEmits(['done', 'error', 'close'])
|
||||||
|
|
||||||
|
// 提示框
|
||||||
|
const $toast = useToast()
|
||||||
|
|
||||||
|
// 选择的保存目录
|
||||||
|
const selectedDirectory = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 所有目录设置
|
||||||
|
const directories = ref<TransferDirectoryConf[]>([])
|
||||||
|
|
||||||
|
// 是否正在加载
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否显示高级选项
|
||||||
|
const showAdvancedOptions = ref(false)
|
||||||
|
|
||||||
|
// TMDB ID
|
||||||
|
const tmdbid = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
|
// 豆瓣ID
|
||||||
|
const doubanId = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
|
// TMDB选择对话框
|
||||||
|
const mediaSelectorDialog = ref(false)
|
||||||
|
|
||||||
|
// 计算按钮图标
|
||||||
|
const icon = computed(() => (loading.value ? 'mdi-progress-download' : 'mdi-download'))
|
||||||
|
|
||||||
|
// 计算按钮文字
|
||||||
|
const buttonText = computed(() =>
|
||||||
|
loading.value ? t('dialog.addSubtitleDownload.downloading') : t('dialog.addSubtitleDownload.startDownload'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 加载目录设置
|
||||||
|
async function loadDirectories() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||||
|
directories.value = result.data?.value ?? []
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToUri(item: TransferDirectoryConf) {
|
||||||
|
if (!item.download_path) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (item.storage === 'local') {
|
||||||
|
return item.download_path
|
||||||
|
}
|
||||||
|
return item.storage + ':' + item.download_path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取保存目录
|
||||||
|
const targetDirectories = computed(() => {
|
||||||
|
const downloadDirectories = directories.value
|
||||||
|
.map(item => convertToUri(item))
|
||||||
|
.filter((item): item is string => item !== undefined)
|
||||||
|
return [...new Set(downloadDirectories)]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下载字幕
|
||||||
|
async function addSubtitleDownload() {
|
||||||
|
startNProgress()
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
subtitle_in: props.subtitle,
|
||||||
|
save_path: selectedDirectory.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tmdbid.value) {
|
||||||
|
payload.tmdbid = tmdbid.value
|
||||||
|
}
|
||||||
|
if (doubanId.value) {
|
||||||
|
payload.doubanid = doubanId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: { [key: string]: any } = await api.post('download/subtitle', payload)
|
||||||
|
|
||||||
|
if (result && result.success) {
|
||||||
|
$toast.success(
|
||||||
|
t('dialog.addSubtitleDownload.downloadSuccess', {
|
||||||
|
site: props.subtitle?.site_name,
|
||||||
|
title: props.subtitle?.title,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
emit('done', props.subtitle?.enclosure)
|
||||||
|
} else {
|
||||||
|
$toast.error(
|
||||||
|
t('dialog.addSubtitleDownload.downloadFailed', {
|
||||||
|
site: props.subtitle?.site_name,
|
||||||
|
title: props.subtitle?.title,
|
||||||
|
message: result?.message,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
emit('error', result?.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
emit('error', String(error))
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
doneNProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDirectories()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog max-width="35rem" scrollable>
|
||||||
|
<VCard>
|
||||||
|
<VCardItem class="py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-subtitles-outline" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VCardTitle>{{ t('dialog.addSubtitleDownload.confirmDownload') }}</VCardTitle>
|
||||||
|
<VCardSubtitle>{{ subtitle?.site_name }} - {{ title }}</VCardSubtitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
|
<VDivider />
|
||||||
|
<VCardText>
|
||||||
|
<VList lines="one">
|
||||||
|
<VListItem>
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-web"></VIcon>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<span class="whitespace-break-spaces me-2">{{ subtitle?.title }}</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="subtitle?.description">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-text-box-outline"></VIcon>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<span class="text-body-2 whitespace-break-spaces">{{ subtitle?.description }}</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="subtitle?.language || subtitle?.uploader">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-translate"></VIcon>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<span class="text-body-2">
|
||||||
|
{{ subtitle?.language || t('common.unknown') }}
|
||||||
|
<span v-if="subtitle?.uploader" class="text-medium-emphasis ms-2">{{ subtitle.uploader }}</span>
|
||||||
|
</span>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
<VListItem v-if="subtitle?.size">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-database"></VIcon>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle>
|
||||||
|
<VChip variant="tonal" label>
|
||||||
|
{{ formatFileSize(subtitle?.size || 0) }}
|
||||||
|
</VChip>
|
||||||
|
</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
<VRow class="px-5">
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCombobox
|
||||||
|
v-model="selectedDirectory"
|
||||||
|
:items="targetDirectories"
|
||||||
|
:label="t('dialog.addSubtitleDownload.saveDirectory')"
|
||||||
|
:placeholder="t('dialog.addSubtitleDownload.autoPlaceholder')"
|
||||||
|
variant="underlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-folder"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow class="px-5 mt-2">
|
||||||
|
<VCol cols="12">
|
||||||
|
<VBtn
|
||||||
|
variant="text"
|
||||||
|
:prepend-icon="showAdvancedOptions ? 'mdi-chevron-up' : 'mdi-chevron-down'"
|
||||||
|
@click="showAdvancedOptions = !showAdvancedOptions"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
showAdvancedOptions
|
||||||
|
? t('dialog.addDownload.hideAdvancedOptions')
|
||||||
|
: t('dialog.addDownload.showAdvancedOptions')
|
||||||
|
}}
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
<VRow v-show="showAdvancedOptions" class="px-5">
|
||||||
|
<VCol cols="12">
|
||||||
|
<VTextField
|
||||||
|
v-if="mediaSource === 'themoviedb'"
|
||||||
|
v-model="tmdbid"
|
||||||
|
:label="t('dialog.reorganize.tmdbId')"
|
||||||
|
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||||
|
:rules="[numberValidator]"
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-identifier"
|
||||||
|
variant="underlined"
|
||||||
|
density="comfortable"
|
||||||
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
|
/>
|
||||||
|
<VTextField
|
||||||
|
v-else
|
||||||
|
v-model="doubanId"
|
||||||
|
:label="t('dialog.reorganize.doubanId')"
|
||||||
|
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||||
|
:rules="[numberValidator]"
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-identifier"
|
||||||
|
variant="underlined"
|
||||||
|
density="comfortable"
|
||||||
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
<VCardText class="text-center">
|
||||||
|
<VBtn variant="elevated" :disabled="loading" @click="addSubtitleDownload" :prepend-icon="icon" class="px-5">
|
||||||
|
{{ buttonText }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||||
|
<MediaIdSelector
|
||||||
|
v-if="mediaSource === 'themoviedb'"
|
||||||
|
v-model="tmdbid"
|
||||||
|
@close="mediaSelectorDialog = false"
|
||||||
|
:type="mediaSource"
|
||||||
|
/>
|
||||||
|
<MediaIdSelector v-else v-model="doubanId" @close="mediaSelectorDialog = false" :type="mediaSource" />
|
||||||
|
</VDialog>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
@@ -133,12 +133,12 @@ async function savaAlistConfig() {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||||
{{ t('dialog.alistConfig.reset') }}
|
{{ t('dialog.alistConfig.reset') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||||
{{ t('dialog.alistConfig.complete') }}
|
{{ t('dialog.alistConfig.complete') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -138,12 +138,12 @@ onUnmounted(() => {
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||||
{{ t('dialog.aliyunAuth.reset') }}
|
{{ t('dialog.aliyunAuth.reset') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||||
{{ t('dialog.aliyunAuth.complete') }}
|
{{ t('dialog.aliyunAuth.complete') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -84,9 +84,16 @@ function submitReidentify() {
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:loading="props.loading"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
class="px-5"
|
||||||
|
@click="submitReidentify"
|
||||||
|
>
|
||||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
|||||||
</VTab>
|
</VTab>
|
||||||
</VTabs>
|
</VTabs>
|
||||||
|
|
||||||
<div v-if="loading" class="d-flex justify-center align-center" style="min-height: 300px">
|
<div v-if="loading" class="d-flex justify-center align-center" style="min-block-size: 300px">
|
||||||
<VProgressCircular indeterminate color="primary" size="64" />
|
<VProgressCircular indeterminate color="primary" size="64" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -610,12 +610,16 @@ onMounted(() => {
|
|||||||
</VWindow>
|
</VWindow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="text" @click="emit('close')">
|
<VBtn
|
||||||
{{ t('common.cancel') }}
|
color="primary"
|
||||||
</VBtn>
|
variant="flat"
|
||||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
:loading="saving"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
@click="saveConfig"
|
||||||
|
>
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -153,15 +153,15 @@ function submitSettings() {
|
|||||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||||
</p>
|
</p>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
|
||||||
{{ props.selectAllText }}
|
{{ props.selectAllText }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
<VBtn v-if="props.showBulkActions" color="warning" variant="tonal" @click="setAllItems(false)">
|
||||||
{{ props.selectNoneText }}
|
{{ props.selectNoneText }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-content-save" />
|
<VIcon icon="mdi-content-save" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ function submitCustomCSS() {
|
|||||||
class="custom-css-editor"
|
class="custom-css-editor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VCardActions class="custom-css-actions">
|
<VCardActions class="app-dialog-actions custom-css-actions">
|
||||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
@@ -98,9 +99,9 @@ function submitCustomCSS() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.custom-css-dialog {
|
.custom-css-dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-block-size: calc(100dvh - 2rem);
|
max-block-size: calc(100dvh - 2rem);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-header {
|
.custom-css-header {
|
||||||
@@ -110,7 +111,7 @@ function submitCustomCSS() {
|
|||||||
|
|
||||||
.custom-css-editor-body {
|
.custom-css-editor-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
min-block-size: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-editor {
|
.custom-css-editor {
|
||||||
@@ -140,8 +141,8 @@ function submitCustomCSS() {
|
|||||||
|
|
||||||
.custom-css-editor {
|
.custom-css-editor {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-block-size: 0;
|
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-css-actions {
|
.custom-css-actions {
|
||||||
|
|||||||
@@ -199,8 +199,9 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||||
{{ t('customRule.action.confirm') }}
|
{{ t('customRule.action.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ function submitOrder() {
|
|||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="submitOrder">
|
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-content-save" />
|
<VIcon icon="mdi-content-save" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -117,9 +117,20 @@ function generateId() {
|
|||||||
return Math.random().toString(36).substring(2, 9)
|
return Math.random().toString(36).substring(2, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 初始化下载器新增配置项的兼容默认值。 */
|
||||||
|
function initializeDownloaderConfigDefaults() {
|
||||||
|
if (!['qbittorrent', 'transmission'].includes(downloaderInfo.value.type)) return
|
||||||
|
if (!downloaderInfo.value.config) downloaderInfo.value.config = {}
|
||||||
|
if (downloaderInfo.value.type === 'qbittorrent' && downloaderInfo.value.config.incomplete_files_ext === undefined)
|
||||||
|
downloaderInfo.value.config.incomplete_files_ext = true
|
||||||
|
if (downloaderInfo.value.type === 'transmission' && downloaderInfo.value.config.rename_partial_files === undefined)
|
||||||
|
downloaderInfo.value.config.rename_partial_files = true
|
||||||
|
}
|
||||||
|
|
||||||
/** 初始化下载器编辑表单数据。 */
|
/** 初始化下载器编辑表单数据。 */
|
||||||
function initializeDownloaderInfo() {
|
function initializeDownloaderInfo() {
|
||||||
downloaderInfo.value = cloneDeep(props.downloader)
|
downloaderInfo.value = cloneDeep(props.downloader)
|
||||||
|
initializeDownloaderConfigDefaults()
|
||||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
storage: item[0],
|
storage: item[0],
|
||||||
@@ -299,6 +310,15 @@ onMounted(() => {
|
|||||||
active
|
active
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VSwitch
|
||||||
|
v-model="downloaderInfo.config.incomplete_files_ext"
|
||||||
|
:label="t('downloader.incomplete_files_ext')"
|
||||||
|
:hint="t('downloader.incomplete_files_extHint')"
|
||||||
|
persistent-hint
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
@@ -344,6 +364,15 @@ onMounted(() => {
|
|||||||
prepend-inner-icon="mdi-lock"
|
prepend-inner-icon="mdi-lock"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<VSwitch
|
||||||
|
v-model="downloaderInfo.config.rename_partial_files"
|
||||||
|
:label="t('downloader.rename_partial_files')"
|
||||||
|
:hint="t('downloader.rename_partial_filesHint')"
|
||||||
|
persistent-hint
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
@@ -507,8 +536,9 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -52,9 +52,16 @@ function closeDialog() {
|
|||||||
<VCardText>
|
<VCardText>
|
||||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<div class="flex-grow-1" />
|
<VSpacer />
|
||||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:disabled="!folderName"
|
||||||
|
prepend-icon="mdi-folder-plus"
|
||||||
|
class="px-5"
|
||||||
|
@click="emit('create')"
|
||||||
|
>
|
||||||
{{ t('common.create') }}
|
{{ t('common.create') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -81,11 +81,19 @@ function closeDialog() {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
|
||||||
{{ t('file.autoRecognizeName') }}
|
{{ t('file.autoRecognizeName') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:disabled="!renameName"
|
||||||
|
prepend-icon="mdi-check"
|
||||||
|
class="px-5"
|
||||||
|
@click="emit('rename')"
|
||||||
|
>
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -294,18 +294,23 @@ onMounted(() => {
|
|||||||
</Draggable>
|
</Draggable>
|
||||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="primary" @click="addFilterCard">
|
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
|
||||||
<VIcon icon="mdi-plus" />
|
<VIcon icon="mdi-plus" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn color="success" @click="importRules('priority')">
|
<VBtn
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
class="app-dialog-actions__icon-btn"
|
||||||
|
@click="importRules('priority')"
|
||||||
|
>
|
||||||
<VIcon icon="mdi-import" />
|
<VIcon icon="mdi-import" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn color="info" @click="shareRules">
|
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
|
||||||
<VIcon icon="mdi-share" />
|
<VIcon icon="mdi-share" />
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VBtn color="primary" variant="flat" @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function toggleExpand() {
|
|||||||
// 加载follow用户列表
|
// 加载follow用户列表
|
||||||
async function queryFollowUsers() {
|
async function queryFollowUsers() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/FollowSubscribers')
|
const result: { [key: string]: any } = await api.get('system/setting/public/FollowSubscribers')
|
||||||
followUsers.value = result.data?.value ?? []
|
followUsers.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ function handleImport() {
|
|||||||
<VCardText class="pt-2">
|
<VCardText class="pt-2">
|
||||||
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
|
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="handleImport" prepend-icon="mdi-import" class="px-5 me-3">
|
<VBtn color="primary" variant="flat" @click="handleImport" prepend-icon="mdi-import" class="px-5">
|
||||||
{{ t('dialog.importCode.import') }}
|
{{ t('dialog.importCode.import') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ function closeDialog() {
|
|||||||
<template>
|
<template>
|
||||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
<VCardItem>
|
||||||
|
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
<VCardText class="d-flex flex-column ga-4">
|
<VCardText class="d-flex flex-column ga-4">
|
||||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||||
{{ props.authSession.instructions }}
|
{{ props.authSession.instructions }}
|
||||||
@@ -71,9 +74,9 @@ function closeDialog() {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn variant="text" @click="closeDialog">
|
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
|
||||||
{{ t('common.close') }}
|
{{ t('common.close') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -591,8 +591,15 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="saveMediaServerInfo"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -1171,8 +1171,15 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="saveNotificationInfo"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ function submitTemplate() {
|
|||||||
class="template-ace-editor"
|
class="template-ace-editor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VCardActions class="template-editor-actions">
|
<VCardActions class="app-dialog-actions template-editor-actions">
|
||||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -299,8 +299,9 @@ watch(
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions class="justify-end px-6 pb-4">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -154,10 +154,11 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click="submitClone"
|
@click="submitClone"
|
||||||
prepend-icon="mdi-content-copy"
|
prepend-icon="mdi-content-copy"
|
||||||
class="px-5"
|
class="px-5"
|
||||||
|
|||||||
@@ -160,13 +160,26 @@ onBeforeMount(async () => {
|
|||||||
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
|
<VBtn
|
||||||
|
v-if="props.plugin?.has_page"
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-database-eye-outline"
|
||||||
|
@click="emit('switch')"
|
||||||
|
>
|
||||||
{{ t('dialog.pluginConfig.viewData') }}
|
{{ t('dialog.pluginConfig.viewData') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
<!-- 只有Vuetify模式显示默认保存按钮,Vue模式由组件内部控制 -->
|
||||||
<VBtn v-if="renderMode === 'vuetify'" @click="savePluginConf" prepend-icon="mdi-content-save" class="px-5">
|
<VBtn
|
||||||
|
v-if="renderMode === 'vuetify'"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="savePluginConf"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
保存
|
保存
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ function closeDialog() {
|
|||||||
@keyup.enter="emit('create')"
|
@keyup.enter="emit('create')"
|
||||||
/>
|
/>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||||
{{ t('plugin.create') }}
|
{{ t('plugin.create') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ function confirmRename() {
|
|||||||
@keyup.enter="confirmRename"
|
@keyup.enter="confirmRename"
|
||||||
/>
|
/>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -201,9 +201,11 @@ onMounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">
|
||||||
|
保存
|
||||||
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ function openLoggerWindow() {
|
|||||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 下载当前插件日志压缩包。 */
|
||||||
|
function downloadLogger() {
|
||||||
|
const url = `${import.meta.env.VITE_API_BASE_URL}system/logging/download/${props.plugin?.id?.toLowerCase()}`
|
||||||
|
window.open(url, '_blank')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,12 +58,20 @@ function openLoggerWindow() {
|
|||||||
<VCardTitle class="d-inline-flex">
|
<VCardTitle class="d-inline-flex">
|
||||||
<VIcon icon="mdi-file-document" class="me-2" />
|
<VIcon icon="mdi-file-document" class="me-2" />
|
||||||
{{ t('plugin.logTitle') }}
|
{{ t('plugin.logTitle') }}
|
||||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
|
||||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
<VChip color="grey-darken-1" size="small">
|
||||||
{{ t('common.openInNewWindow') }}
|
<VIcon icon="mdi-download" size="small" start />
|
||||||
</VChip>
|
{{ t('common.download') }}
|
||||||
</a>
|
</VChip>
|
||||||
|
</a>
|
||||||
|
<a class="d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||||
|
<VChip color="grey-darken-1" size="small">
|
||||||
|
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||||
|
{{ t('common.openInNewWindow') }}
|
||||||
|
</VChip>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
|||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||||
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||||
|
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||||
|
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||||
|
)
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
|||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
|||||||
const imageLoadError = ref(false)
|
const imageLoadError = ref(false)
|
||||||
|
|
||||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||||
|
|
||||||
/** 打开插件安装进度弹窗。 */
|
/** 打开插件安装进度弹窗。 */
|
||||||
function showInstallProgress(text: string) {
|
function showInstallProgress(text: string) {
|
||||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 安装插件并通知父级刷新市场列表。 */
|
/** 安装插件并通知父级刷新市场列表。 */
|
||||||
async function installPlugin() {
|
async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||||
if (props.plugin?.system_version_compatible === false) {
|
if (!releaseVersion && props.plugin?.system_version_compatible === false) {
|
||||||
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (releaseVersion) {
|
||||||
|
const isConfirmed = await createConfirm({
|
||||||
|
title: t('common.confirm'),
|
||||||
|
content: t('plugin.confirmInstallOldRelease', {
|
||||||
|
name: props.plugin?.plugin_name,
|
||||||
|
version: releaseVersion,
|
||||||
|
}),
|
||||||
|
confirmText: t('common.confirm'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isConfirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showInstallProgress(
|
showInstallProgress(
|
||||||
t('plugin.installing', {
|
t('plugin.installing', {
|
||||||
name: props.plugin?.plugin_name,
|
name: props.plugin?.plugin_name,
|
||||||
version: props?.plugin?.plugin_version,
|
version: releaseVersion || props?.plugin?.plugin_version,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||||
params: {
|
params: {
|
||||||
repo_url: props.plugin?.repo_url,
|
repo_url: repoUrl || props.plugin?.repo_url,
|
||||||
force: props.plugin?.has_update,
|
release_version: releaseVersion,
|
||||||
|
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = null
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('install')
|
emit('install')
|
||||||
} else {
|
} else {
|
||||||
@@ -133,8 +156,22 @@ async function installPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开版本历史并支持从 Release 资产安装指定版本。 */
|
||||||
|
function showUpdateHistory() {
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
|
versionHistoryDialogController = openSharedDialog(
|
||||||
|
PluginVersionHistoryDialog,
|
||||||
|
{ plugin: props.plugin, actionMode: 'install' },
|
||||||
|
{
|
||||||
|
update: installPlugin,
|
||||||
|
},
|
||||||
|
{ closeOn: ['close', 'update:modelValue'] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
closeInstallProgress()
|
closeInstallProgress()
|
||||||
|
versionHistoryDialogController?.close()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -190,16 +227,21 @@ onUnmounted(() => {
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||||
/>
|
/>
|
||||||
<div class="text-center text-md-left">
|
<div class="plugin-market-detail-actions">
|
||||||
<VBtn
|
<div class="plugin-market-detail-actions__buttons">
|
||||||
color="primary"
|
<VBtn
|
||||||
@click="installPlugin"
|
color="primary"
|
||||||
prepend-icon="mdi-download"
|
@click="installPlugin()"
|
||||||
:disabled="props.plugin?.system_version_compatible === false"
|
prepend-icon="mdi-download"
|
||||||
>
|
:disabled="props.plugin?.system_version_compatible === false"
|
||||||
{{ t('plugin.installToLocal') }}
|
>
|
||||||
</VBtn>
|
{{ t('plugin.installToLocal') }}
|
||||||
<div class="text-xs mt-2" v-if="props.count">
|
</VBtn>
|
||||||
|
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||||
|
{{ t('plugin.versionHistory') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||||
<VIcon icon="mdi-fire" />
|
<VIcon icon="mdi-fire" />
|
||||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -212,3 +254,42 @@ onUnmounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plugin-market-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-detail-actions__buttons {
|
||||||
|
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-detail-actions__downloads {
|
||||||
|
flex-basis: 100%;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 960px) {
|
||||||
|
.plugin-market-detail-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-detail-actions__buttons {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-detail-actions__downloads {
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ const repoText = ref('')
|
|||||||
const newRepoUrl = ref('')
|
const newRepoUrl = ref('')
|
||||||
const editingIndex = ref<number | null>(null)
|
const editingIndex = ref<number | null>(null)
|
||||||
const editingUrl = ref('')
|
const editingUrl = ref('')
|
||||||
|
const syncingWiki = ref(false)
|
||||||
|
|
||||||
const emit = defineEmits(['save', 'close'])
|
const emit = defineEmits(['save', 'close'])
|
||||||
|
|
||||||
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
|
||||||
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
|
const activeRepoCount = computed(() =>
|
||||||
|
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
|
||||||
|
)
|
||||||
const saveDisabled = computed(
|
const saveDisabled = computed(
|
||||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||||
)
|
)
|
||||||
@@ -108,7 +111,7 @@ function switchEditorMode(mode: EditorMode | undefined) {
|
|||||||
/** 加载插件市场仓库配置。 */
|
/** 加载插件市场仓库配置。 */
|
||||||
async function queryMarketRepoSetting() {
|
async function queryMarketRepoSetting() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
|
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
|
||||||
if (result && result.data && result.data.value) {
|
if (result && result.data && result.data.value) {
|
||||||
repoList.value = parseRepoInput(result.data.value).repos
|
repoList.value = parseRepoInput(result.data.value).repos
|
||||||
syncTextFromList()
|
syncTextFromList()
|
||||||
@@ -136,6 +139,35 @@ async function saveHandle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
|
||||||
|
async function syncWikiRepos() {
|
||||||
|
try {
|
||||||
|
syncingWiki.value = true
|
||||||
|
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const repos = Array.isArray(result.data?.repos)
|
||||||
|
? result.data.repos
|
||||||
|
: parseRepoInput(result.data?.value || '').repos
|
||||||
|
repoList.value = repos
|
||||||
|
syncTextFromList()
|
||||||
|
$toast.success(
|
||||||
|
t('dialog.pluginMarketSetting.syncSuccess', {
|
||||||
|
added: result.data?.added_count ?? 0,
|
||||||
|
total: result.data?.total_count ?? repos.length,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: result?.message }))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||||
|
} finally {
|
||||||
|
syncingWiki.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取当前维护模式下可保存的仓库地址。 */
|
/** 获取当前维护模式下可保存的仓库地址。 */
|
||||||
function normalizeCurrentRepos() {
|
function normalizeCurrentRepos() {
|
||||||
if (editorMode.value === 'text') {
|
if (editorMode.value === 'text') {
|
||||||
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
|
|||||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
|
||||||
&& pathSegments.length >= 2
|
pathSegments.length >= 2
|
||||||
) {
|
) {
|
||||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||||
}
|
}
|
||||||
@@ -258,25 +290,47 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
<VDivider />
|
||||||
<VCardText class="plugin-market-dialog-body pt-4">
|
<VCardText class="plugin-market-dialog-body pt-4">
|
||||||
<div class="plugin-market-toolbar">
|
<div class="plugin-market-toolbar">
|
||||||
<VBtnToggle
|
<div class="plugin-market-toolbar-hint">
|
||||||
:model-value="editorMode"
|
<VIcon icon="mdi-information-outline" size="18" />
|
||||||
mandatory
|
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
||||||
color="primary"
|
</div>
|
||||||
density="comfortable"
|
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
|
||||||
variant="tonal"
|
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
|
||||||
class="plugin-market-mode-toggle"
|
<template #activator="{ props }">
|
||||||
@update:model-value="switchEditorMode"
|
<button
|
||||||
>
|
v-bind="props"
|
||||||
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
type="button"
|
||||||
{{ t('dialog.pluginMarketSetting.listMode') }}
|
class="plugin-market-mode-button"
|
||||||
</VBtn>
|
:class="{ 'is-active': editorMode === 'list' }"
|
||||||
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
role="tab"
|
||||||
{{ t('dialog.pluginMarketSetting.textMode') }}
|
:aria-label="t('dialog.pluginMarketSetting.listMode')"
|
||||||
</VBtn>
|
:aria-selected="editorMode === 'list'"
|
||||||
</VBtnToggle>
|
@click="switchEditorMode('list')"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-format-list-bulleted" size="20" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</VTooltip>
|
||||||
|
<VTooltip :text="t('dialog.pluginMarketSetting.textMode')" location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<button
|
||||||
|
v-bind="props"
|
||||||
|
type="button"
|
||||||
|
class="plugin-market-mode-button"
|
||||||
|
:class="{ 'is-active': editorMode === 'text' }"
|
||||||
|
role="tab"
|
||||||
|
:aria-label="t('dialog.pluginMarketSetting.textMode')"
|
||||||
|
:aria-selected="editorMode === 'text'"
|
||||||
|
@click="switchEditorMode('text')"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-text-box-edit-outline" size="20" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</VTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||||
@@ -424,7 +478,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions class="plugin-market-actions">
|
<VCardActions class="app-dialog-actions">
|
||||||
|
<VBtn
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-cloud-sync-outline"
|
||||||
|
:loading="syncingWiki"
|
||||||
|
:disabled="syncingWiki"
|
||||||
|
@click="syncWikiRepos"
|
||||||
|
>
|
||||||
|
{{ t('dialog.pluginMarketSetting.syncWiki') }}
|
||||||
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -478,14 +542,70 @@ onMounted(() => {
|
|||||||
.plugin-market-toolbar {
|
.plugin-market-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-block-size: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-mode-toggle {
|
.plugin-market-toolbar-hint {
|
||||||
inline-size: 100%;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: rgba(var(--v-theme-info), 0.08);
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
|
||||||
:deep(.v-btn) {
|
span {
|
||||||
flex: 1;
|
overflow: hidden;
|
||||||
min-inline-size: 0;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: rgba(var(--v-theme-surface), 0.72);
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-market-mode-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: transparent;
|
||||||
|
block-size: 2.25rem;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
inline-size: 2.25rem;
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.07);
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(var(--v-theme-primary), 0.48);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.12);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,8 +649,8 @@ onMounted(() => {
|
|||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
line-break: anywhere;
|
line-break: anywhere;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -550,20 +670,22 @@ onMounted(() => {
|
|||||||
|
|
||||||
.plugin-market-empty {
|
.plugin-market-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: column;
|
|
||||||
min-block-size: 14rem;
|
min-block-size: 14rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-textarea-field {
|
.plugin-market-textarea-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: rgba(var(--v-theme-surface), 0.72);
|
background: rgba(var(--v-theme-surface), 0.72);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
overflow: hidden;
|
transition:
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
border-color: rgb(var(--v-theme-primary));
|
border-color: rgb(var(--v-theme-primary));
|
||||||
@@ -586,13 +708,14 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem 1rem 1rem 3.25rem;
|
padding-block: 1rem;
|
||||||
|
padding-inline: 3.25rem 1rem;
|
||||||
resize: none;
|
resize: none;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -612,19 +735,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-actions {
|
@media (width <= 600px) {
|
||||||
flex: 0 0 auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.plugin-market-dialog-card {
|
.plugin-market-dialog-card {
|
||||||
block-size: 100dvh;
|
block-size: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-card-item {
|
.plugin-market-card-item {
|
||||||
padding: 0.75rem 1rem 0.625rem;
|
padding-block: 0.75rem 0.625rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-header {
|
.plugin-market-header {
|
||||||
@@ -640,16 +758,22 @@ onMounted(() => {
|
|||||||
|
|
||||||
.plugin-market-dialog-body {
|
.plugin-market-dialog-body {
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
padding: 0.75rem 1rem !important;
|
padding-block: 0.75rem !important;
|
||||||
|
padding-inline: 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-mode-toggle {
|
.plugin-market-toolbar {
|
||||||
inline-size: 100%;
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.v-btn) {
|
.plugin-market-mode-switch {
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
min-inline-size: 0;
|
}
|
||||||
}
|
|
||||||
|
.plugin-market-toolbar-hint {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-list-panel,
|
.plugin-market-list-panel,
|
||||||
@@ -664,9 +788,5 @@ onMounted(() => {
|
|||||||
.plugin-market-empty {
|
.plugin-market-empty {
|
||||||
min-block-size: 10rem;
|
min-block-size: 10rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plugin-market-actions {
|
|
||||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin, PluginReleaseVersion, PluginReleaseVersionsResponse } from '@/api/types'
|
||||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 多语言
|
// 多语言
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -21,14 +21,25 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
actionMode: {
|
||||||
|
type: String as PropType<'install' | 'update'>,
|
||||||
|
default: 'update',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
const emit = defineEmits(['update:modelValue', 'close', 'update'])
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'update', releaseVersion?: string, repoUrl?: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const pluginDetail = ref<Plugin | null>(null)
|
const pluginDetail = ref<Plugin | null>(null)
|
||||||
|
const releaseLoading = ref(false)
|
||||||
|
const releaseError = ref('')
|
||||||
|
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||||
|
|
||||||
// 弹窗显示状态
|
// 弹窗显示状态
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -41,19 +52,73 @@ const visible = computed({
|
|||||||
|
|
||||||
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
|
const resolvedPlugin = computed(() => pluginDetail.value ?? props.plugin)
|
||||||
|
|
||||||
const resolvedHistory = computed(() => resolvedPlugin.value?.history || {})
|
const resolvedHistory = computed(() => {
|
||||||
|
const history = { ...(resolvedPlugin.value?.history || {}) }
|
||||||
|
releaseItems.value.forEach(item => {
|
||||||
|
const key = normalizeHistoryVersion(item.version)
|
||||||
|
if (!(key in history)) history[key] = item.body || ''
|
||||||
|
})
|
||||||
|
return history
|
||||||
|
})
|
||||||
|
|
||||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||||
|
|
||||||
|
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
||||||
|
|
||||||
|
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||||
|
|
||||||
|
const shouldShowUpdatePanel = computed(() => props.showUpdateAction)
|
||||||
|
|
||||||
|
const releaseByHistoryVersion = computed(() => {
|
||||||
|
const releaseMap = new Map<string, PluginReleaseVersion>()
|
||||||
|
releaseItems.value.forEach(item => {
|
||||||
|
releaseMap.set(normalizeHistoryVersion(item.version), item)
|
||||||
|
})
|
||||||
|
return releaseMap
|
||||||
|
})
|
||||||
|
|
||||||
|
function normalizeHistoryVersion(version: string) {
|
||||||
|
return version.startsWith('v') ? version : `v${version}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReleaseDate(value?: string) {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
return date.toLocaleDateString(locale.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseItemByHistoryVersion(version: string) {
|
||||||
|
return releaseByHistoryVersion.value.get(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowReleaseButton(item?: PluginReleaseVersion) {
|
||||||
|
if (!item || item.is_current) return false
|
||||||
|
return !(item.is_latest && shouldShowUpdatePanel.value && props.actionMode === 'update')
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPluginHistory() {
|
async function loadPluginHistory() {
|
||||||
if (!props.plugin?.id) {
|
if (!props.plugin?.id) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
releaseDetail.value = null
|
||||||
|
releaseError.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
releaseDetail.value = null
|
||||||
|
releaseError.value = ''
|
||||||
|
|
||||||
|
// 插件市场条目已经携带远端信息;history 接口只查询已安装插件,
|
||||||
|
// 未安装插件打开版本历史时只能基于传入的市场数据和 Release 列表展示。
|
||||||
|
if (props.actionMode === 'install' && props.plugin?.repo_url) {
|
||||||
|
pluginDetail.value = null
|
||||||
|
loading.value = false
|
||||||
|
loadPluginReleases(props.plugin, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||||
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
|
|||||||
force: true,
|
force: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pluginDetail.value = null
|
pluginDetail.value = null
|
||||||
loadError.value = t('plugin.updateHistoryLoadFailed')
|
loadError.value = t('plugin.updateHistoryLoadFailed')
|
||||||
@@ -70,36 +136,108 @@ async function loadPluginHistory() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPluginReleases(plugin: Plugin | null | undefined = resolvedPlugin.value, force = false) {
|
||||||
|
if (!plugin?.id || !plugin?.repo_url || !plugin?.release) {
|
||||||
|
releaseDetail.value = null
|
||||||
|
releaseError.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseLoading.value = true
|
||||||
|
releaseError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
releaseDetail.value = await api.get(`plugin/releases/${plugin.id}`, {
|
||||||
|
params: {
|
||||||
|
repo_url: plugin.repo_url,
|
||||||
|
force,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
releaseDetail.value = null
|
||||||
|
releaseError.value = t('plugin.releaseVersionsLoadFailed')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
releaseLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 触发插件更新操作。 */
|
/** 触发插件更新操作。 */
|
||||||
function handleUpdate() {
|
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
||||||
emit('update')
|
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [visible.value, props.plugin?.id],
|
() => [visible.value, props.plugin?.id],
|
||||||
([isVisible]) => {
|
([isVisible]) => {
|
||||||
if (isVisible) loadPluginHistory()
|
if (isVisible) {
|
||||||
|
loadPluginHistory()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
|
<VDialog v-if="visible" v-model="visible" width="680" max-height="85vh" scrollable>
|
||||||
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
|
<VCard :title="t('plugin.updateHistoryTitle', { name: resolvedPlugin?.plugin_name })">
|
||||||
<VDialogCloseBtn v-model="visible" />
|
<VDialogCloseBtn v-model="visible" />
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
|
||||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||||
<VProgressCircular indeterminate color="primary" />
|
<VProgressCircular indeterminate color="primary" />
|
||||||
</div>
|
</div>
|
||||||
<VCardText v-else-if="loadError && !hasHistory">
|
<VCardText v-else-if="loadError && !hasHistory">
|
||||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText v-else-if="!hasHistory">
|
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VersionHistory v-else :history="resolvedHistory" />
|
<template v-else>
|
||||||
<template v-if="props.showUpdateAction">
|
<VCardText v-if="releaseError" class="pb-0">
|
||||||
|
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
||||||
|
</VCardText>
|
||||||
|
<VersionHistory
|
||||||
|
:history="resolvedHistory"
|
||||||
|
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||||
|
>
|
||||||
|
<template #meta="{ version }">
|
||||||
|
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||||
|
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
||||||
|
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||||
|
</span>
|
||||||
|
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
||||||
|
{{ t('plugin.latestVersion') }}
|
||||||
|
</VChip>
|
||||||
|
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
||||||
|
{{ t('plugin.currentVersion') }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #action="{ version }">
|
||||||
|
<VBtn
|
||||||
|
v-if="shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||||
|
class="plugin-release-button"
|
||||||
|
size="small"
|
||||||
|
min-width="5rem"
|
||||||
|
:color="releaseItemByHistoryVersion(version)?.is_latest ? 'primary' : undefined"
|
||||||
|
:variant="releaseItemByHistoryVersion(version)?.is_latest ? 'flat' : 'tonal'"
|
||||||
|
:disabled="
|
||||||
|
releaseItemByHistoryVersion(version)?.is_current ||
|
||||||
|
(releaseItemByHistoryVersion(version)?.is_latest && resolvedPlugin?.system_version_compatible === false)
|
||||||
|
"
|
||||||
|
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
releaseItemByHistoryVersion(version)?.is_latest
|
||||||
|
? latestActionText
|
||||||
|
: t('plugin.installReleaseVersion')
|
||||||
|
}}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VersionHistory>
|
||||||
|
</template>
|
||||||
|
<template v-if="shouldShowUpdatePanel">
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VAlert
|
<VAlert
|
||||||
@@ -110,7 +248,11 @@ watch(
|
|||||||
class="mb-3"
|
class="mb-3"
|
||||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||||
/>
|
/>
|
||||||
<VBtn @click="handleUpdate" block :disabled="resolvedPlugin?.system_version_compatible === false">
|
<VBtn
|
||||||
|
@click="handleUpdate()"
|
||||||
|
block
|
||||||
|
:disabled="resolvedPlugin?.system_version_compatible === false"
|
||||||
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||||
</template>
|
</template>
|
||||||
@@ -129,4 +271,23 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plugin-release-button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-release-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-release-meta__date {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,12 +89,12 @@ async function handleReset() {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||||
{{ t('dialog.rcloneConfig.reset') }}
|
{{ t('dialog.rcloneConfig.reset') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||||
{{ t('dialog.rcloneConfig.complete') }}
|
{{ t('dialog.rcloneConfig.complete') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
ManualTransferPayload,
|
ManualTransferPayload,
|
||||||
ManualTransferPreviewData,
|
ManualTransferPreviewData,
|
||||||
ManualTransferPreviewItem,
|
ManualTransferPreviewItem,
|
||||||
ManualTransferTargetPathData,
|
|
||||||
StorageConf,
|
StorageConf,
|
||||||
TransferDirectoryConf,
|
TransferDirectoryConf,
|
||||||
TransferForm,
|
TransferForm,
|
||||||
@@ -19,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
|
|||||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
import ProgressDialog from './ProgressDialog.vue'
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { nextTick } from 'vue'
|
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
@@ -118,14 +116,13 @@ const episodeFormatRecommendState = reactive<{
|
|||||||
|
|
||||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||||
|
|
||||||
interface ManualTransferTargetPathRequest {
|
interface TargetDirectoryOption {
|
||||||
fileitem?: FileItem
|
title: string
|
||||||
fileitems?: FileItem[]
|
value: string
|
||||||
logid?: number
|
|
||||||
logids?: number[]
|
|
||||||
target_storage?: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
|
||||||
|
|
||||||
// 生成文件项稳定键,用于去重和状态同步。
|
// 生成文件项稳定键,用于去重和状态同步。
|
||||||
function getFileItemKey(item?: FileItem) {
|
function getFileItemKey(item?: FileItem) {
|
||||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||||
@@ -152,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
|
|||||||
|
|
||||||
// 分页
|
// 分页
|
||||||
const previewPage = ref(1)
|
const previewPage = ref(1)
|
||||||
const previewPageSize = ref(10)
|
const previewPageSize = ref(20)
|
||||||
|
|
||||||
// 预览列表主体元素
|
|
||||||
const previewFileBodyRef = ref<HTMLElement>()
|
|
||||||
|
|
||||||
// 预览列表尺寸观察器
|
|
||||||
let previewFileBodyResizeObserver: ResizeObserver | undefined
|
|
||||||
|
|
||||||
// 所有存储
|
// 所有存储
|
||||||
const storages = ref<StorageConf[]>([])
|
const storages = ref<StorageConf[]>([])
|
||||||
@@ -175,7 +166,7 @@ let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
|
|||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||||
|
|
||||||
storages.value = result.data?.value ?? []
|
storages.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -185,10 +176,27 @@ async function loadStorages() {
|
|||||||
|
|
||||||
// 存储字典
|
// 存储字典
|
||||||
const storageOptions = computed(() => {
|
const storageOptions = computed(() => {
|
||||||
return storages.value.map(item => ({
|
return [
|
||||||
title: item.name,
|
{
|
||||||
value: item.type,
|
title: t('dialog.reorganize.auto'),
|
||||||
}))
|
value: null,
|
||||||
|
},
|
||||||
|
...storages.value.map(item => ({
|
||||||
|
title: item.name,
|
||||||
|
value: item.type,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 整理方式选项,包含可提交 null 的自动项。
|
||||||
|
const manualTransferTypeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('dialog.reorganize.auto'),
|
||||||
|
value: null,
|
||||||
|
},
|
||||||
|
...transferTypeOptions,
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 剧集组选项属性
|
// 剧集组选项属性
|
||||||
@@ -273,16 +281,20 @@ const disableEpisodeDetail = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||||
|
|
||||||
// 表单
|
// 表单
|
||||||
const transferForm = reactive<TransferForm>({
|
const transferForm = reactive<TransferForm>({
|
||||||
fileitem: {} as FileItem,
|
fileitem: {} as FileItem,
|
||||||
logid: 0,
|
logid: 0,
|
||||||
target_storage: props.target_storage ?? 'local',
|
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||||
target_path: normalizeTargetPath(props.target_path),
|
target_path: initialTargetPath,
|
||||||
transfer_type: '',
|
transfer_type: null,
|
||||||
min_filesize: 0,
|
min_filesize: 0,
|
||||||
scrape: false,
|
scrape: initialTargetPath ? false : null,
|
||||||
from_history: false,
|
from_history: false,
|
||||||
|
library_type_folder: null,
|
||||||
|
library_category_folder: null,
|
||||||
episode_group: null,
|
episode_group: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -292,90 +304,52 @@ const directories = ref<TransferDirectoryConf[]>([])
|
|||||||
// 查询目录
|
// 查询目录
|
||||||
async function loadDirectories() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||||
directories.value = result.data?.value ?? []
|
directories.value = result.data?.value ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 目的目录下拉框
|
// 目的目录下拉框,第一项用于把目标路径显式重置为后端自动匹配。
|
||||||
const targetDirectories = computed(() => {
|
const targetDirectoryOptions = computed<TargetDirectoryOption[]>(() => {
|
||||||
const libraryDirectories = directories.value.map(item => item.library_path)
|
const libraryDirectories = directories.value.map(item => item.library_path).filter(Boolean) as string[]
|
||||||
return [...new Set(libraryDirectories)]
|
return [
|
||||||
|
{
|
||||||
|
title: t('dialog.reorganize.auto'),
|
||||||
|
value: AUTO_TARGET_PATH_VALUE,
|
||||||
|
},
|
||||||
|
...[...new Set(libraryDirectories)].map(path => ({
|
||||||
|
title: path,
|
||||||
|
value: path,
|
||||||
|
})),
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
|
// 目标路径选择值,用哨兵值把界面上的“自动”和接口里的 null 解耦。
|
||||||
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
|
const targetPathSelection = computed({
|
||||||
const payload: ManualTransferTargetPathRequest = {}
|
get() {
|
||||||
|
return transferForm.target_path ?? AUTO_TARGET_PATH_VALUE
|
||||||
if (props.target_storage) {
|
},
|
||||||
payload.target_storage = props.target_storage
|
set(value: string | null) {
|
||||||
}
|
const targetPath = normalizeTargetPath(value)
|
||||||
|
if (!targetPath || targetPath === AUTO_TARGET_PATH_VALUE) {
|
||||||
if (normalizedItems.value.length === 1) {
|
resetAutomaticTargetConfig()
|
||||||
payload.fileitem = normalizedItems.value[0]
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedItems.value.length > 1) {
|
|
||||||
payload.fileitems = normalizedItems.value
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.logids?.length) {
|
|
||||||
if (props.logids.length > 1) {
|
|
||||||
payload.logids = props.logids
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
payload.logid = props.logids[0]
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
|
|
||||||
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
|
|
||||||
const matchedTargetPath = normalizeTargetPath(data?.target_path)
|
|
||||||
if (!matchedTargetPath) {
|
|
||||||
transferForm.target_path = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
|
|
||||||
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
|
|
||||||
transferForm.scrape = data?.scrape ?? false
|
|
||||||
transferForm.library_type_folder = data?.library_type_folder ?? false
|
|
||||||
transferForm.library_category_folder = data?.library_category_folder ?? false
|
|
||||||
transferForm.target_path = matchedTargetPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求后端按源目录匹配最合适的手动整理目的路径。
|
|
||||||
async function autoSelectTargetPath() {
|
|
||||||
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
|
|
||||||
|
|
||||||
const payload = createTargetPathMatchRequest()
|
|
||||||
if (!payload) {
|
|
||||||
transferForm.target_path = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
|
|
||||||
'transfer/manual/target-path',
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
transferForm.target_path = null
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMatchedTargetPath(result.data)
|
transferForm.target_path = targetPath
|
||||||
} catch (error) {
|
},
|
||||||
console.log(error)
|
})
|
||||||
transferForm.target_path = null
|
|
||||||
}
|
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
||||||
|
function resetAutomaticTargetConfig() {
|
||||||
|
transferForm.target_storage = null
|
||||||
|
transferForm.target_path = null
|
||||||
|
transferForm.transfer_type = null
|
||||||
|
transferForm.scrape = null
|
||||||
|
transferForm.library_type_folder = null
|
||||||
|
transferForm.library_category_folder = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听目的路径变化,配置默认值
|
// 监听目的路径变化,配置默认值
|
||||||
@@ -391,6 +365,7 @@ watch(
|
|||||||
transferForm.library_category_folder = directory.library_category_folder ?? false
|
transferForm.library_category_folder = directory.library_category_folder ?? false
|
||||||
transferForm.library_type_folder = directory.library_type_folder ?? false
|
transferForm.library_type_folder = directory.library_type_folder ?? false
|
||||||
} else {
|
} else {
|
||||||
|
transferForm.target_storage = transferForm.target_storage || 'local'
|
||||||
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
transferForm.transfer_type = transferForm.transfer_type || 'copy'
|
||||||
transferForm.scrape = false
|
transferForm.scrape = false
|
||||||
transferForm.library_category_folder = false
|
transferForm.library_category_folder = false
|
||||||
@@ -398,9 +373,9 @@ watch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 路径为空时, 恢复到`自动`条件
|
// 路径为空时, 恢复到`自动`条件
|
||||||
transferForm.transfer_type = ''
|
transferForm.transfer_type = null
|
||||||
transferForm.library_type_folder = undefined
|
transferForm.library_type_folder = null
|
||||||
transferForm.library_category_folder = undefined
|
transferForm.library_category_folder = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -437,9 +412,39 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 过滤后的预览数据
|
// 过滤并排序后的预览数据
|
||||||
const filteredPreviewItems = computed(() => {
|
const filteredPreviewItems = computed(() => {
|
||||||
return previewData.value?.items ?? []
|
const items = [...(previewData.value?.items ?? [])]
|
||||||
|
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
// 1. 获取季号(如果有的话优先按季号排)
|
||||||
|
const seasonA = getPreviewSeasonNumber(a)
|
||||||
|
const seasonB = getPreviewSeasonNumber(b)
|
||||||
|
if (seasonA !== seasonB) {
|
||||||
|
if (seasonA === undefined) return 1
|
||||||
|
if (seasonB === undefined) return -1
|
||||||
|
return seasonA - seasonB
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取集数
|
||||||
|
const epA = toPreviewNumber(a.episode)
|
||||||
|
const epB = toPreviewNumber(b.episode)
|
||||||
|
|
||||||
|
// 如果都有集数,按集数排序
|
||||||
|
if (epA !== undefined && epB !== undefined) {
|
||||||
|
if (epA !== epB) return epA - epB
|
||||||
|
// 集数相同(可能是同集的视频、字幕等),退化到按文件名排序,保证相关文件挨在一起
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 有集数的排前面,没集数的(通常是其他文件)排后面
|
||||||
|
if (epA !== undefined && epB === undefined) return -1
|
||||||
|
if (epA === undefined && epB !== undefined) return 1
|
||||||
|
|
||||||
|
// 4. 如果都没集数,或者集数完全相同,则按照目标路径(或源路径)的字母顺序排
|
||||||
|
const nameA = a.target || a.source || ''
|
||||||
|
const nameB = b.target || b.source || ''
|
||||||
|
return nameA.localeCompare(nameB, undefined, { numeric: true })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 分页后的预览数据(含文件名解析)
|
// 分页后的预览数据(含文件名解析)
|
||||||
@@ -496,6 +501,12 @@ function normalizeTargetPath(path?: string | null) {
|
|||||||
return normalizedPath || null
|
return normalizedPath || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 归一化可选文本参数,保证自动项提交 null 而不是空字符串。
|
||||||
|
function normalizeOptionalText(value?: string | null) {
|
||||||
|
const normalizedValue = value?.trim()
|
||||||
|
return normalizedValue || null
|
||||||
|
}
|
||||||
|
|
||||||
// 归一化剧集组值,兼容历史对象态值。
|
// 归一化剧集组值,兼容历史对象态值。
|
||||||
function normalizeEpisodeGroup(episodeGroup?: string | { value?: string | null } | null) {
|
function normalizeEpisodeGroup(episodeGroup?: string | { value?: string | null } | null) {
|
||||||
if (!episodeGroup) return null
|
if (!episodeGroup) return null
|
||||||
@@ -822,7 +833,9 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
|||||||
...transferForm,
|
...transferForm,
|
||||||
fileitem: sourceItem,
|
fileitem: sourceItem,
|
||||||
logid: options.logid ?? 0,
|
logid: options.logid ?? 0,
|
||||||
|
target_storage: normalizeOptionalText(transferForm.target_storage),
|
||||||
target_path: normalizeTargetPath(transferForm.target_path),
|
target_path: normalizeTargetPath(transferForm.target_path),
|
||||||
|
transfer_type: normalizeOptionalText(transferForm.transfer_type),
|
||||||
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
episode_group: normalizeEpisodeGroup(transferForm.episode_group),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,7 +861,7 @@ async function requestManualTransfer<T = any>(
|
|||||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||||
async function loadEpisodeFormatRuleConfiguration() {
|
async function loadEpisodeFormatRuleConfiguration() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable')
|
||||||
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error)
|
console.log(error)
|
||||||
@@ -1120,7 +1133,6 @@ async function previewTransfer() {
|
|||||||
|
|
||||||
previewData.value = mergedPreviewData
|
previewData.value = mergedPreviewData
|
||||||
previewLoaded.value = true
|
previewLoaded.value = true
|
||||||
nextTick(() => updatePreviewPageSize())
|
|
||||||
|
|
||||||
if (previewHasFailures(mergedPreviewData)) {
|
if (previewHasFailures(mergedPreviewData)) {
|
||||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||||
@@ -1147,45 +1159,6 @@ async function togglePreview() {
|
|||||||
await previewTransfer()
|
await previewTransfer()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据可用高度自动计算每页条数,保持统一行高
|
|
||||||
function updatePreviewPageSize() {
|
|
||||||
const bodyHeight = previewFileBodyRef.value?.clientHeight ?? 0
|
|
||||||
if (bodyHeight <= 0) return
|
|
||||||
|
|
||||||
const firstRow = previewFileBodyRef.value?.querySelector('.preview-file-row')
|
|
||||||
const rowHeight = firstRow?.getBoundingClientRect().height ?? 46
|
|
||||||
const pageSize = Math.max(1, Math.floor(bodyHeight / rowHeight))
|
|
||||||
previewPageSize.value = pageSize
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredPreviewItems.value.length / pageSize))
|
|
||||||
if (previewPage.value > totalPages) {
|
|
||||||
previewPage.value = totalPages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动预览列表高度监听
|
|
||||||
function setupPreviewFileBodyObserver() {
|
|
||||||
previewFileBodyResizeObserver?.disconnect()
|
|
||||||
|
|
||||||
if (!previewFileBodyRef.value || typeof ResizeObserver === 'undefined') return
|
|
||||||
|
|
||||||
previewFileBodyResizeObserver = new ResizeObserver(() => {
|
|
||||||
updatePreviewPageSize()
|
|
||||||
})
|
|
||||||
previewFileBodyResizeObserver.observe(previewFileBodyRef.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => previewLoaded.value, () => previewVisible.value], ([loaded, visible]) => {
|
|
||||||
if (loaded && visible) {
|
|
||||||
nextTick(() => {
|
|
||||||
setupPreviewFileBodyObserver()
|
|
||||||
updatePreviewPageSize()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
previewFileBodyResizeObserver?.disconnect()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 整理文件
|
// 整理文件
|
||||||
async function handleTransfer(item: FileItem, background: boolean = false) {
|
async function handleTransfer(item: FileItem, background: boolean = false) {
|
||||||
try {
|
try {
|
||||||
@@ -1306,7 +1279,6 @@ async function transfer(background: boolean = false) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadDirectories()
|
await loadDirectories()
|
||||||
await autoSelectTargetPath()
|
|
||||||
loadStorages()
|
loadStorages()
|
||||||
loadEpisodeFormatRuleConfiguration()
|
loadEpisodeFormatRuleConfiguration()
|
||||||
})
|
})
|
||||||
@@ -1314,7 +1286,6 @@ onMounted(async () => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopLoadingProgress()
|
stopLoadingProgress()
|
||||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||||
previewFileBodyResizeObserver?.disconnect()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1356,20 +1327,19 @@ onUnmounted(() => {
|
|||||||
<VSelect
|
<VSelect
|
||||||
v-model="transferForm.transfer_type"
|
v-model="transferForm.transfer_type"
|
||||||
:label="t('dialog.reorganize.transferType')"
|
:label="t('dialog.reorganize.transferType')"
|
||||||
:items="transferTypeOptions"
|
:items="manualTransferTypeOptions"
|
||||||
:hint="t('dialog.reorganize.transferTypeHint')"
|
:hint="t('dialog.reorganize.transferTypeHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-swap-horizontal"
|
prepend-inner-icon="mdi-swap-horizontal"
|
||||||
>
|
/>
|
||||||
<template v-slot:selection="{ item }">
|
|
||||||
{{ transferForm.transfer_type === '' ? t('dialog.reorganize.auto') : item.title }}
|
|
||||||
</template>
|
|
||||||
</VSelect>
|
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12">
|
<VCol cols="12">
|
||||||
<VCombobox
|
<VCombobox
|
||||||
v-model="transferForm.target_path"
|
v-model="targetPathSelection"
|
||||||
:items="targetDirectories"
|
:items="targetDirectoryOptions"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:return-object="false"
|
||||||
:label="t('dialog.reorganize.targetPath')"
|
:label="t('dialog.reorganize.targetPath')"
|
||||||
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
:placeholder="t('dialog.reorganize.targetPathPlaceholder')"
|
||||||
:hint="t('dialog.reorganize.targetPathHint')"
|
:hint="t('dialog.reorganize.targetPathHint')"
|
||||||
@@ -1528,7 +1498,7 @@ onUnmounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="transferForm.library_type_folder"
|
v-model="transferForm.library_type_folder"
|
||||||
:label="t('dialog.reorganize.typeFolderOption')"
|
:label="t('dialog.reorganize.typeFolderOption')"
|
||||||
@@ -1536,7 +1506,7 @@ onUnmounted(() => {
|
|||||||
persistent-hint
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="6" v-if="transferForm.target_path">
|
<VCol cols="12" md="6">
|
||||||
<VSwitch
|
<VSwitch
|
||||||
v-model="transferForm.library_category_folder"
|
v-model="transferForm.library_category_folder"
|
||||||
:label="t('dialog.reorganize.categoryFolderOption')"
|
:label="t('dialog.reorganize.categoryFolderOption')"
|
||||||
@@ -1562,35 +1532,39 @@ onUnmounted(() => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
<VCardActions class="reorganize-form-pane__actions pt-3 px-0 pb-0">
|
|
||||||
<VBtn
|
|
||||||
color="info"
|
|
||||||
:variant="previewVisible ? 'tonal' : 'text'"
|
|
||||||
@click="togglePreview"
|
|
||||||
:prepend-icon="previewToggleIcon"
|
|
||||||
class="reorganize-action-btn reorganize-action-btn--preview"
|
|
||||||
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
|
||||||
:loading="previewLoading"
|
|
||||||
>
|
|
||||||
{{ t('dialog.reorganize.previewResult') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
color="success"
|
|
||||||
@click="transfer(true)"
|
|
||||||
prepend-icon="mdi-plus"
|
|
||||||
class="reorganize-action-btn reorganize-action-btn--queue"
|
|
||||||
>
|
|
||||||
{{ t('dialog.reorganize.addToQueue') }}
|
|
||||||
</VBtn>
|
|
||||||
<VBtn
|
|
||||||
@click="transfer(false)"
|
|
||||||
prepend-icon="mdi-arrow-right-bold"
|
|
||||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
|
||||||
>
|
|
||||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
|
||||||
</VBtn>
|
|
||||||
</VCardActions>
|
|
||||||
</div>
|
</div>
|
||||||
|
<VCardActions class="app-dialog-actions reorganize-form-pane__actions">
|
||||||
|
<VBtn
|
||||||
|
color="info"
|
||||||
|
variant="tonal"
|
||||||
|
@click="togglePreview"
|
||||||
|
:prepend-icon="previewToggleIcon"
|
||||||
|
class="reorganize-action-btn reorganize-action-btn--preview"
|
||||||
|
:class="{ 'reorganize-action-btn--active': previewVisible }"
|
||||||
|
:loading="previewLoading"
|
||||||
|
>
|
||||||
|
{{ t('dialog.reorganize.previewResult') }}
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
@click="transfer(true)"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
class="reorganize-action-btn reorganize-action-btn--queue"
|
||||||
|
>
|
||||||
|
{{ t('dialog.reorganize.addToQueue') }}
|
||||||
|
</VBtn>
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="transfer(false)"
|
||||||
|
prepend-icon="mdi-arrow-right-bold"
|
||||||
|
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||||
|
>
|
||||||
|
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="previewVisible" class="reorganize-preview-pane">
|
<div v-show="previewVisible" class="reorganize-preview-pane">
|
||||||
<div class="reorganize-preview-pane__header">
|
<div class="reorganize-preview-pane__header">
|
||||||
@@ -1679,7 +1653,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="reorganize-preview-list">
|
<div class="reorganize-preview-list">
|
||||||
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
|
<div v-if="pagedPreviewRows.length" class="preview-file-body">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in pagedPreviewRows"
|
v-for="(item, index) in pagedPreviewRows"
|
||||||
:key="`${item.source}-${item.target}-${index}`"
|
:key="`${item.source}-${item.target}-${index}`"
|
||||||
@@ -1823,17 +1797,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-form-pane__actions {
|
.reorganize-form-pane__actions {
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-block-start: auto;
|
margin-block-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-action-btn {
|
|
||||||
min-inline-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reorganize-action-btn--active {
|
.reorganize-action-btn--active {
|
||||||
background: rgba(var(--v-theme-info), 0.12);
|
background: rgba(var(--v-theme-info), 0.12);
|
||||||
}
|
}
|
||||||
@@ -1910,6 +1876,8 @@ onUnmounted(() => {
|
|||||||
.preview-overview-card {
|
.preview-overview-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 0.875rem;
|
padding-block: 0.875rem;
|
||||||
@@ -1935,6 +1903,8 @@ onUnmounted(() => {
|
|||||||
.preview-custom-words {
|
.preview-custom-words {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding-block: 0.875rem;
|
padding-block: 0.875rem;
|
||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
@@ -1986,8 +1956,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-custom-words__chip {
|
.preview-custom-words__chip {
|
||||||
|
block-size: auto !important;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
|
min-block-size: 1.5rem;
|
||||||
|
padding-block: 0.25rem;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-preview-pane__scroll {
|
.reorganize-preview-pane__scroll {
|
||||||
@@ -2027,9 +2001,9 @@ onUnmounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-block-end: 1.5rem;
|
margin-block-end: 1.5rem;
|
||||||
margin-inline: 1.5rem;
|
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
|
padding-inline: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-body {
|
.preview-file-body {
|
||||||
@@ -2040,13 +2014,13 @@ onUnmounted(() => {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 1rem;
|
|
||||||
padding-inline: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-row {
|
.preview-file-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
border-radius: 0.5rem;
|
||||||
gap: 0.875rem;
|
gap: 0.875rem;
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
min-block-size: 5.25rem;
|
min-block-size: 5.25rem;
|
||||||
@@ -2055,10 +2029,6 @@ onUnmounted(() => {
|
|||||||
padding-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-file-row + .preview-file-row {
|
|
||||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-file-row--failed {
|
.preview-file-row--failed {
|
||||||
background: rgba(var(--v-theme-error), 0.04);
|
background: rgba(var(--v-theme-error), 0.04);
|
||||||
}
|
}
|
||||||
@@ -2173,15 +2143,9 @@ onUnmounted(() => {
|
|||||||
border-inline-end: none;
|
border-inline-end: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-form-pane__actions {
|
|
||||||
display: grid;
|
|
||||||
justify-content: stretch;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.reorganize-action-btn {
|
.reorganize-action-btn {
|
||||||
inline-size: 100%;
|
|
||||||
min-block-size: 2.75rem;
|
min-block-size: 2.75rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-preview-pane__summary {
|
.reorganize-preview-pane__summary {
|
||||||
@@ -2190,20 +2154,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.reorganize-preview-list {
|
.reorganize-preview-list {
|
||||||
margin-block-end: 1rem;
|
margin-block-end: 1rem;
|
||||||
margin-inline: 1rem;
|
padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 640px) {
|
@media (width <= 640px) {
|
||||||
.reorganize-form-pane__actions {
|
.reorganize-form-pane__actions {
|
||||||
justify-content: stretch;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-action-btn {
|
|
||||||
min-inline-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reorganize-action-btn--primary {
|
.reorganize-action-btn--primary {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
|||||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { hasPermission, filterMenusByPermission } from '@/utils/permission'
|
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -30,41 +30,29 @@ const userStore = useUserStore()
|
|||||||
const globalSettingsStore = useGlobalSettingsStore()
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
const globalSettings = globalSettingsStore.globalSettings
|
const globalSettings = globalSettingsStore.globalSettings
|
||||||
|
|
||||||
// 超级用户
|
|
||||||
const superUser = userStore.superUser
|
|
||||||
|
|
||||||
// 当前用户名
|
// 当前用户名
|
||||||
const userName = userStore.userName
|
const userName = userStore.userName
|
||||||
|
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
const hasSearchPermission = computed(() => {
|
const hasSearchPermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'search')
|
||||||
{
|
})
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
const hasDiscoveryPermission = computed(() => {
|
||||||
},
|
return hasPermission(userPermissions.value, 'discovery')
|
||||||
'search',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasSubscribePermission = computed(() => {
|
const hasSubscribePermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'subscribe')
|
||||||
{
|
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
},
|
|
||||||
'subscribe',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasManagePermission = computed(() => {
|
const hasManagePermission = computed(() => {
|
||||||
return hasPermission(
|
return hasPermission(userPermissions.value, 'manage')
|
||||||
{
|
})
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
const hasAdminPermission = computed(() => {
|
||||||
},
|
return hasPermission(userPermissions.value, 'admin')
|
||||||
'manage',
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
// 是否显示合集搜索项(当SEARCH_SOURCE包含themoviedb时显示)
|
||||||
@@ -79,6 +67,7 @@ const SubscribeItems = ref<Subscribe[]>([])
|
|||||||
const chooseSiteDialog = ref(false)
|
const chooseSiteDialog = ref(false)
|
||||||
const selectedSites = ref<number[]>([])
|
const selectedSites = ref<number[]>([])
|
||||||
const allSites = ref<Site[]>([])
|
const allSites = ref<Site[]>([])
|
||||||
|
const siteSearchType = ref<'torrent' | 'subtitle'>('torrent')
|
||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
const emit = defineEmits(['close', 'update:modelValue'])
|
const emit = defineEmits(['close', 'update:modelValue'])
|
||||||
@@ -139,6 +128,7 @@ function getMenus(): NavMenu[] {
|
|||||||
to: item.to,
|
to: item.to,
|
||||||
header: item.header,
|
header: item.header,
|
||||||
admin: item.admin,
|
admin: item.admin,
|
||||||
|
permission: item.permission,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
// 设置标签页
|
// 设置标签页
|
||||||
@@ -151,6 +141,7 @@ function getMenus(): NavMenu[] {
|
|||||||
to: `/setting?tab=${item.tab}`,
|
to: `/setting?tab=${item.tab}`,
|
||||||
header: '',
|
header: '',
|
||||||
admin: true,
|
admin: true,
|
||||||
|
permission: 'admin',
|
||||||
description: item.description,
|
description: item.description,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -158,12 +149,6 @@ function getMenus(): NavMenu[] {
|
|||||||
return menus
|
return menus
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户权限信息
|
|
||||||
const userPermissions = computed(() => ({
|
|
||||||
is_superuser: userStore.superUser,
|
|
||||||
...userStore.permissions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 匹配的菜单列表
|
// 匹配的菜单列表
|
||||||
const matchedMenuItems = computed(() => {
|
const matchedMenuItems = computed(() => {
|
||||||
if (!searchWord.value) return []
|
if (!searchWord.value) return []
|
||||||
@@ -201,7 +186,7 @@ async function fetchInstalledPlugins() {
|
|||||||
// 匹配的插件列表
|
// 匹配的插件列表
|
||||||
const matchedPluginItems = computed(() => {
|
const matchedPluginItems = computed(() => {
|
||||||
if (!searchWord.value) return []
|
if (!searchWord.value) return []
|
||||||
if (!hasManagePermission.value) return []
|
if (!hasAdminPermission.value) return []
|
||||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||||
return pluginItems.value.filter((item: Plugin) => {
|
return pluginItems.value.filter((item: Plugin) => {
|
||||||
if (!item.plugin_name && !item.plugin_desc) return false
|
if (!item.plugin_name && !item.plugin_desc) return false
|
||||||
@@ -221,7 +206,7 @@ async function fetchSubscribes() {
|
|||||||
// 从接口加载用户站点偏好设置
|
// 从接口加载用户站点偏好设置
|
||||||
const loadUserSitePreferences = async () => {
|
const loadUserSitePreferences = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.get('system/setting/IndexerSites')
|
const result = await api.get('system/setting/public/IndexerSites')
|
||||||
if (result && result.data && result.data.value) {
|
if (result && result.data && result.data.value) {
|
||||||
selectedSites.value = result.data.value
|
selectedSites.value = result.data.value
|
||||||
return
|
return
|
||||||
@@ -247,7 +232,8 @@ async function queryAllSites() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 打开站点选择对话框
|
// 打开站点选择对话框
|
||||||
const openSiteDialog = () => {
|
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||||
|
siteSearchType.value = type
|
||||||
chooseSiteDialog.value = true
|
chooseSiteDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +243,7 @@ const matchedSubscribeItems = computed(() => {
|
|||||||
if (!hasSubscribePermission.value) return []
|
if (!hasSubscribePermission.value) return []
|
||||||
const lowerWord = (searchWord.value as string).toLowerCase()
|
const lowerWord = (searchWord.value as string).toLowerCase()
|
||||||
return SubscribeItems.value.filter((item: Subscribe) => {
|
return SubscribeItems.value.filter((item: Subscribe) => {
|
||||||
return (item.name.toLowerCase().includes(lowerWord) && (superUser || userName === item.username)) || false
|
return (item.name.toLowerCase().includes(lowerWord) && (userStore.superUser || userName === item.username)) || false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -265,12 +251,16 @@ const matchedSubscribeItems = computed(() => {
|
|||||||
function searchSites(sites: number[]) {
|
function searchSites(sites: number[]) {
|
||||||
chooseSiteDialog.value = false
|
chooseSiteDialog.value = false
|
||||||
selectedSites.value = sites
|
selectedSites.value = sites
|
||||||
|
if (siteSearchType.value === 'subtitle') {
|
||||||
|
searchSubtitle()
|
||||||
|
return
|
||||||
|
}
|
||||||
searchTorrent()
|
searchTorrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索资源
|
// 搜索资源
|
||||||
function searchTorrent() {
|
function searchTorrent() {
|
||||||
if (!searchWord.value) return
|
if (!searchWord.value || !hasSearchPermission.value) return
|
||||||
// 记录搜索词
|
// 记录搜索词
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
// 跳转到搜索页面
|
// 跳转到搜索页面
|
||||||
@@ -279,6 +269,7 @@ function searchTorrent() {
|
|||||||
query: {
|
query: {
|
||||||
keyword: searchWord.value,
|
keyword: searchWord.value,
|
||||||
area: 'title',
|
area: 'title',
|
||||||
|
result_type: 'torrent',
|
||||||
sites: selectedSites.value.join(','),
|
sites: selectedSites.value.join(','),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -287,10 +278,27 @@ function searchTorrent() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 搜索字幕资源
|
||||||
|
function searchSubtitle() {
|
||||||
|
if (!searchWord.value || !hasSearchPermission.value) return
|
||||||
|
saveRecentSearches(searchWord.value)
|
||||||
|
router.push({
|
||||||
|
path: '/resource',
|
||||||
|
query: {
|
||||||
|
keyword: searchWord.value,
|
||||||
|
area: 'title',
|
||||||
|
result_type: 'subtitle',
|
||||||
|
sites: selectedSites.value.join(','),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dialog.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
// 跳转媒体搜索页面
|
// 跳转媒体搜索页面
|
||||||
function searchMedia(searchType: string) {
|
function searchMedia(searchType: string) {
|
||||||
// 搜索类型 media/person
|
// 搜索类型 media/person
|
||||||
if (!searchWord.value) return
|
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||||
saveRecentSearches(searchWord.value)
|
saveRecentSearches(searchWord.value)
|
||||||
router.push({
|
router.push({
|
||||||
path: '/browse/media/search',
|
path: '/browse/media/search',
|
||||||
@@ -371,7 +379,7 @@ onMounted(() => {
|
|||||||
searchWordInput.value?.focus()
|
searchWordInput.value?.focus()
|
||||||
}, 500)
|
}, 500)
|
||||||
// 根据权限加载不同的数据
|
// 根据权限加载不同的数据
|
||||||
if (hasManagePermission.value) {
|
if (hasAdminPermission.value) {
|
||||||
fetchInstalledPlugins()
|
fetchInstalledPlugins()
|
||||||
}
|
}
|
||||||
if (hasSubscribePermission.value) {
|
if (hasSubscribePermission.value) {
|
||||||
@@ -413,58 +421,60 @@ onMounted(() => {
|
|||||||
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
<!-- 有搜索词时显示搜索入口和匹配结果 -->
|
||||||
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
<VList lines="two" v-if="searchWord" class="search-list pa-0 py-2">
|
||||||
<!-- 媒体搜索入口 -->
|
<!-- 媒体搜索入口 -->
|
||||||
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
<template v-if="hasDiscoveryPermission">
|
||||||
{{ t('common.media') }}
|
<VListSubheader class="font-weight-medium text-uppercase px-4">
|
||||||
</VListSubheader>
|
{{ t('common.media') }}
|
||||||
|
</VListSubheader>
|
||||||
|
|
||||||
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
<VListItem density="comfortable" link @click="searchMedia('media')" class="search-result-item mx-2 my-1">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<div class="result-icon-wrapper">
|
<div class="result-icon-wrapper">
|
||||||
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
<VIcon icon="mdi-movie-search" size="small" color="medium-emphasis" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle class="font-weight-medium text-body-2">
|
<VListItemTitle class="font-weight-medium text-body-2">
|
||||||
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
{{ t('recommend.categoryMovie') }}、{{ t('recommend.categoryTV') }}
|
||||||
</VListItemTitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||||
{{ t('resource.title') }}
|
{{ t('resource.title') }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<VListItem
|
<VListItem
|
||||||
v-if="showCollectionSearch"
|
v-if="showCollectionSearch"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
link
|
link
|
||||||
@click="searchMedia('collection')"
|
@click="searchMedia('collection')"
|
||||||
class="search-result-item mx-2 my-1"
|
class="search-result-item mx-2 my-1"
|
||||||
>
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<div class="result-icon-wrapper">
|
<div class="result-icon-wrapper">
|
||||||
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
<VIcon icon="mdi-movie-filter" size="small" color="medium-emphasis" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle class="font-weight-medium text-body-2">{{
|
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||||
t('dialog.searchBar.collections')
|
t('dialog.searchBar.collections')
|
||||||
}}</VListItemTitle>
|
}}</VListItemTitle>
|
||||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||||
{{ t('dialog.searchBar.collectionSearch') }}
|
{{ t('dialog.searchBar.collectionSearch') }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
|
||||||
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
<VListItem density="comfortable" link @click="searchMedia('person')" class="search-result-item mx-2 my-1">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<div class="result-icon-wrapper">
|
<div class="result-icon-wrapper">
|
||||||
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
<VIcon icon="mdi-account-search" size="small" color="medium-emphasis" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
<VListItemTitle class="font-weight-medium text-body-2">{{ t('browse.actor') }}</VListItemTitle>
|
||||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||||
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||||
{{ t('dialog.searchBar.actorSearch') }}
|
{{ t('dialog.searchBar.actorSearch') }}
|
||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
</template>
|
||||||
|
|
||||||
<VListItem
|
<VListItem
|
||||||
v-if="hasSubscribePermission"
|
v-if="hasSubscribePermission"
|
||||||
@@ -622,7 +632,34 @@ onMounted(() => {
|
|||||||
variant="tonal"
|
variant="tonal"
|
||||||
color="primary"
|
color="primary"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
@click.stop="openSiteDialog"
|
@click.stop="openSiteDialog('torrent')"
|
||||||
|
>
|
||||||
|
{{ t('dialog.searchBar.selectSites') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
|
||||||
|
<VListItem density="comfortable" link @click="searchSubtitle" class="search-result-item mx-2 my-1">
|
||||||
|
<template #prepend>
|
||||||
|
<div class="result-icon-wrapper">
|
||||||
|
<VIcon icon="mdi-subtitles-outline" size="small" color="medium-emphasis" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<VListItemTitle class="font-weight-medium text-body-2">{{
|
||||||
|
t('dialog.searchBar.searchSubtitlesInSites')
|
||||||
|
}}</VListItemTitle>
|
||||||
|
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||||
|
{{ t('common.search') }} <span class="primary-text font-weight-medium">{{ searchWord }}</span>
|
||||||
|
{{ t('dialog.searchBar.relatedSubtitles') }}
|
||||||
|
</VListItemSubtitle>
|
||||||
|
<template #append>
|
||||||
|
<VBtn
|
||||||
|
v-if="hasManagePermission"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
rounded="pill"
|
||||||
|
@click.stop="openSiteDialog('subtitle')"
|
||||||
>
|
>
|
||||||
{{ t('dialog.searchBar.selectSites') }}
|
{{ t('dialog.searchBar.selectSites') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
|||||||
@@ -175,10 +175,11 @@ const filteredSites = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
color="primary"
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
:disabled="selectedSites.length === 0"
|
:disabled="selectedSites.length === 0"
|
||||||
@click="confirmSearch"
|
@click="confirmSearch"
|
||||||
prepend-icon="mdi-magnify"
|
prepend-icon="mdi-magnify"
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const visible = computed({
|
|||||||
function allLoggingUrl() {
|
function allLoggingUrl() {
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拼接主程序日志下载 URL。 */
|
||||||
|
function allLoggingDownloadUrl() {
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/logging/download/moviepilot`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,12 +49,20 @@ function allLoggingUrl() {
|
|||||||
<VCardTitle class="d-inline-flex">
|
<VCardTitle class="d-inline-flex">
|
||||||
<VIcon icon="mdi-file-document" class="me-2" />
|
<VIcon icon="mdi-file-document" class="me-2" />
|
||||||
{{ t('shortcut.log.subtitle') }}
|
{{ t('shortcut.log.subtitle') }}
|
||||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
|
||||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
<VChip color="grey-darken-1" size="small">
|
||||||
{{ t('common.openInNewWindow') }}
|
<VIcon icon="mdi-download" size="small" start />
|
||||||
</VChip>
|
{{ t('common.download') }}
|
||||||
</a>
|
</VChip>
|
||||||
|
</a>
|
||||||
|
<a class="d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||||
|
<VChip color="grey-darken-1" size="small">
|
||||||
|
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||||
|
{{ t('common.openInNewWindow') }}
|
||||||
|
</VChip>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import api from '@/api'
|
|
||||||
import { clearAppBadge } from '@/utils/badge'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
|
|
||||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
|
||||||
|
|
||||||
type MessageViewExpose = {
|
|
||||||
pauseSSE?: () => void
|
|
||||||
resumeSSE?: () => void
|
|
||||||
refreshLatestMessages?: () => Promise<void> | void
|
|
||||||
forceScrollToEnd?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// 国际化
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// 显示器宽度
|
|
||||||
const display = useDisplay()
|
|
||||||
|
|
||||||
// 输入参数
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
|
||||||
const emit = defineEmits(['update:modelValue', 'close'])
|
|
||||||
|
|
||||||
// 弹窗显示状态
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: value => {
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
if (!value) emit('close')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 输入消息
|
|
||||||
const user_message = ref('')
|
|
||||||
|
|
||||||
// 发送按钮是否可用
|
|
||||||
const sendButtonDisabled = ref(false)
|
|
||||||
|
|
||||||
// 消息视图引用
|
|
||||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
|
||||||
|
|
||||||
/** 发送 Web 消息。 */
|
|
||||||
async function sendMessage() {
|
|
||||||
const messageText = user_message.value.trim()
|
|
||||||
if (!messageText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
sendButtonDisabled.value = true
|
|
||||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
|
||||||
user_message.value = ''
|
|
||||||
messageViewRef.value?.forceScrollToEnd?.()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
sendButtonDisabled.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(visible, async newValue => {
|
|
||||||
if (newValue) {
|
|
||||||
await nextTick()
|
|
||||||
messageViewRef.value?.resumeSSE?.()
|
|
||||||
messageViewRef.value?.forceScrollToEnd?.()
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
void clearAppBadge()
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messageViewRef.value?.pauseSSE?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
messageViewRef.value?.resumeSSE?.()
|
|
||||||
messageViewRef.value?.forceScrollToEnd?.()
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
void clearAppBadge()
|
|
||||||
}, 500)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
messageViewRef.value?.pauseSSE?.()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
|
||||||
<VCard>
|
|
||||||
<VCardItem>
|
|
||||||
<VCardTitle>
|
|
||||||
<VIcon icon="mdi-message" class="me-2" />
|
|
||||||
{{ t('shortcut.message.subtitle') }}
|
|
||||||
</VCardTitle>
|
|
||||||
<VDialogCloseBtn v-model="visible" />
|
|
||||||
</VCardItem>
|
|
||||||
<VDivider />
|
|
||||||
<VCardText>
|
|
||||||
<MessageView ref="messageViewRef" />
|
|
||||||
</VCardText>
|
|
||||||
<VDivider />
|
|
||||||
<VCardActions class="pa-4">
|
|
||||||
<div class="d-flex w-100 gap-2">
|
|
||||||
<VTextField
|
|
||||||
v-model="user_message"
|
|
||||||
variant="outlined"
|
|
||||||
hide-details
|
|
||||||
density="compact"
|
|
||||||
:placeholder="t('common.inputMessage')"
|
|
||||||
@keyup.enter="sendMessage"
|
|
||||||
/>
|
|
||||||
<VBtn
|
|
||||||
variant="elevated"
|
|
||||||
:disabled="sendButtonDisabled"
|
|
||||||
@click="sendMessage"
|
|
||||||
:loading="sendButtonDisabled"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-send"
|
|
||||||
>{{ t('common.send') }}
|
|
||||||
</VBtn>
|
|
||||||
</div>
|
|
||||||
</VCardActions>
|
|
||||||
</VCard>
|
|
||||||
</VDialog>
|
|
||||||
</template>
|
|
||||||
@@ -39,10 +39,21 @@ const visible = computed({
|
|||||||
if (!value) emit('close')
|
if (!value) emit('close')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||||
|
|
||||||
|
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||||
|
const bodyClasses = computed(() => [
|
||||||
|
props.bodyClass,
|
||||||
|
{
|
||||||
|
'system-health-dialog-body--fullscreen':
|
||||||
|
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
|
||||||
|
},
|
||||||
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
|
||||||
<VCard :class="props.cardClass">
|
<VCard :class="props.cardClass">
|
||||||
<VCardItem>
|
<VCardItem>
|
||||||
<VCardTitle>
|
<VCardTitle>
|
||||||
@@ -53,7 +64,7 @@ const visible = computed({
|
|||||||
<VDialogCloseBtn v-model="visible" />
|
<VDialogCloseBtn v-model="visible" />
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VCardText :class="props.bodyClass">
|
<VCardText :class="bodyClasses">
|
||||||
<Component :is="props.view" v-bind="props.viewProps" />
|
<Component :is="props.view" v-bind="props.viewProps" />
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -61,8 +72,6 @@ const visible = computed({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
|
||||||
|
|
||||||
.system-health-dialog-card {
|
.system-health-dialog-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -78,7 +87,7 @@ const visible = computed({
|
|||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
.system-health-dialog-body--fullscreen {
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -340,12 +340,26 @@ onMounted(async () => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn v-if="props.oper === 'add'" color="primary" @click="addSite" prepend-icon="mdi-plus" class="px-5">
|
<VBtn
|
||||||
|
v-if="props.oper === 'add'"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="addSite"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
{{ t('site.actions.add') }}
|
{{ t('site.actions.add') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn v-else color="primary" @click="updateSiteInfo" prepend-icon="mdi-content-save" class="px-5">
|
<VBtn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="updateSiteInfo"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -110,9 +110,11 @@ async function updateSiteCookie() {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="mx-auto">
|
<VCardActions class="app-dialog-actions">
|
||||||
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
size="large"
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click="updateSiteCookie"
|
@click="updateSiteCookie"
|
||||||
:disabled="updateButtonDisable"
|
:disabled="updateButtonDisable"
|
||||||
:loading="updateButtonDisable"
|
:loading="updateButtonDisable"
|
||||||
|
|||||||
@@ -475,26 +475,26 @@ onMounted(() => {
|
|||||||
:items="mobileResourceList"
|
:items="mobileResourceList"
|
||||||
:columns="1"
|
:columns="1"
|
||||||
:gap="12"
|
:gap="12"
|
||||||
:estimated-item-height="320"
|
:estimated-item-height="220"
|
||||||
:overscan-rows="5"
|
:overscan-rows="5"
|
||||||
:get-item-key="getResourceItemKey"
|
:get-item-key="getResourceItemKey"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<VCard>
|
<VCard class="site-resource-card" variant="flat">
|
||||||
<VCardText class="pa-4">
|
<VCardText class="pa-3">
|
||||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="item.description"
|
v-if="item.description"
|
||||||
class="site-resource-card__description mt-2 text-body-2 text-medium-emphasis"
|
class="site-resource-card__description mt-1 text-body-2 text-medium-emphasis"
|
||||||
>
|
>
|
||||||
{{ item.description }}
|
{{ item.description }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="site-resource-card__chips mt-2">
|
||||||
<VChip
|
<VChip
|
||||||
v-if="item.hit_and_run"
|
v-if="item.hit_and_run"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@@ -533,47 +533,82 @@ onMounted(() => {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="site-resource-card__meta mt-4">
|
<!-- 移动端在操作区前展示关键资源指标,方便点击前快速判断。 -->
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__summary mt-3">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
<div class="site-resource-card__stat">
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
<VIcon icon="mdi-clock-outline" size="15" />
|
||||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
<VIcon icon="mdi-harddisk" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
<span>{{ formatFileSize(item.size) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat site-resource-card__stat--success">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
<VIcon icon="mdi-arrow-up" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
<span>{{ item.seeders ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-resource-card__meta-item">
|
<div class="site-resource-card__stat site-resource-card__stat--warning">
|
||||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
<VIcon icon="mdi-arrow-down" size="15" />
|
||||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
<span>{{ item.peers ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="site-resource-card__actions mt-4">
|
<!-- 下载保留文本,其它低频操作改为图标按钮并保持同一行。 -->
|
||||||
<VBtn color="primary" variant="flat" block prepend-icon="mdi-download" @click="addDownload(item)">
|
<div class="site-resource-card__actions mt-2">
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
class="site-resource-card__download-btn"
|
||||||
|
prepend-icon="mdi-download"
|
||||||
|
@click="addDownload(item)"
|
||||||
|
>
|
||||||
{{ t('actionStep.addDownload') }}
|
{{ t('actionStep.addDownload') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<div class="site-resource-card__secondary-actions mt-2">
|
<VTooltip :text="t('common.viewDetails')" location="top">
|
||||||
<VBtn
|
<template #activator="{ props: tooltipProps }">
|
||||||
variant="tonal"
|
<VBtn
|
||||||
prepend-icon="mdi-open-in-new"
|
v-bind="tooltipProps"
|
||||||
@click="openTorrentDetail(item.page_url || '')"
|
icon
|
||||||
>
|
variant="tonal"
|
||||||
{{ t('common.viewDetails') }}
|
color="primary"
|
||||||
</VBtn>
|
class="site-resource-card__icon-btn"
|
||||||
<VBtn
|
:aria-label="t('common.viewDetails')"
|
||||||
v-if="item.enclosure?.startsWith('http')"
|
@click="openTorrentDetail(item.page_url || '')"
|
||||||
variant="tonal"
|
>
|
||||||
prepend-icon="mdi-tray-arrow-down"
|
<VIcon icon="mdi-open-in-new" />
|
||||||
@click="downloadTorrentFile(item.enclosure)"
|
</VBtn>
|
||||||
>
|
</template>
|
||||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
</VTooltip>
|
||||||
</VBtn>
|
<VTooltip
|
||||||
</div>
|
v-if="item.enclosure?.startsWith('http')"
|
||||||
|
:text="t('dialog.siteResource.downloadTorrent')"
|
||||||
|
location="top"
|
||||||
|
>
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<VBtn
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
icon
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
class="site-resource-card__icon-btn"
|
||||||
|
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||||
|
@click="downloadTorrentFile(item.enclosure)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-file-download-outline" />
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
</VTooltip>
|
||||||
|
<VBtn
|
||||||
|
v-else
|
||||||
|
icon
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
disabled
|
||||||
|
class="site-resource-card__icon-btn"
|
||||||
|
:aria-label="t('dialog.siteResource.downloadTorrent')"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-file-download-outline" />
|
||||||
|
</VBtn>
|
||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
@@ -702,44 +737,107 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-resource-card {
|
||||||
|
--site-resource-card-bg:
|
||||||
|
linear-gradient(180deg, rgba(var(--v-theme-surface), 0.98), rgba(var(--v-theme-surface), 0.94)),
|
||||||
|
radial-gradient(circle at top right, rgba(var(--v-theme-primary), 0.08), transparent 34%);
|
||||||
|
|
||||||
|
border: 1px solid rgba(var(--v-border-color), calc(var(--v-border-opacity) * 0.9));
|
||||||
|
background: var(--site-resource-card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||||
|
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||||
|
|
||||||
|
backdrop-filter: blur(var(--transparent-blur));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__summary {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(2.5rem, 0.62fr) minmax(2.5rem, 0.62fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__stat {
|
||||||
|
display: inline-flex;
|
||||||
|
overflow: hidden;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.22rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
min-block-size: 1.65rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
padding-inline: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__stat span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__stat--success {
|
||||||
|
color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__stat--warning {
|
||||||
|
color: rgb(var(--v-theme-warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__title {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-height: 1.38;
|
||||||
|
}
|
||||||
|
|
||||||
.site-resource-card__description {
|
.site-resource-card__description {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 2;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta {
|
.site-resource-card__chips {
|
||||||
|
max-block-size: 4.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.45rem;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item {
|
.site-resource-card__download-btn {
|
||||||
background: rgba(var(--v-theme-surface), 0.78);
|
min-block-size: 2.5rem;
|
||||||
min-block-size: 0;
|
min-inline-size: 0;
|
||||||
padding-block: 0.55rem;
|
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
|
||||||
padding-inline: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item :deep(.text-caption) {
|
.site-resource-card__download-btn :deep(.v-btn__content) {
|
||||||
font-size: 0.72rem !important;
|
overflow: hidden;
|
||||||
line-height: 1.2;
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
.site-resource-card__icon-btn {
|
||||||
font-size: 0.82rem !important;
|
block-size: 2.5rem;
|
||||||
line-height: 1.25;
|
inline-size: 2.5rem;
|
||||||
|
min-inline-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-resource-card__secondary-actions {
|
.site-resource-card__icon-btn :deep(.v-btn__content) {
|
||||||
display: flex;
|
font-size: 1.05rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
|
||||||
flex: 1 1 12rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width >= 960px) {
|
@media (width >= 960px) {
|
||||||
@@ -761,4 +859,14 @@ onMounted(() => {
|
|||||||
min-block-size: 2.5rem;
|
min-block-size: 2.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 420px) {
|
||||||
|
.site-resource-card__summary {
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr) minmax(2.3rem, 0.55fr) minmax(2.3rem, 0.55fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-card__stat {
|
||||||
|
padding-inline: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ async function saveSmbConfig() {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||||
{{ t('dialog.smbConfig.reset') }}
|
{{ t('dialog.smbConfig.reset') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn @click="handleDone" prepend-icon="mdi-check" class="px-5 me-3">
|
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-check" class="px-5">
|
||||||
{{ t('dialog.smbConfig.complete') }}
|
{{ t('dialog.smbConfig.complete') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ function handleDone() {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
<VSpacer />
|
||||||
|
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||||
{{ t('common.save') }}
|
{{ t('common.save') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import { useDisplay } from 'vuetify'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
// i18n
|
// i18n
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
@@ -128,6 +134,8 @@ async function loadDownloaderSetting() {
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||||
filterRuleGroups.value = result.data?.value ?? []
|
filterRuleGroups.value = result.data?.value ?? []
|
||||||
@@ -163,6 +171,8 @@ async function updateSubscribeInfo() {
|
|||||||
|
|
||||||
// 设置用户设置的默认订阅规则
|
// 设置用户设置的默认订阅规则
|
||||||
async function saveDefaultSubscribeConfig() {
|
async function saveDefaultSubscribeConfig() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
||||||
@@ -183,8 +193,8 @@ async function saveDefaultSubscribeConfig() {
|
|||||||
async function queryDefaultSubscribeConfig() {
|
async function queryDefaultSubscribeConfig() {
|
||||||
try {
|
try {
|
||||||
let subscribe_config_url = ''
|
let subscribe_config_url = ''
|
||||||
if (props.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
|
if (props.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||||
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
|
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||||
|
|
||||||
@@ -260,7 +270,7 @@ async function removeSubscribe() {
|
|||||||
// 查询下载目录
|
// 查询下载目录
|
||||||
async function loadDownloadDirectories() {
|
async function loadDownloadDirectories() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||||
if (result.success && result.data?.value) {
|
if (result.success && result.data?.value) {
|
||||||
downloadDirectories.value = result.data.value
|
downloadDirectories.value = result.data.value
|
||||||
}
|
}
|
||||||
@@ -549,12 +559,14 @@ onMounted(() => {
|
|||||||
</VWindow>
|
</VWindow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
|
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
|
||||||
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
|
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||||
prepend-icon="mdi-content-save"
|
prepend-icon="mdi-content-save"
|
||||||
class="px-5"
|
class="px-5"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||||
import { PropType } from 'vue'
|
import { PropType } from 'vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
|
||||||
|
|||||||
@@ -105,9 +105,17 @@ const $toast = useToast()
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:disabled="shareDoing"
|
||||||
|
@click="doShare"
|
||||||
|
prepend-icon="mdi-share"
|
||||||
|
class="px-5"
|
||||||
|
:loading="shareDoing"
|
||||||
|
>
|
||||||
{{ t('dialog.subscribeShare.confirmShare') }}
|
{{ t('dialog.subscribeShare.confirmShare') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||||
|
margin: 4px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.v-chip--selected {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||||
|
color: rgb(var(--v-theme-on-primary)) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
|
|||||||
max-block-size: 60vh;
|
max-block-size: 60vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chip-season {
|
||||||
|
background-color: #3f51b5;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-free {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-discount {
|
||||||
|
background-color: #ff5722;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-bonus {
|
||||||
|
background-color: #9c27b0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
|
|||||||
@update:model-value="updateFilter"
|
@update:model-value="updateFilter"
|
||||||
>
|
>
|
||||||
<VChip
|
<VChip
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option"
|
:key="option"
|
||||||
:value="option"
|
:value="option"
|
||||||
filter
|
filter
|
||||||
@@ -97,12 +97,39 @@ function updateFilter(values: string[]) {
|
|||||||
</VChip>
|
</VChip>
|
||||||
</VChipGroup>
|
</VChipGroup>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
<VBtn color="primary" variant="flat" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||||
{{ t('torrent.confirm') }}
|
{{ t('torrent.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||||
|
margin: 4px;
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.1) !important;
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.9) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip:hover {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip.v-chip--selected {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.85) !important;
|
||||||
|
box-shadow: 0 2px 4px rgba(var(--v-theme-primary), 0.3);
|
||||||
|
color: rgb(var(--v-theme-on-primary)) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -225,11 +225,11 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VBtn
|
<VBtn
|
||||||
color="error"
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
prepend-icon="mdi-restore"
|
prepend-icon="mdi-restore"
|
||||||
class="px-5 me-3"
|
|
||||||
@click="handleReset"
|
@click="handleReset"
|
||||||
>
|
>
|
||||||
{{ t('dialog.u115Auth.reset') }}
|
{{ t('dialog.u115Auth.reset') }}
|
||||||
@@ -238,8 +238,10 @@ onUnmounted(() => {
|
|||||||
<VSpacer />
|
<VSpacer />
|
||||||
|
|
||||||
<VBtn
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
prepend-icon="mdi-check"
|
prepend-icon="mdi-check"
|
||||||
class="px-5 me-3"
|
class="px-5"
|
||||||
@click="handleDone"
|
@click="handleDone"
|
||||||
>
|
>
|
||||||
{{ t('dialog.u115Auth.complete') }}
|
{{ t('dialog.u115Auth.complete') }}
|
||||||
|
|||||||
@@ -612,12 +612,13 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn
|
<VBtn
|
||||||
v-if="props.oper === 'add'"
|
v-if="props.oper === 'add'"
|
||||||
:disabled="isAdding"
|
:disabled="isAdding"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click="addUser"
|
@click="addUser"
|
||||||
prepend-icon="mdi-plus"
|
prepend-icon="mdi-plus"
|
||||||
class="px-5"
|
class="px-5"
|
||||||
@@ -629,6 +630,7 @@ onMounted(() => {
|
|||||||
v-else
|
v-else
|
||||||
:disabled="isUpdating"
|
:disabled="isUpdating"
|
||||||
color="primary"
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
@click="updateUser"
|
@click="updateUser"
|
||||||
prepend-icon="mdi-content-save"
|
prepend-icon="mdi-content-save"
|
||||||
class="px-5"
|
class="px-5"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import useDragAndDrop from '@core/utils/workflow'
|
|||||||
import { Workflow } from '@/api/types'
|
import { Workflow } from '@/api/types'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
|
||||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
|||||||
@@ -312,12 +312,19 @@ onMounted(() => {
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn v-if="workflow" color="primary" @click="editWorkflow" prepend-icon="mdi-content-save" class="px-5">
|
<VBtn
|
||||||
|
v-if="workflow"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="editWorkflow"
|
||||||
|
prepend-icon="mdi-content-save"
|
||||||
|
class="px-5"
|
||||||
|
>
|
||||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
<VBtn v-else color="primary" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
<VBtn v-else color="primary" variant="flat" @click="addWorkflow" prepend-icon="mdi-plus" class="px-5">
|
||||||
{{ t('dialog.workflowAddEdit.confirm') }}
|
{{ t('dialog.workflowAddEdit.confirm') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -125,9 +125,17 @@ const $toast = useToast()
|
|||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardActions class="pt-3">
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn :disabled="shareDoing" @click="doShare" prepend-icon="mdi-share" class="px-5" :loading="shareDoing">
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
:disabled="shareDoing"
|
||||||
|
@click="doShare"
|
||||||
|
prepend-icon="mdi-share"
|
||||||
|
class="px-5"
|
||||||
|
:loading="shareDoing"
|
||||||
|
>
|
||||||
{{ t('dialog.workflowShare.confirmShare') }}
|
{{ t('dialog.workflowShare.confirmShare') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FileList from './filebrowser/FileList.vue'
|
import FileList from './FileList.vue'
|
||||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
import FileToolbar from './FileToolbar.vue'
|
||||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
import FileNavigator from './FileNavigator.vue'
|
||||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||||
import { storageIconDict } from '@/api/constants'
|
import { storageIconDict } from '@/api/constants'
|
||||||
import type { AxiosInstance } from 'axios'
|
import type { AxiosInstance } from 'axios'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
// LocalStorage keys
|
// LocalStorage keys
|
||||||
const SORT_KEY = 'fileBrowser.sort'
|
const SORT_KEY = 'fileBrowser.sort'
|
||||||
@@ -41,6 +43,10 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['pathchanged'])
|
const emit = defineEmits(['pathchanged'])
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { appMode } = usePWA()
|
const { appMode } = usePWA()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canManage = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'manage'),
|
||||||
|
)
|
||||||
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
const toolbarRef = ref<InstanceType<typeof FileToolbar> | null>(null)
|
||||||
|
|
||||||
const fileIcons = {
|
const fileIcons = {
|
||||||
@@ -136,11 +142,12 @@ function openNewFolderDialog() {
|
|||||||
toolbarRef.value?.openNewFolderDialog()
|
toolbarRef.value?.openNewFolderDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager')
|
const showFloatingNewFolderAction = computed(() => route.path === '/filemanager' && canManage.value)
|
||||||
|
|
||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-folder-plus-outline',
|
icon: 'mdi-folder-plus-outline',
|
||||||
onClick: openNewFolderDialog,
|
onClick: openNewFolderDialog,
|
||||||
|
permission: 'manage',
|
||||||
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
show: computed(() => appMode.value && showFloatingNewFolderAction.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ onMounted(() => {
|
|||||||
:key="key"
|
:key="key"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
size="small"
|
size="small"
|
||||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
color="primary"
|
||||||
:prepend-icon="getFilterIcon(key)"
|
:prepend-icon="getFilterIcon(key)"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
rounded="pill"
|
rounded="pill"
|
||||||
@@ -555,7 +555,7 @@ onMounted(() => {
|
|||||||
v-for="(title, key) in filterTitles"
|
v-for="(title, key) in filterTitles"
|
||||||
v-show="filterOptions[key].length > 0"
|
v-show="filterOptions[key].length > 0"
|
||||||
:key="key"
|
:key="key"
|
||||||
variant="text"
|
variant="tonal"
|
||||||
color="primary"
|
color="primary"
|
||||||
class="filter-btn-mobile"
|
class="filter-btn-mobile"
|
||||||
@click="toggleFilterMenu(key)"
|
@click="toggleFilterMenu(key)"
|
||||||
@@ -575,7 +575,7 @@ onMounted(() => {
|
|||||||
</VBtn>
|
</VBtn>
|
||||||
|
|
||||||
<!-- 全部筛选按钮 -->
|
<!-- 全部筛选按钮 -->
|
||||||
<VBtn variant="text" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
<VBtn variant="tonal" color="primary" class="filter-btn-mobile" @click="toggleAllFilterMenu">
|
||||||
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
<VIcon icon="mdi-filter-variant" class="filter-icon me-1"></VIcon>
|
||||||
<span class="filter-label">
|
<span class="filter-label">
|
||||||
{{ t('torrent.allFilters') }}
|
{{ t('torrent.allFilters') }}
|
||||||
@@ -665,7 +665,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +732,6 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
min-block-size: 48px;
|
min-block-size: 48px;
|
||||||
padding-block: 4px;
|
padding-block: 4px;
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
|
|||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||||
|
|
||||||
// 初始化 markdown-it
|
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML,避免外部内容注入脚本或事件属性。
|
||||||
const md = new MarkdownIt({
|
const md = new MarkdownIt({
|
||||||
html: true,
|
html: false,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true,
|
typographer: true,
|
||||||
})
|
})
|
||||||
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
|
|||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
history: Object as PropType<{ [key: string]: string }>,
|
history: Object as PropType<{ [key: string]: string }>,
|
||||||
|
hasAction: Function as PropType<(version: string) => boolean>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function shouldRenderAction(version: string) {
|
||||||
|
return props.hasAction?.(version) ?? true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCardText>
|
<VCardText class="version-history">
|
||||||
<VList>
|
<div class="version-history__list">
|
||||||
<VListItem v-for="(value, key) in props.history" :key="key">
|
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
|
||||||
<VListItemTitle class="font-bold text-lg">
|
<div
|
||||||
{{ key }}
|
class="version-history__top"
|
||||||
</VListItemTitle>
|
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
|
||||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
>
|
||||||
</VListItem>
|
<div class="version-history__header">
|
||||||
</VList>
|
<div class="version-history__version">
|
||||||
|
{{ key }}
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.meta" class="version-history__meta">
|
||||||
|
<slot name="meta" :version="String(key)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.action && shouldRenderAction(String(key))" class="version-history__action">
|
||||||
|
<slot name="action" :version="String(key)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="markdown-body text-medium-emphasis" v-html="renderMarkdown(value)" />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.version-history {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__item {
|
||||||
|
padding: 1.25rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__item + .version-history__item {
|
||||||
|
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-areas: "main";
|
||||||
|
gap: 0;
|
||||||
|
align-items: center;
|
||||||
|
margin-block-end: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__top--with-action {
|
||||||
|
grid-template-columns: minmax(0, 1fr) max-content;
|
||||||
|
grid-template-areas: "main action";
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__header {
|
||||||
|
grid-area: main;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__version {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__meta {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__action {
|
||||||
|
grid-area: action;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-body :deep(h1),
|
.markdown-body :deep(h1),
|
||||||
.markdown-body :deep(h2),
|
.markdown-body :deep(h2),
|
||||||
.markdown-body :deep(h3) {
|
.markdown-body :deep(h3) {
|
||||||
@@ -112,4 +189,28 @@ const props = defineProps({
|
|||||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||||
color: rgba(127, 127, 127, 0.8);
|
color: rgba(127, 127, 127, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.version-history {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__item {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__top--with-action {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-history__version {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ const instructions = computed(() => {
|
|||||||
</VAlert>
|
</VAlert>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
|
|
||||||
<VCardActions>
|
<VCardActions class="app-dialog-actions">
|
||||||
<VSpacer />
|
<VSpacer />
|
||||||
<VBtn color="primary" variant="text" @click="showInstructions = false">
|
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
|
||||||
{{ t('pwa.gotIt') }}
|
{{ t('pwa.gotIt') }}
|
||||||
</VBtn>
|
</VBtn>
|
||||||
</VCardActions>
|
</VCardActions>
|
||||||
894
src/components/theme/ThemeCustomizer.vue
Normal file
894
src/components/theme/ThemeCustomizer.vue
Normal file
@@ -0,0 +1,894 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
themeCustomizerPrimaryColors,
|
||||||
|
themeCustomizerShadowLevels,
|
||||||
|
useThemeCustomizer,
|
||||||
|
type ThemeCustomizerLayout,
|
||||||
|
type ThemeCustomizerRadius,
|
||||||
|
type ThemeCustomizerShadow,
|
||||||
|
type ThemeCustomizerSkin,
|
||||||
|
type ThemeCustomizerTheme,
|
||||||
|
} from '@/composables/useThemeCustomizer'
|
||||||
|
import { usePWA } from '@/composables/usePWA'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'close': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const customColorInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
isCustomized,
|
||||||
|
resetSettings,
|
||||||
|
setLayout,
|
||||||
|
setPrimaryColor,
|
||||||
|
setRadius,
|
||||||
|
setSemiDarkMenu,
|
||||||
|
setShadow,
|
||||||
|
setSkin,
|
||||||
|
setTheme,
|
||||||
|
settings,
|
||||||
|
} = useThemeCustomizer()
|
||||||
|
const { appMode } = usePWA()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { global: globalTheme } = useTheme()
|
||||||
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
|
|
||||||
|
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
|
||||||
|
function syncThemeCustomizerOpenState(isOpen: boolean) {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.documentElement.setAttribute('data-theme-customizer-open', 'true')
|
||||||
|
document.body.setAttribute('data-theme-customizer-open', 'true')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.removeAttribute('data-theme-customizer-open')
|
||||||
|
document.body.removeAttribute('data-theme-customizer-open')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时清理根节点状态,避免路由切换后悬浮按钮继续保持让位。
|
||||||
|
function clearThemeCustomizerOpenState() {
|
||||||
|
syncThemeCustomizerOpenState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||||
|
// 固定侧栏不再依赖 Vuetify overlay,手动补上常见的 Esc 关闭行为。
|
||||||
|
if (event.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 面板一挂载就代表已打开,及时同步根节点状态让全局 FAB 预留右侧空间。
|
||||||
|
syncThemeCustomizerOpenState(true)
|
||||||
|
window.addEventListener('keydown', handleGlobalKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onScopeDispose(clearThemeCustomizerOpenState)
|
||||||
|
onScopeDispose(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerTheme }>>(() => [
|
||||||
|
{ title: t('theme.light'), value: 'light', icon: 'mdi-white-balance-sunny' },
|
||||||
|
{ title: t('theme.dark'), value: 'dark', icon: 'mdi-weather-night' },
|
||||||
|
{ title: t('theme.auto'), value: 'auto', icon: 'mdi-monitor' },
|
||||||
|
{ title: t('theme.purple'), value: 'purple', icon: 'mdi-theme-light-dark' },
|
||||||
|
{ title: t('theme.transparent'), value: 'transparent', icon: 'mdi-blur' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }>>(() => [
|
||||||
|
{ title: t('theme.customizer.skinDefault'), value: 'default' },
|
||||||
|
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 当前阴影滑杆数值,界面使用 number,主题设置继续存储 Vuetify elevation 字符串档位。
|
||||||
|
const shadowSliderValue = computed(() => Number(settings.value.shadow))
|
||||||
|
|
||||||
|
const radiusOptions = computed<
|
||||||
|
Array<{
|
||||||
|
title: string
|
||||||
|
value: ThemeCustomizerRadius
|
||||||
|
}>
|
||||||
|
>(() => [
|
||||||
|
{
|
||||||
|
title: t('theme.customizer.radiusNone'),
|
||||||
|
value: 'none',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('theme.customizer.radiusSmall'),
|
||||||
|
value: 'small',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('theme.customizer.radiusDefault'),
|
||||||
|
value: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('theme.customizer.radiusLarge'),
|
||||||
|
value: 'large',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('theme.customizer.radiusExtra'),
|
||||||
|
value: 'extra',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
|
||||||
|
{ title: t('theme.customizer.layoutVertical'), value: 'vertical', icon: 'mdi-dock-left' },
|
||||||
|
{ title: t('theme.customizer.layoutCollapsed'), value: 'collapsed', icon: 'mdi-dock-window' },
|
||||||
|
{ title: t('theme.customizer.layoutHorizontal'), value: 'horizontal', icon: 'mdi-dock-top' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const showLayoutSection = computed(() => !appMode.value)
|
||||||
|
|
||||||
|
const hasAppModeCustomization = computed(() => {
|
||||||
|
return (
|
||||||
|
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||||
|
settings.value.radius !== 'default' ||
|
||||||
|
settings.value.shadow !== '0' ||
|
||||||
|
settings.value.skin !== 'default' ||
|
||||||
|
settings.value.theme !== 'auto'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const showResetBadge = computed(() => (appMode.value ? hasAppModeCustomization.value : isCustomized.value))
|
||||||
|
|
||||||
|
const showSemiDarkMenuOption = computed(() => {
|
||||||
|
return (
|
||||||
|
!appMode.value &&
|
||||||
|
!globalTheme.current.value.dark &&
|
||||||
|
(settings.value.layout === 'vertical' || settings.value.layout === 'collapsed')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function openColorPicker() {
|
||||||
|
customColorInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomColorInput(event: Event) {
|
||||||
|
const color = (event.target as HTMLInputElement).value
|
||||||
|
|
||||||
|
setPrimaryColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||||
|
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
|
||||||
|
if (appMode.value) return
|
||||||
|
|
||||||
|
setLayout(layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将 Vuetify 滑杆的数字步进写回字符串型 elevation 档位。
|
||||||
|
function handleShadowSliderChange(value: unknown) {
|
||||||
|
const rawValue = Array.isArray(value) ? value[0] : value
|
||||||
|
const numericValue = Number(rawValue)
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue)) return
|
||||||
|
|
||||||
|
const clampedValue = Math.min(24, Math.max(0, Math.round(numericValue)))
|
||||||
|
const shadow = String(clampedValue) as ThemeCustomizerShadow
|
||||||
|
|
||||||
|
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetSettings() {
|
||||||
|
if (!appMode.value) {
|
||||||
|
await resetSettings()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||||
|
await setPrimaryColor(defaultPrimaryColor)
|
||||||
|
await setRadius('default')
|
||||||
|
await setShadow('0')
|
||||||
|
await setSkin('default')
|
||||||
|
await setTheme('auto')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="theme-customizer-panel-host"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="t('theme.customizer.title')"
|
||||||
|
>
|
||||||
|
<div class="theme-customizer-panel" :class="{ 'theme-customizer-panel--dialog': appMode, 'app-surface': appMode }">
|
||||||
|
<div class="theme-customizer-header py-5 px-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="theme-customizer-title">{{ t('theme.customizer.title') }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="theme-customizer-header-actions">
|
||||||
|
<VBadge color="error" dot :model-value="showResetBadge" location="top end" offset-x="2" offset-y="2">
|
||||||
|
<IconBtn :aria-label="t('theme.customizer.reset')" @click="handleResetSettings">
|
||||||
|
<VIcon class="text-high-emphasis" icon="mdi-refresh" />
|
||||||
|
</IconBtn>
|
||||||
|
</VBadge>
|
||||||
|
<IconBtn :aria-label="t('common.close')" @click="emit('close')">
|
||||||
|
<VIcon class="text-high-emphasis" icon="mdi-close" />
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
|
||||||
|
<section class="theme-customizer-section">
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
|
||||||
|
<div class="theme-customizer-color-grid">
|
||||||
|
<div
|
||||||
|
v-for="color in themeCustomizerPrimaryColors"
|
||||||
|
:key="color.value"
|
||||||
|
class="theme-customizer-color-option"
|
||||||
|
:class="{ 'is-active': settings.primaryColor === color.value }"
|
||||||
|
:aria-label="t('theme.customizer.usePrimaryColor', { color: color.name })"
|
||||||
|
@click="setPrimaryColor(color.value)"
|
||||||
|
>
|
||||||
|
<span class="theme-customizer-color-swatch" :style="{ backgroundColor: color.value }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!appMode"
|
||||||
|
class="theme-customizer-color-option theme-customizer-color-option--picker"
|
||||||
|
:class="{
|
||||||
|
'is-active': !themeCustomizerPrimaryColors.some(color => color.value === settings.primaryColor),
|
||||||
|
}"
|
||||||
|
:aria-label="t('theme.customizer.chooseCustomColor')"
|
||||||
|
@click="openColorPicker"
|
||||||
|
>
|
||||||
|
<VIcon class="theme-customizer-native-icon" icon="mdi-palette-outline" size="30" />
|
||||||
|
<input
|
||||||
|
ref="customColorInput"
|
||||||
|
class="theme-customizer-native-color"
|
||||||
|
type="color"
|
||||||
|
:value="settings.primaryColor"
|
||||||
|
@input="handleCustomColorInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('common.theme') }}</h3>
|
||||||
|
<div class="theme-customizer-option-grid theme-customizer-option-grid--theme">
|
||||||
|
<div
|
||||||
|
v-for="theme in themeOptions"
|
||||||
|
:key="theme.value"
|
||||||
|
class="theme-customizer-card-option"
|
||||||
|
:class="{ 'is-active': settings.theme === theme.value }"
|
||||||
|
@click="setTheme(theme.value)"
|
||||||
|
>
|
||||||
|
<VIcon class="theme-customizer-theme-icon" :icon="theme.icon" size="36" />
|
||||||
|
<span>{{ theme.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mt-7" />
|
||||||
|
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.skins') }}</h3>
|
||||||
|
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--skins">
|
||||||
|
<div
|
||||||
|
v-for="skin in skinOptions"
|
||||||
|
:key="skin.value"
|
||||||
|
class="theme-customizer-preview-option"
|
||||||
|
:class="{ 'is-active': settings.skin === skin.value }"
|
||||||
|
@click="setSkin(skin.value)"
|
||||||
|
>
|
||||||
|
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${skin.value}`">
|
||||||
|
<span class="mini-sidebar">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<span class="mini-content">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{{ skin.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mt-7" />
|
||||||
|
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.radius') }}</h3>
|
||||||
|
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--radius">
|
||||||
|
<div
|
||||||
|
v-for="radius in radiusOptions"
|
||||||
|
:key="radius.value"
|
||||||
|
class="theme-customizer-preview-option"
|
||||||
|
:class="{ 'is-active': settings.radius === radius.value }"
|
||||||
|
@click="setRadius(radius.value)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="theme-customizer-radius-scene"
|
||||||
|
:class="`theme-customizer-radius-scene--${radius.value}`"
|
||||||
|
>
|
||||||
|
<span class="theme-customizer-radius-scene__card">
|
||||||
|
<span class="theme-customizer-radius-scene__badge" />
|
||||||
|
<span class="theme-customizer-radius-scene__line" />
|
||||||
|
<span class="theme-customizer-radius-scene__line theme-customizer-radius-scene__line--short" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{{ radius.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VDivider class="mt-7" />
|
||||||
|
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
||||||
|
<div class="theme-customizer-shadow-slider">
|
||||||
|
<div class="theme-customizer-shadow-slider__header">
|
||||||
|
<span>{{ t('theme.customizer.shadowLevel', { level: settings.shadow }) }}</span>
|
||||||
|
<span>0 - 24</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-customizer-shadow-slider__control">
|
||||||
|
<span
|
||||||
|
class="theme-customizer-shadow-slider__sample"
|
||||||
|
:style="{ boxShadow: `var(--app-elevation-${settings.shadow})` }"
|
||||||
|
>
|
||||||
|
<span class="theme-customizer-shadow-slider__sample-accent" />
|
||||||
|
<span class="theme-customizer-shadow-slider__sample-line" />
|
||||||
|
<span class="theme-customizer-shadow-slider__sample-line theme-customizer-shadow-slider__sample-line--short" />
|
||||||
|
</span>
|
||||||
|
<VSlider
|
||||||
|
:model-value="shadowSliderValue"
|
||||||
|
:aria-label="t('theme.customizer.shadow')"
|
||||||
|
:max="24"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
color="primary"
|
||||||
|
density="comfortable"
|
||||||
|
hide-details
|
||||||
|
show-ticks="always"
|
||||||
|
thumb-label
|
||||||
|
tick-size="2"
|
||||||
|
@update:model-value="handleShadowSliderChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="theme-customizer-shadow-slider__scale"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span>0</span>
|
||||||
|
<span>24</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showSemiDarkMenuOption" class="theme-customizer-semi-dark">
|
||||||
|
<span>{{ t('theme.customizer.semiDarkMenu') }}</span>
|
||||||
|
<VSwitch
|
||||||
|
:model-value="settings.semiDarkMenu"
|
||||||
|
color="primary"
|
||||||
|
inset
|
||||||
|
hide-details
|
||||||
|
@update:model-value="setSemiDarkMenu(Boolean($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<VDivider v-if="showLayoutSection" />
|
||||||
|
|
||||||
|
<section v-if="showLayoutSection" class="theme-customizer-section">
|
||||||
|
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.layout') }}</h3>
|
||||||
|
<div class="theme-customizer-preview-grid">
|
||||||
|
<div
|
||||||
|
v-for="layout in layoutOptions"
|
||||||
|
:key="layout.value"
|
||||||
|
class="theme-customizer-preview-option"
|
||||||
|
:class="{ 'is-active': settings.layout === layout.value, 'is-disabled': appMode }"
|
||||||
|
@click="handleLayoutChange(layout.value)"
|
||||||
|
>
|
||||||
|
<span class="theme-customizer-mini-layout" :class="`theme-customizer-mini-layout--${layout.value}`">
|
||||||
|
<span class="mini-sidebar">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<span class="mini-content">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>{{ layout.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PerfectScrollbar>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* stylelint-disable no-descending-specificity */
|
||||||
|
|
||||||
|
.theme-customizer-panel-host {
|
||||||
|
position: fixed !important;
|
||||||
|
z-index: 2102 !important;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
|
||||||
|
/* 背景层保持完整视口高度,避免 iOS 键盘触发 visual viewport resize 后露出底层页面。 */
|
||||||
|
block-size: 100vh !important;
|
||||||
|
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.08) !important;
|
||||||
|
box-shadow: var(--app-surface-shadow) !important;
|
||||||
|
inline-size: 420px !important;
|
||||||
|
inset-block-start: 0 !important;
|
||||||
|
inset-inline-end: 0 !important;
|
||||||
|
max-block-size: none !important;
|
||||||
|
min-block-size: 100vh !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (block-size: 100lvh) {
|
||||||
|
.theme-customizer-panel-host {
|
||||||
|
block-size: 100lvh !important;
|
||||||
|
min-block-size: 100lvh !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-panel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
block-size: 100%;
|
||||||
|
inline-size: 100%;
|
||||||
|
min-block-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-panel--dialog {
|
||||||
|
overflow: hidden;
|
||||||
|
block-size: 100%;
|
||||||
|
max-block-size: 100%;
|
||||||
|
|
||||||
|
/* 独立 App 模式会贴近 viewport-fit=cover 顶部,面板内部需要避开 iOS 状态栏。 */
|
||||||
|
padding-block-start: env(safe-area-inset-top, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-panel--dialog .theme-customizer-body {
|
||||||
|
block-size: auto;
|
||||||
|
padding-block-end: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.theme-customizer-panel-host {
|
||||||
|
inline-size: 100vw !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ps__rail-x),
|
||||||
|
:deep(.ps__rail-y) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-section {
|
||||||
|
padding-block-end: 28px;
|
||||||
|
padding-inline: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-section-title {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-block: 28px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-section-note {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-block: -6px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-color-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fill, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-color-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
appearance: none;
|
||||||
|
block-size: 48px;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
cursor: pointer;
|
||||||
|
inline-size: 48px;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-color-swatch {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
block-size: 30px;
|
||||||
|
inline-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-color-option--picker {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-native-color {
|
||||||
|
position: absolute;
|
||||||
|
block-size: 1px;
|
||||||
|
inline-size: 1px;
|
||||||
|
inset-block: 50% auto;
|
||||||
|
inset-inline: 50% auto;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-option-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-option-grid--theme {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(96px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-card-option,
|
||||||
|
.theme-customizer-preview-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
appearance: none;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 10px;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
background: rgba(var(--v-theme-primary), 0.08);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.12);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-card-option {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
min-block-size: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-preview-grid--skins {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-preview-grid--radius {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-preview-option {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none !important;
|
||||||
|
|
||||||
|
.theme-customizer-mini-layout,
|
||||||
|
.theme-customizer-radius-scene {
|
||||||
|
border-width: 2px;
|
||||||
|
border-color: rgb(var(--v-theme-primary));
|
||||||
|
background: rgba(var(--v-theme-primary), 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> span:last-child {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
padding-inline-start: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-semi-dark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-block-start: 28px;
|
||||||
|
margin-inline: -32px;
|
||||||
|
padding-inline: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-mini-layout {
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
block-size: 74px;
|
||||||
|
grid-template-columns: 34% 1fr;
|
||||||
|
inline-size: 100%;
|
||||||
|
min-inline-size: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-mini-layout--collapsed {
|
||||||
|
grid-template-columns: 18% 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-mini-layout--horizontal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 24% 1fr;
|
||||||
|
|
||||||
|
.mini-sidebar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-sidebar,
|
||||||
|
.mini-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-sidebar {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-sidebar i,
|
||||||
|
.mini-content i {
|
||||||
|
display: block;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
block-size: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-content i {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
block-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-mini-layout--bordered {
|
||||||
|
.mini-content i,
|
||||||
|
.mini-sidebar i {
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--v-theme-on-surface), 0.02), rgba(var(--v-theme-on-surface), 0.05)),
|
||||||
|
rgb(var(--v-theme-surface));
|
||||||
|
block-size: 90px;
|
||||||
|
inline-size: 100%;
|
||||||
|
min-inline-size: 0;
|
||||||
|
|
||||||
|
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene--none {
|
||||||
|
--theme-customizer-preview-radius: var(--app-vuetify-rounded-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene--small {
|
||||||
|
--theme-customizer-preview-radius: var(--app-vuetify-rounded-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene--large {
|
||||||
|
--theme-customizer-preview-radius: var(--app-vuetify-rounded-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene--extra {
|
||||||
|
--theme-customizer-preview-radius: var(--app-vuetify-rounded-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene__card {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
border-radius: var(--theme-customizer-preview-radius);
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
gap: 8px;
|
||||||
|
inset: 16px;
|
||||||
|
padding-block: 12px;
|
||||||
|
padding-inline: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene__badge,
|
||||||
|
.theme-customizer-radius-scene__line {
|
||||||
|
display: block;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene__badge {
|
||||||
|
border-radius: var(--theme-customizer-preview-radius);
|
||||||
|
block-size: 8px;
|
||||||
|
inline-size: 42%;
|
||||||
|
min-inline-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene__line {
|
||||||
|
border-radius: var(--theme-customizer-preview-radius);
|
||||||
|
block-size: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-radius-scene__line--short {
|
||||||
|
inline-size: 66%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider {
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
border-radius: var(--app-vuetify-rounded-lg);
|
||||||
|
background: rgba(var(--v-theme-surface), 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__header,
|
||||||
|
.theme-customizer-shadow-slider__scale {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__header {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-block-end: 14px;
|
||||||
|
|
||||||
|
> span:first-child {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__control {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__sample {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--app-vuetify-rounded);
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
block-size: 42px;
|
||||||
|
gap: 5px;
|
||||||
|
inline-size: 42px;
|
||||||
|
padding-block: 8px;
|
||||||
|
padding-inline: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__sample-accent,
|
||||||
|
.theme-customizer-shadow-slider__sample-line {
|
||||||
|
display: block;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__sample-accent {
|
||||||
|
background: rgba(var(--v-theme-primary), 0.48);
|
||||||
|
block-size: 5px;
|
||||||
|
inline-size: 44%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__sample-line {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
block-size: 4px;
|
||||||
|
inline-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__sample-line--short {
|
||||||
|
inline-size: 68%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider__scale {
|
||||||
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
margin-block-start: 2px;
|
||||||
|
margin-inline-start: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider :deep(.v-slider.v-input) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-shadow-slider :deep(.v-slider-track__tick) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 600px) {
|
||||||
|
.theme-customizer-header,
|
||||||
|
.theme-customizer-section {
|
||||||
|
padding-inline: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-customizer-preview-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,8 +4,14 @@ import { FilterRuleGroup } from '@/api/types'
|
|||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -23,6 +29,8 @@ const filterRuleGroups = ref<FilterRuleGroup[]>([])
|
|||||||
|
|
||||||
// 加载规则组
|
// 加载规则组
|
||||||
async function queryFilterRuleGroups() {
|
async function queryFilterRuleGroups() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
|
||||||
filterRuleGroups.value = result.data?.value ?? []
|
filterRuleGroups.value = result.data?.value ?? []
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const storages = ref<StorageConf[]>([])
|
|||||||
|
|
||||||
// 查询存储
|
// 查询存储
|
||||||
async function loadStorages() {
|
async function loadStorages() {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Storages')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Storages')
|
||||||
storages.value = result.data?.value ?? []
|
storages.value = result.data?.value ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import api from '@/api'
|
|||||||
import { NotificationConf } from '@/api/types'
|
import { NotificationConf } from '@/api/types'
|
||||||
import { Handle, Position } from '@vue-flow/core'
|
import { Handle, Position } from '@vue-flow/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useUserStore } from '@/stores'
|
||||||
|
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const canAdmin = computed(() =>
|
||||||
|
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||||
|
)
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
id: {
|
id: {
|
||||||
@@ -22,6 +28,8 @@ const notifications = ref<NotificationConf[]>([])
|
|||||||
|
|
||||||
// 调用API查询通知渠道设置
|
// 调用API查询通知渠道设置
|
||||||
async function loadNotificationSetting() {
|
async function loadNotificationSetting() {
|
||||||
|
if (!canAdmin.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
const result: { [key: string]: any } = await api.get('system/setting/Notifications')
|
||||||
notifications.value = result.data?.value ?? []
|
notifications.value = result.data?.value ?? []
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ComputedRef,
|
type ComputedRef,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
|
|
||||||
// 声明全局变量类型
|
// 声明全局变量类型
|
||||||
declare global {
|
declare global {
|
||||||
@@ -29,6 +30,7 @@ export interface DynamicButtonMenuItem {
|
|||||||
titleParams?: Record<string, unknown>
|
titleParams?: Record<string, unknown>
|
||||||
icon?: string
|
icon?: string
|
||||||
color?: string
|
color?: string
|
||||||
|
permission?: UserPermissionKey
|
||||||
action: () => void
|
action: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +59,12 @@ export function useDynamicButton(options: {
|
|||||||
icon: MaybeRefValue<string>
|
icon: MaybeRefValue<string>
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||||
|
permission?: UserPermissionKey
|
||||||
show?: MaybeRefValue<boolean>
|
show?: MaybeRefValue<boolean>
|
||||||
autoRegister?: boolean // 是否自动注册,默认为true
|
autoRegister?: boolean // 是否自动注册,默认为true
|
||||||
}) {
|
}) {
|
||||||
// 提取配置
|
// 提取配置
|
||||||
const { icon, onClick, menuItems, show, autoRegister = true } = options
|
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
|
||||||
|
|
||||||
// 动态按钮相关
|
// 动态按钮相关
|
||||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||||
@@ -81,6 +84,7 @@ export function useDynamicButton(options: {
|
|||||||
return {
|
return {
|
||||||
icon: resolvedIcon.value,
|
icon: resolvedIcon.value,
|
||||||
action: onClick || (() => {}),
|
action: onClick || (() => {}),
|
||||||
|
permission,
|
||||||
show: resolvedShow.value,
|
show: resolvedShow.value,
|
||||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||||
}
|
}
|
||||||
@@ -174,7 +178,7 @@ export function useDynamicButton(options: {
|
|||||||
cleanupDynamicButton()
|
cleanupDynamicButton()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([resolvedIcon, resolvedShow, resolvedMenuItems], () => {
|
watch([resolvedIcon, resolvedShow, resolvedMenuItems, () => permission], () => {
|
||||||
if (!componentActive.value) return
|
if (!componentActive.value) return
|
||||||
|
|
||||||
setupDynamicButton()
|
setupDynamicButton()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||||
|
import type { UserPermissionKey } from '@/utils/permission'
|
||||||
|
|
||||||
// 动态标签页相关类型
|
// 动态标签页相关类型
|
||||||
interface DynamicHeaderTabButton {
|
interface DynamicHeaderTabButton {
|
||||||
@@ -9,6 +10,7 @@ interface DynamicHeaderTabButton {
|
|||||||
size?: string
|
size?: string
|
||||||
class?: string
|
class?: string
|
||||||
action?: () => void
|
action?: () => void
|
||||||
|
permission?: UserPermissionKey
|
||||||
show?: boolean | ComputedRef<boolean>
|
show?: boolean | ComputedRef<boolean>
|
||||||
loading?: boolean | ComputedRef<boolean>
|
loading?: boolean | ComputedRef<boolean>
|
||||||
dataAttr?: string // 用于VMenu定位的data属性
|
dataAttr?: string // 用于VMenu定位的data属性
|
||||||
|
|||||||
20
src/composables/useLaunchLoading.ts
Normal file
20
src/composables/useLaunchLoading.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readonly, ref } from 'vue'
|
||||||
|
|
||||||
|
function detectInitialLaunchLoading() {
|
||||||
|
if (typeof document === 'undefined') return true
|
||||||
|
|
||||||
|
return document.documentElement.getAttribute('data-launch-loading') === 'true' || Boolean(document.getElementById('loading-bg'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动屏的全局状态,供 Teleport 到 body 的组件避开 iOS PWA 启动阶段的固定层闪烁。
|
||||||
|
const isLaunchLoading = ref(detectInitialLaunchLoading())
|
||||||
|
|
||||||
|
export function completeLaunchLoading() {
|
||||||
|
isLaunchLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLaunchLoading() {
|
||||||
|
return {
|
||||||
|
isLaunchLoading: readonly(isLaunchLoading),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { checkPWAStatus, isPWADisplayMode } from '@/@core/utils/navigator'
|
import { checkPWAStatus, isMobileDevice, isPWADisplayMode } from '@/@core/utils/navigator'
|
||||||
|
|
||||||
// 全局PWA状态,确保只初始化一次
|
// 全局PWA状态,确保只初始化一次
|
||||||
const globalPwaStatus = ref<{
|
const globalPwaStatus = ref<{
|
||||||
@@ -34,11 +34,14 @@ async function initializePWAGlobally() {
|
|||||||
globalPwaStatus.value = await checkPWAStatus()
|
globalPwaStatus.value = await checkPWAStatus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to detect PWA status', error)
|
console.error('Failed to detect PWA status', error)
|
||||||
|
const isStandaloneMode = isPWADisplayMode()
|
||||||
|
|
||||||
// 即使检测失败,也设置一个合理的默认值
|
// 即使检测失败,也设置一个合理的默认值
|
||||||
globalPwaStatus.value = {
|
globalPwaStatus.value = {
|
||||||
hasPWAFeatures: false,
|
hasPWAFeatures: false,
|
||||||
isStandaloneMode: isPWADisplayMode(),
|
isStandaloneMode,
|
||||||
isPWAEnvironment: isPWADisplayMode(),
|
// iOS Safari 浏览器模式可能取不到 Service Worker 注册信息,但移动端仍应使用 App 交互。
|
||||||
|
isPWAEnvironment: isStandaloneMode || isMobileDevice(),
|
||||||
isFullPWA: false,
|
isFullPWA: false,
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -56,7 +59,8 @@ export function usePWA() {
|
|||||||
|
|
||||||
// 基于新的PWA状态结构
|
// 基于新的PWA状态结构
|
||||||
const pwaMode = computed(() => {
|
const pwaMode = computed(() => {
|
||||||
return globalPwaStatus.value?.isPWAEnvironment ?? false
|
// PWA 状态异步恢复前先用移动端特征兜底,避免 Safari 浏览器首屏阶段缺少移动端交互。
|
||||||
|
return globalPwaStatus.value?.isPWAEnvironment ?? isMobileDevice()
|
||||||
})
|
})
|
||||||
|
|
||||||
const appMode = computed(() => {
|
const appMode = computed(() => {
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const indicatorTransform = computed(() => {
|
const indicatorTransform = computed(() => {
|
||||||
return `translate(-50%, ${Math.min(60 + pullDistance.value - config.SHOW_INDICATOR, 70)}px)`
|
// 顶部基准位置由布局 CSS 负责,这里只让指示器跟随下拉手势轻微移动。
|
||||||
|
const followOffset = Math.min(Math.max(pullDistance.value - config.SHOW_INDICATOR, 0), 16)
|
||||||
|
|
||||||
|
return `translate3d(-50%, ${followOffset}px, 0)`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 弹窗检测函数
|
// 弹窗检测函数
|
||||||
|
|||||||
@@ -1619,7 +1619,7 @@ export function useSetupWizard() {
|
|||||||
// 加载存储设置
|
// 加载存储设置
|
||||||
async function loadStorageSettings() {
|
async function loadStorageSettings() {
|
||||||
try {
|
try {
|
||||||
const result: { [key: string]: any } = await api.get('system/setting/Directories')
|
const result: { [key: string]: any } = await api.get('system/setting/public/Directories')
|
||||||
if (result.success && result.data?.value && result.data.value.length > 0) {
|
if (result.success && result.data?.value && result.data.value.length > 0) {
|
||||||
const directory = result.data.value[0]
|
const directory = result.data.value[0]
|
||||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
|
|||||||
|
|
||||||
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
||||||
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
||||||
|
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||||
|
|
||||||
export const themeCustomizerPrimaryColors = [
|
export const themeCustomizerPrimaryColors = [
|
||||||
{ name: 'Purple', value: '#9155FD' },
|
{ name: 'Purple', value: '#9155FD' },
|
||||||
@@ -23,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
|
|||||||
{ name: 'Slate', value: '#607D8B' },
|
{ name: 'Slate', value: '#607D8B' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
export const themeCustomizerShadowLevels = [
|
||||||
|
'0',
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
'3',
|
||||||
|
'4',
|
||||||
|
'5',
|
||||||
|
'6',
|
||||||
|
'7',
|
||||||
|
'8',
|
||||||
|
'9',
|
||||||
|
'10',
|
||||||
|
'11',
|
||||||
|
'12',
|
||||||
|
'13',
|
||||||
|
'14',
|
||||||
|
'15',
|
||||||
|
'16',
|
||||||
|
'17',
|
||||||
|
'18',
|
||||||
|
'19',
|
||||||
|
'20',
|
||||||
|
'21',
|
||||||
|
'22',
|
||||||
|
'23',
|
||||||
|
'24',
|
||||||
|
] as const
|
||||||
|
|
||||||
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
|
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||||
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
|
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
||||||
|
|
||||||
@@ -43,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
|||||||
|
|
||||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||||
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
|
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||||
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
|
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||||
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
||||||
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
||||||
|
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||||
|
high: '24',
|
||||||
|
low: '6',
|
||||||
|
medium: '12',
|
||||||
|
none: '0',
|
||||||
|
}
|
||||||
|
|
||||||
let themeApplyVersion = 0
|
let themeApplyVersion = 0
|
||||||
|
|
||||||
@@ -72,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: readStoredThemePreference(),
|
theme: readStoredThemePreference(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||||
|
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||||
|
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
|
||||||
|
if (typeof shadow === 'string' && legacyShadowMap[shadow]) return legacyShadowMap[shadow]
|
||||||
|
|
||||||
|
return getDefaultThemeCustomizerSettings().shadow
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||||
const fallback = getDefaultThemeCustomizerSettings()
|
const fallback = getDefaultThemeCustomizerSettings()
|
||||||
|
const storedRadius = settings.radius as string | undefined
|
||||||
|
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||||
? (settings.layout as ThemeCustomizerLayout)
|
? (settings.layout as ThemeCustomizerLayout)
|
||||||
: fallback.layout,
|
: fallback.layout,
|
||||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
||||||
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
|
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||||
? (settings.radius as ThemeCustomizerRadius)
|
? (radius as ThemeCustomizerRadius)
|
||||||
: fallback.radius,
|
: fallback.radius,
|
||||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||||
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
|
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||||
? (settings.shadow as ThemeCustomizerShadow)
|
|
||||||
: fallback.shadow,
|
|
||||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||||
? (settings.skin as ThemeCustomizerSkin)
|
? (settings.skin as ThemeCustomizerSkin)
|
||||||
: fallback.skin,
|
: fallback.skin,
|
||||||
@@ -246,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
})
|
})
|
||||||
@@ -323,7 +366,7 @@ export function useThemeCustomizer() {
|
|||||||
primaryColor: defaultPrimaryColor,
|
primaryColor: defaultPrimaryColor,
|
||||||
radius: 'default',
|
radius: 'default',
|
||||||
semiDarkMenu: false,
|
semiDarkMenu: false,
|
||||||
shadow: 'none',
|
shadow: '0',
|
||||||
skin: 'default',
|
skin: 'default',
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 空白布局用于登录、初始化与 404,复用全局页面动效保持切换手感一致。
|
||||||
|
const routeTransitionKey = computed(() => route.fullPath)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-wrapper layout-blank">
|
<div class="layout-wrapper layout-blank">
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component }">
|
||||||
|
<transition name="mp-page" mode="out-in" appear>
|
||||||
|
<div :key="routeTransitionKey" class="mp-page-route">
|
||||||
|
<component :is="Component" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</RouterView>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { formatDateDifference } from '@core/utils/formatters'
|
|
||||||
import { SystemNotification } from '@/api/types'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { useBackground } from '@/composables/useBackground'
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const { useDelayedSSE } = useBackground()
|
|
||||||
|
|
||||||
// 是否有新消息
|
|
||||||
const hasNewMessage = ref(false)
|
|
||||||
|
|
||||||
// 通知列表
|
|
||||||
const notificationList = ref<SystemNotification[]>([])
|
|
||||||
const MAX_NOTIFICATIONS = 100
|
|
||||||
|
|
||||||
// 弹窗
|
|
||||||
const appsMenu = ref(false)
|
|
||||||
|
|
||||||
// 标记所有消息为已读
|
|
||||||
function markAllAsRead() {
|
|
||||||
hasNewMessage.value = false
|
|
||||||
// 标记所有消息为已读
|
|
||||||
notificationList.value.forEach(item => {
|
|
||||||
item.read = true
|
|
||||||
})
|
|
||||||
appsMenu.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 消息处理函数
|
|
||||||
function handleMessage(event: MessageEvent) {
|
|
||||||
if (event.data) {
|
|
||||||
const noti: SystemNotification = JSON.parse(event.data)
|
|
||||||
notificationList.value.unshift(noti)
|
|
||||||
if (notificationList.value.length > MAX_NOTIFICATIONS) {
|
|
||||||
notificationList.value.length = MAX_NOTIFICATIONS
|
|
||||||
}
|
|
||||||
hasNewMessage.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟3秒启动SSE连接,避免认证信息尚未准备好。
|
|
||||||
useDelayedSSE(
|
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message`,
|
|
||||||
handleMessage,
|
|
||||||
'user-notification',
|
|
||||||
3000,
|
|
||||||
{
|
|
||||||
backgroundCloseDelay: 5000,
|
|
||||||
reconnectDelay: 3000,
|
|
||||||
maxReconnectAttempts: 3
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VMenu
|
|
||||||
v-model="appsMenu"
|
|
||||||
width="400"
|
|
||||||
transition="scale-transition"
|
|
||||||
close-on-content-click
|
|
||||||
class="notification-menu"
|
|
||||||
scrim
|
|
||||||
>
|
|
||||||
<!-- Menu Activator -->
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
|
|
||||||
<IconBtn>
|
|
||||||
<VIcon icon="mdi-bell-outline" />
|
|
||||||
</IconBtn>
|
|
||||||
</VBadge>
|
|
||||||
<IconBtn v-else v-bind="props">
|
|
||||||
<VIcon icon="mdi-bell-outline" />
|
|
||||||
</IconBtn>
|
|
||||||
</template>
|
|
||||||
<!-- Menu Content -->
|
|
||||||
<VCard>
|
|
||||||
<VCardItem class="py-3">
|
|
||||||
<VCardTitle>{{ t('notification.center') }}</VCardTitle>
|
|
||||||
<template #append>
|
|
||||||
<VTooltip :text="t('notification.markRead')">
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<IconBtn v-bind="props" @click="markAllAsRead">
|
|
||||||
<VIcon icon="mdi-email-check-outline" size="20" />
|
|
||||||
</IconBtn>
|
|
||||||
</template>
|
|
||||||
</VTooltip>
|
|
||||||
</template>
|
|
||||||
</VCardItem>
|
|
||||||
<VDivider />
|
|
||||||
<div class="notification-list-container">
|
|
||||||
<div v-if="notificationList.length > 0">
|
|
||||||
<VListItem v-for="(item, i) in notificationList" :key="i" lines="two" class="mb-1">
|
|
||||||
<template #prepend>
|
|
||||||
<VAvatar rounded>
|
|
||||||
<VIcon v-if="item.type === 'user'" icon="mdi-account-alert" size="large"></VIcon>
|
|
||||||
<VIcon v-else-if="item.type === 'plugin'" icon="mdi-robot" size="large"></VIcon>
|
|
||||||
<VIcon v-else icon="mdi-laptop" size="large"></VIcon>
|
|
||||||
</VAvatar>
|
|
||||||
</template>
|
|
||||||
<div>
|
|
||||||
<div class="text-body-1 text-high-emphasis break-words whitespace-break-spaces">
|
|
||||||
{{ item.title }}
|
|
||||||
</div>
|
|
||||||
<div class="text-caption mt-1.5">
|
|
||||||
{{ item.text }}
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-primary mt-1.5">
|
|
||||||
{{ formatDateDifference(item.date) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VListItem>
|
|
||||||
</div>
|
|
||||||
<div v-else class="py-8 text-center">
|
|
||||||
<VIcon icon="mdi-bell-sleep-outline" size="40" class="mb-3" />
|
|
||||||
<div>{{ t('notification.empty') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</VMenu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.notification-list-container {
|
|
||||||
max-block-size: 50vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,19 +1,59 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import DefaultLayout from './components/DefaultLayout.vue'
|
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||||
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
|
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
|
||||||
|
|
||||||
|
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||||
|
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||||
|
const isPageEntering = ref(false)
|
||||||
|
let pageMotionTimer: number | null = null
|
||||||
|
let pageMotionFrame: number | null = null
|
||||||
|
|
||||||
|
// 使用稳定容器触发轻量入场动画,避免重建 keep-alive 导致页面缓存失效。
|
||||||
|
function playPageEnterMotion() {
|
||||||
|
if (pageMotionTimer) {
|
||||||
|
window.clearTimeout(pageMotionTimer)
|
||||||
|
pageMotionTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageMotionFrame) {
|
||||||
|
window.cancelAnimationFrame(pageMotionFrame)
|
||||||
|
pageMotionFrame = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isPageEntering.value = false
|
||||||
|
pageMotionFrame = window.requestAnimationFrame(() => {
|
||||||
|
isPageEntering.value = true
|
||||||
|
pageMotionFrame = null
|
||||||
|
pageMotionTimer = window.setTimeout(() => {
|
||||||
|
isPageEntering.value = false
|
||||||
|
pageMotionTimer = null
|
||||||
|
}, 220)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(routeTransitionKey, playPageEnterMotion, { flush: 'post' })
|
||||||
|
|
||||||
|
onMounted(playPageEnterMotion)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
||||||
|
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DefaultLayout>
|
<DefaultLayout>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive :max="24">
|
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
<keep-alive :max="24">
|
||||||
</keep-alive>
|
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||||
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
</keep-alive>
|
||||||
|
<component :is="Component" v-if="!route.meta.keepAlive" :key="route.fullPath" />
|
||||||
|
</div>
|
||||||
</router-view>
|
</router-view>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user