mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-25 01:23:56 +08:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
530fe9d35b | ||
|
|
48ed396a19 | ||
|
|
b0356c217d | ||
|
|
55eed1ecb5 | ||
|
|
50ae739a4d | ||
|
|
d9cbcc2991 | ||
|
|
ad12701fe2 | ||
|
|
2b426a47c6 | ||
|
|
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 |
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/**
|
||||
docs-lock/
|
||||
.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
|
||||
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)
|
||||
- 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
|
||||
|
||||
### Recommended IDE Setup
|
||||
@@ -57,3 +48,10 @@ yarn build
|
||||
```shell
|
||||
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" />
|
||||
<!-- 核心viewport设置 - 针对PWA优化 -->
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no, 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" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.7",
|
||||
"version": "2.13.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -35,6 +35,23 @@ http {
|
||||
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)$ {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
@@ -44,8 +61,7 @@ http {
|
||||
|
||||
location /assets {
|
||||
# 静态资源
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
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')}`
|
||||
}
|
||||
@@ -170,6 +170,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.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
|
||||
min-block-size: 100%;
|
||||
|
||||
@@ -185,13 +189,16 @@ export default defineComponent({
|
||||
.layout-navbar {
|
||||
position: fixed;
|
||||
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);
|
||||
inset-block-start: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
|
||||
.navbar-content-container {
|
||||
block-size: calc(
|
||||
env(safe-area-inset-top) + variables.$layout-vertical-nav-navbar-height + var(--navbar-tab-height)
|
||||
);
|
||||
block-size: var(--layout-navbar-block-size);
|
||||
}
|
||||
|
||||
@at-root {
|
||||
|
||||
@@ -15,7 +15,7 @@ body {
|
||||
background: rgb(var(--v-theme-background));
|
||||
overscroll-behavior-y: contain;
|
||||
|
||||
--webkit-overflow-scrolling: touch;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body,
|
||||
|
||||
12
src/App.vue
12
src/App.vue
@@ -9,7 +9,7 @@ import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
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 { applyStoredThemeCustomizerAppearance } from '@/composables/useThemeCustomizer'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
@@ -59,8 +59,9 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
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 backgroundRequestController: AbortController | null = null
|
||||
@@ -434,7 +435,7 @@ onUnmounted(() => {
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp>
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -504,4 +505,9 @@ onUnmounted(() => {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface Subscribe {
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
note?: string | number[]
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
state: string
|
||||
// 最后更新时间
|
||||
@@ -656,6 +656,8 @@ export interface Plugin {
|
||||
system_version_message?: string
|
||||
// 主系统版本限定范围
|
||||
system_version?: string
|
||||
// 是否声明支持通过 GitHub Release 资产安装
|
||||
release?: boolean
|
||||
// 是否本地插件
|
||||
is_local?: boolean
|
||||
// 插件仓库地址
|
||||
@@ -668,6 +670,38 @@ export interface Plugin {
|
||||
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 对齐)
|
||||
export interface PluginSidebarNavItem {
|
||||
plugin_id: string
|
||||
@@ -1131,6 +1165,12 @@ export interface MediaServerLibrary {
|
||||
|
||||
// 消息通知
|
||||
export interface Message {
|
||||
// 消息ID
|
||||
id?: number
|
||||
// 消息渠道
|
||||
channel?: string
|
||||
// 消息来源
|
||||
source?: string
|
||||
// 消息类型
|
||||
mtype?: string
|
||||
// 消息标题
|
||||
@@ -1150,19 +1190,15 @@ export interface Message {
|
||||
// 消息方向:0-接收,1-发送
|
||||
action?: number
|
||||
// JSON
|
||||
note?: string
|
||||
note?: string | any[] | Record<string, any>
|
||||
}
|
||||
|
||||
// 系统通知
|
||||
export interface SystemNotification {
|
||||
// 通知类型 user/system/plugin
|
||||
type: string
|
||||
// 通知标题
|
||||
title: string
|
||||
// 通知内容
|
||||
text: string
|
||||
export interface SystemNotification extends Message {
|
||||
// 通知类型 user/system/plugin/notification
|
||||
type?: string
|
||||
// 通知时间
|
||||
date: string
|
||||
date?: string
|
||||
// 是否已读
|
||||
read?: boolean
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2505
src/components/agent/AgentAssistantEntry.vue
Normal file
2505
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>
|
||||
@@ -46,17 +46,18 @@ const getImgUrl = computed(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<template #placeholder>
|
||||
@@ -86,7 +87,14 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backdrop-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,16 +73,16 @@ async function deleteDownload() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
@@ -130,7 +130,8 @@ async function deleteDownload() {
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -138,6 +139,10 @@ async function deleteDownload() {
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -156,15 +156,17 @@ onMounted(async () => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
@@ -184,7 +186,14 @@ onMounted(async () => {
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.library-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -493,14 +493,14 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div ref="mediaCardRef">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500 media-card"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@@ -577,6 +577,7 @@ onBeforeUnmount(() => {
|
||||
<!--来源图标-->
|
||||
<VAvatar
|
||||
size="24"
|
||||
variant="plain"
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
@@ -590,6 +591,10 @@ onBeforeUnmount(() => {
|
||||
</VHover>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-hover-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
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>
|
||||
@@ -75,15 +75,17 @@ function goPersonDetail() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="person-card-hover-area">
|
||||
<VCard
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div class="person-card relative cursor-pointer ring-gray-700">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
@@ -107,12 +109,17 @@ function goPersonDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.person-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const PluginMarketDetailDialog = defineAsyncComponent(() => import('@/components/dialog/PluginMarketDetailDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -26,6 +30,11 @@ const emit = defineEmits(['install'])
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
|
||||
@@ -48,6 +57,21 @@ const isImageLoaded = 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() {
|
||||
isImageLoaded.value = true
|
||||
@@ -96,14 +120,69 @@ function visitPluginPage() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory() {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin },
|
||||
{},
|
||||
{ plugin: props.plugin, actionMode: 'install' },
|
||||
{
|
||||
update: installPlugin,
|
||||
},
|
||||
{ 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() {
|
||||
openSharedDialog(
|
||||
@@ -140,22 +219,28 @@ const dropdownItems = ref([
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -241,13 +326,18 @@ const dropdownItems = ref([
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -69,6 +69,7 @@ const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let cloneDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件操作进度弹窗,插件卡片自身不再持有进度弹窗实例。 */
|
||||
function showPluginProgress(text: string) {
|
||||
@@ -103,11 +104,12 @@ async function imageLoaded() {
|
||||
|
||||
// 显示更新日志
|
||||
function showUpdateHistory(showUpdateAction: boolean = false) {
|
||||
openSharedDialog(
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = openSharedDialog(
|
||||
PluginVersionHistoryDialog,
|
||||
{ plugin: props.plugin, showUpdateAction },
|
||||
{ update: updatePlugin },
|
||||
{ closeOn: ['close', 'update', 'update:modelValue'] },
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,19 +221,37 @@ async function resetPlugin() {
|
||||
}
|
||||
|
||||
// 更新插件
|
||||
async function updatePlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
async function updatePlugin(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 {
|
||||
// 显示等待提示框
|
||||
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}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
@@ -241,6 +261,8 @@ async function updatePlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
@@ -545,19 +567,19 @@ watch(
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -647,7 +669,8 @@ watch(
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -655,6 +678,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -211,20 +211,21 @@ const dropdownItems = ref([
|
||||
<!-- 文件夹卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
|
||||
<VCard
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card app-hover-lift-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
@@ -288,25 +289,29 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-folder-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-folder-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
&__bg {
|
||||
|
||||
@@ -47,16 +47,17 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
@@ -93,7 +94,14 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.poster-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -239,25 +239,27 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="site-card-hover-area h-full">
|
||||
<VCard
|
||||
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer site-card--hoverable': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
@@ -419,11 +421,20 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-card-hover-area:hover .site-card--hoverable {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -455,7 +466,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -644,7 +655,7 @@ onMounted(() => {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
.site-card-hover-area:hover .site-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { useToast } from 'vue-toastification'
|
||||
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 type { Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
@@ -403,26 +404,27 @@ function handleCardClick() {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
@@ -478,7 +480,7 @@ function handleCardClick() {
|
||||
<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 ...">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeason(props.media?.season ? props.media?.season.toString() : '') }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
@@ -567,13 +569,18 @@ function handleCardClick() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
@@ -587,7 +594,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
* 待定:内发光挂在实际 VCard 上,跟随卡片圆角并被 overflow-hidden 裁剪。
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
|
||||
@@ -93,16 +93,17 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="app-hover-lift-card w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
@@ -155,13 +156,18 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-card-hover-area 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="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -203,11 +204,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,10 +99,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-item-hover-area 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="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -206,11 +207,19 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,12 +146,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -316,12 +317,20 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -115,10 +115,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -262,11 +263,19 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
}
|
||||
|
||||
|
||||
@@ -127,14 +127,16 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
:class="[
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
@@ -302,10 +304,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.user-card-hover-area:hover .user-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -95,17 +95,18 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
@@ -134,20 +135,16 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
.workflow-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -133,12 +133,12 @@ async function savaAlistConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.alistConfig.reset') }}
|
||||
</VBtn>
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -138,12 +138,12 @@ onUnmounted(() => {
|
||||
</VAlert>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.aliyunAuth.reset') }}
|
||||
</VBtn>
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -84,9 +84,16 @@ function submitReidentify() {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -383,7 +383,7 @@ onMounted(() => {
|
||||
</VTab>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
@@ -610,12 +610,16 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn color="primary" :loading="saving" prepend-icon="mdi-content-save" class="px-5" @click="saveConfig">
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="saving"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -153,15 +153,15 @@ function submitSettings() {
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="props.showBulkActions" color="success" variant="tonal" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</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 }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
|
||||
@@ -86,8 +86,9 @@ function submitCustomCSS() {
|
||||
class="custom-css-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="custom-css-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
<VCardActions class="app-dialog-actions custom-css-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
@@ -98,9 +99,9 @@ function submitCustomCSS() {
|
||||
<style scoped>
|
||||
.custom-css-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-css-header {
|
||||
@@ -110,7 +111,7 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
min-block-size: 240px;
|
||||
}
|
||||
|
||||
.custom-css-editor {
|
||||
@@ -140,8 +141,8 @@ function submitCustomCSS() {
|
||||
|
||||
.custom-css-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.custom-css-actions {
|
||||
|
||||
@@ -199,8 +199,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -88,9 +88,9 @@ function submitOrder() {
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
|
||||
@@ -536,8 +536,9 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -52,9 +52,16 @@ function closeDialog() {
|
||||
<VCardText>
|
||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="!folderName"
|
||||
prepend-icon="mdi-folder-plus"
|
||||
class="px-5"
|
||||
@click="emit('create')"
|
||||
>
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -81,11 +81,19 @@ function closeDialog() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="success" variant="tonal" prepend-icon="mdi-magic" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -294,18 +294,23 @@ onMounted(() => {
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="primary" variant="tonal" class="app-dialog-actions__icon-btn" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</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" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VBtn color="info" variant="tonal" class="app-dialog-actions__icon-btn" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -36,9 +36,9 @@ function handleImport() {
|
||||
<VCardText class="pt-2">
|
||||
<VTextarea v-model="codeString" prepend-inner-icon="mdi-code-json" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -43,7 +43,10 @@ function closeDialog() {
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
@@ -71,9 +74,9 @@ function closeDialog() {
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -591,8 +591,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveMediaServerInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -1171,8 +1171,15 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveNotificationInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="saveNotificationInfo"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
>
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -92,8 +92,9 @@ function submitTemplate() {
|
||||
class="template-ace-editor"
|
||||
/>
|
||||
</div>
|
||||
<VCardActions class="template-editor-actions">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
<VCardActions class="app-dialog-actions template-editor-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -299,8 +299,9 @@ watch(
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-end px-6 pb-4">
|
||||
<VBtn variant="outlined" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="show = false">{{ t('common.close') }}</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -154,10 +154,11 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="submitClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
|
||||
@@ -160,13 +160,26 @@ onBeforeMount(async () => {
|
||||
<div v-if="!pluginFormItems || pluginFormItems.length === 0">此插件没有可配置项</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.plugin?.has_page" @click="emit('switch')" color="info">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
v-if="props.plugin?.has_page"
|
||||
color="info"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-database-eye-outline"
|
||||
@click="emit('switch')"
|
||||
>
|
||||
{{ t('dialog.pluginConfig.viewData') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<!-- 只有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>
|
||||
</VCardActions>
|
||||
|
||||
@@ -54,9 +54,9 @@ function closeDialog() {
|
||||
@keyup.enter="emit('create')"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -57,9 +57,9 @@ function confirmRename() {
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -201,9 +201,11 @@ onMounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
@@ -42,6 +42,12 @@ function openLoggerWindow() {
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -52,12 +58,20 @@ function openLoggerWindow() {
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center cursor-pointer" @click="downloadLogger">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</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>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
|
||||
@@ -6,8 +6,12 @@ import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
const PluginVersionHistoryDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/PluginVersionHistoryDialog.vue'),
|
||||
)
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
@@ -15,6 +19,8 @@ const { t } = useI18n()
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -47,6 +53,7 @@ const imageRef = ref<any>()
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
let versionHistoryDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
@@ -97,24 +104,38 @@ function visitPluginPage() {
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
if (props.plugin?.system_version_compatible === false) {
|
||||
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: props?.plugin?.plugin_version,
|
||||
version: releaseVersion || props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -122,6 +143,8 @@ async function installPlugin() {
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} 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(() => {
|
||||
closeInstallProgress()
|
||||
versionHistoryDialogController?.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -190,16 +227,21 @@ onUnmounted(() => {
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</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" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
@@ -212,3 +254,42 @@ onUnmounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</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 editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
const syncingWiki = ref(false)
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
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(
|
||||
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
|
||||
)
|
||||
@@ -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() {
|
||||
if (editorMode.value === 'text') {
|
||||
@@ -224,8 +256,8 @@ function formatRepoDisplay(url: string) {
|
||||
const pathSegments = parsedUrl.pathname.split('/').filter(Boolean)
|
||||
|
||||
if (
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname)
|
||||
&& pathSegments.length >= 2
|
||||
['github.com', 'www.github.com', 'raw.githubusercontent.com'].includes(parsedUrl.hostname) &&
|
||||
pathSegments.length >= 2
|
||||
) {
|
||||
return `${pathSegments[0]}/${pathSegments[1].replace(/\.git$/, '')}`
|
||||
}
|
||||
@@ -258,25 +290,47 @@ onMounted(() => {
|
||||
</div>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
|
||||
<VDivider />
|
||||
<VCardText class="plugin-market-dialog-body pt-4">
|
||||
<div class="plugin-market-toolbar">
|
||||
<VBtnToggle
|
||||
:model-value="editorMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
variant="tonal"
|
||||
class="plugin-market-mode-toggle"
|
||||
@update:model-value="switchEditorMode"
|
||||
>
|
||||
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
|
||||
{{ t('dialog.pluginMarketSetting.listMode') }}
|
||||
</VBtn>
|
||||
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
|
||||
{{ t('dialog.pluginMarketSetting.textMode') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<div class="plugin-market-toolbar-hint">
|
||||
<VIcon icon="mdi-information-outline" size="18" />
|
||||
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
|
||||
</div>
|
||||
<div class="plugin-market-mode-switch" role="tablist" :aria-label="t('dialog.pluginMarketSetting.title')">
|
||||
<VTooltip :text="t('dialog.pluginMarketSetting.listMode')" location="top">
|
||||
<template #activator="{ props }">
|
||||
<button
|
||||
v-bind="props"
|
||||
type="button"
|
||||
class="plugin-market-mode-button"
|
||||
:class="{ 'is-active': editorMode === 'list' }"
|
||||
role="tab"
|
||||
:aria-label="t('dialog.pluginMarketSetting.listMode')"
|
||||
:aria-selected="editorMode === 'list'"
|
||||
@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 v-if="editorMode === 'list'" class="plugin-market-list-panel">
|
||||
@@ -424,7 +478,17 @@ onMounted(() => {
|
||||
</div>
|
||||
</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 />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@@ -478,14 +542,70 @@ onMounted(() => {
|
||||
.plugin-market-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-block-size: 2.25rem;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar-hint {
|
||||
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) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
span {
|
||||
overflow: hidden;
|
||||
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;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-break: anywhere;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
@@ -550,20 +670,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
min-block-size: 14rem;
|
||||
}
|
||||
|
||||
.plugin-market-textarea-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
@@ -586,13 +708,14 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
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;
|
||||
line-height: 1.6;
|
||||
min-block-size: 0;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1rem 1rem 3.25rem;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 3.25rem 1rem;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
@@ -612,19 +735,14 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
flex: 0 0 auto;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@media (width <= 600px) {
|
||||
.plugin-market-dialog-card {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
|
||||
.plugin-market-card-item {
|
||||
padding: 0.75rem 1rem 0.625rem;
|
||||
padding-block: 0.75rem 0.625rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-header {
|
||||
@@ -640,16 +758,22 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-dialog-body {
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem !important;
|
||||
padding-block: 0.75rem !important;
|
||||
padding-inline: 1rem !important;
|
||||
}
|
||||
|
||||
.plugin-market-mode-toggle {
|
||||
inline-size: 100%;
|
||||
.plugin-market-toolbar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
:deep(.v-btn) {
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.plugin-market-mode-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-market-toolbar-hint {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.plugin-market-list-panel,
|
||||
@@ -664,9 +788,5 @@ onMounted(() => {
|
||||
.plugin-market-empty {
|
||||
min-block-size: 10rem;
|
||||
}
|
||||
|
||||
.plugin-market-actions {
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -21,14 +21,25 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
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 loadError = ref('')
|
||||
const pluginDetail = ref<Plugin | null>(null)
|
||||
const releaseLoading = ref(false)
|
||||
const releaseError = ref('')
|
||||
const releaseDetail = ref<PluginReleaseVersionsResponse | null>(null)
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
@@ -41,19 +52,73 @@ const visible = computed({
|
||||
|
||||
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 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() {
|
||||
if (!props.plugin?.id) {
|
||||
pluginDetail.value = null
|
||||
loadError.value = ''
|
||||
releaseDetail.value = null
|
||||
releaseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
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 {
|
||||
pluginDetail.value = await api.get(`plugin/history/${props.plugin.id}`, {
|
||||
@@ -61,6 +126,7 @@ async function loadPluginHistory() {
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
loadPluginReleases(pluginDetail.value ?? props.plugin, true)
|
||||
} catch (error) {
|
||||
pluginDetail.value = null
|
||||
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() {
|
||||
emit('update')
|
||||
function handleUpdate(releaseItem?: PluginReleaseVersion) {
|
||||
emit('update', releaseItem?.is_latest ? undefined : releaseItem?.version, resolvedPlugin.value?.repo_url)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.plugin?.id],
|
||||
([isVisible]) => {
|
||||
if (isVisible) loadPluginHistory()
|
||||
if (isVisible) {
|
||||
loadPluginHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<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 })">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VProgressLinear v-if="releaseLoading && !loading" indeterminate color="primary" height="2" />
|
||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</div>
|
||||
<VCardText v-else-if="loadError && !hasHistory">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!hasHistory">
|
||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<VersionHistory v-else :history="resolvedHistory" />
|
||||
<template v-if="props.showUpdateAction">
|
||||
<template v-else>
|
||||
<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 />
|
||||
<VCardItem>
|
||||
<VAlert
|
||||
@@ -110,7 +248,11 @@ watch(
|
||||
class="mb-3"
|
||||
: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>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
@@ -129,4 +271,23 @@ watch(
|
||||
align-items: 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>
|
||||
|
||||
@@ -89,12 +89,12 @@ async function handleReset() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.rcloneConfig.reset') }}
|
||||
</VBtn>
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
ManualTransferTargetPathData,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -19,7 +18,6 @@ import { useBackground } from '@/composables/useBackground'
|
||||
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
@@ -118,14 +116,6 @@ const episodeFormatRecommendState = reactive<{
|
||||
|
||||
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||
|
||||
interface ManualTransferTargetPathRequest {
|
||||
fileitem?: FileItem
|
||||
fileitems?: FileItem[]
|
||||
logid?: number
|
||||
logids?: number[]
|
||||
target_storage?: string | null
|
||||
}
|
||||
|
||||
interface TargetDirectoryOption {
|
||||
title: string
|
||||
value: string
|
||||
@@ -159,13 +149,7 @@ const normalizedItems = computed(() => dedupeFileItems(props.items))
|
||||
|
||||
// 分页
|
||||
const previewPage = ref(1)
|
||||
const previewPageSize = ref(10)
|
||||
|
||||
// 预览列表主体元素
|
||||
const previewFileBodyRef = ref<HTMLElement>()
|
||||
|
||||
// 预览列表尺寸观察器
|
||||
let previewFileBodyResizeObserver: ResizeObserver | undefined
|
||||
const previewPageSize = ref(20)
|
||||
|
||||
// 所有存储
|
||||
const storages = ref<StorageConf[]>([])
|
||||
@@ -297,16 +281,20 @@ const disableEpisodeDetail = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const initialTargetPath = normalizeTargetPath(props.target_path)
|
||||
|
||||
// 表单
|
||||
const transferForm = reactive<TransferForm>({
|
||||
fileitem: {} as FileItem,
|
||||
logid: 0,
|
||||
target_storage: props.target_storage ?? 'local',
|
||||
target_path: normalizeTargetPath(props.target_path),
|
||||
target_storage: initialTargetPath ? (props.target_storage ?? 'local') : null,
|
||||
target_path: initialTargetPath,
|
||||
transfer_type: null,
|
||||
min_filesize: 0,
|
||||
scrape: false,
|
||||
scrape: initialTargetPath ? false : null,
|
||||
from_history: false,
|
||||
library_type_folder: null,
|
||||
library_category_folder: null,
|
||||
episode_group: null,
|
||||
})
|
||||
|
||||
@@ -354,51 +342,6 @@ const targetPathSelection = computed({
|
||||
},
|
||||
})
|
||||
|
||||
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
|
||||
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
|
||||
const payload: ManualTransferTargetPathRequest = {}
|
||||
|
||||
if (props.target_storage) {
|
||||
payload.target_storage = props.target_storage
|
||||
}
|
||||
|
||||
if (normalizedItems.value.length === 1) {
|
||||
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) {
|
||||
resetAutomaticTargetConfig()
|
||||
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
|
||||
}
|
||||
|
||||
// 重置为完全自动匹配状态,提交时不携带目标路径及其派生配置。
|
||||
function resetAutomaticTargetConfig() {
|
||||
transferForm.target_storage = null
|
||||
@@ -409,34 +352,6 @@ function resetAutomaticTargetConfig() {
|
||||
transferForm.library_category_folder = null
|
||||
}
|
||||
|
||||
// 请求后端按源目录匹配最合适的手动整理目的路径。
|
||||
async function autoSelectTargetPath() {
|
||||
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
|
||||
|
||||
const payload = createTargetPathMatchRequest()
|
||||
if (!payload) {
|
||||
resetAutomaticTargetConfig()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
|
||||
'transfer/manual/target-path',
|
||||
payload,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
resetAutomaticTargetConfig()
|
||||
return
|
||||
}
|
||||
|
||||
applyMatchedTargetPath(result.data)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
resetAutomaticTargetConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听目的路径变化,配置默认值
|
||||
watch(
|
||||
() => transferForm.target_path,
|
||||
@@ -497,9 +412,39 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 过滤后的预览数据
|
||||
// 过滤并排序后的预览数据
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
// 分页后的预览数据(含文件名解析)
|
||||
@@ -1188,7 +1133,6 @@ async function previewTransfer() {
|
||||
|
||||
previewData.value = mergedPreviewData
|
||||
previewLoaded.value = true
|
||||
nextTick(() => updatePreviewPageSize())
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
@@ -1215,45 +1159,6 @@ async function togglePreview() {
|
||||
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) {
|
||||
try {
|
||||
@@ -1374,7 +1279,6 @@ async function transfer(background: boolean = false) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await autoSelectTargetPath()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1382,7 +1286,6 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1629,35 +1532,39 @@ onUnmounted(() => {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</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>
|
||||
<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 v-show="previewVisible" class="reorganize-preview-pane">
|
||||
<div class="reorganize-preview-pane__header">
|
||||
@@ -1746,7 +1653,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-for="(item, index) in pagedPreviewRows"
|
||||
:key="`${item.source}-${item.target}-${index}`"
|
||||
@@ -1890,17 +1797,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--active {
|
||||
background: rgba(var(--v-theme-info), 0.12);
|
||||
}
|
||||
@@ -1977,6 +1876,8 @@ onUnmounted(() => {
|
||||
.preview-overview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.875rem;
|
||||
@@ -2002,6 +1903,8 @@ onUnmounted(() => {
|
||||
.preview-custom-words {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
padding-block: 0.875rem;
|
||||
padding-inline: 1rem;
|
||||
@@ -2053,8 +1956,12 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.preview-custom-words__chip {
|
||||
block-size: auto !important;
|
||||
max-inline-size: 100%;
|
||||
min-block-size: 1.5rem;
|
||||
padding-block: 0.25rem;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__scroll {
|
||||
@@ -2094,9 +2001,9 @@ onUnmounted(() => {
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
margin-block-end: 1.5rem;
|
||||
margin-inline: 1.5rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-file-body {
|
||||
@@ -2107,13 +2014,13 @@ onUnmounted(() => {
|
||||
gap: 0.75rem;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.preview-file-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 0.5rem;
|
||||
gap: 0.875rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
min-block-size: 5.25rem;
|
||||
@@ -2122,10 +2029,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
background: rgba(var(--v-theme-error), 0.04);
|
||||
}
|
||||
@@ -2240,15 +2143,9 @@ onUnmounted(() => {
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.reorganize-form-pane__actions {
|
||||
display: grid;
|
||||
justify-content: stretch;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
inline-size: 100%;
|
||||
min-block-size: 2.75rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.reorganize-preview-pane__summary {
|
||||
@@ -2257,20 +2154,16 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-preview-list {
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.reorganize-form-pane__actions {
|
||||
justify-content: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reorganize-action-btn {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.reorganize-action-btn--primary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -175,10 +175,11 @@ const filteredSites = computed(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:disabled="selectedSites.length === 0"
|
||||
@click="confirmSearch"
|
||||
prepend-icon="mdi-magnify"
|
||||
|
||||
@@ -34,6 +34,11 @@ const visible = computed({
|
||||
function allLoggingUrl() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -44,12 +49,20 @@ function allLoggingUrl() {
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('shortcut.log.subtitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
<span class="ms-4 d-inline-flex align-center ga-1">
|
||||
<a class="d-inline-flex align-center" :href="allLoggingDownloadUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small">
|
||||
<VIcon icon="mdi-download" size="small" start />
|
||||
{{ t('common.download') }}
|
||||
</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>
|
||||
</VCardItem>
|
||||
<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')
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<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">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -53,7 +64,7 @@ const visible = computed({
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<VCardText :class="bodyClasses">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -61,8 +72,6 @@ const visible = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -78,7 +87,7 @@ const visible = computed({
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -340,12 +340,26 @@ onMounted(async () => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -110,9 +110,11 @@ async function updateSiteCookie() {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateSiteCookie"
|
||||
:disabled="updateButtonDisable"
|
||||
:loading="updateButtonDisable"
|
||||
|
||||
@@ -475,26 +475,26 @@ onMounted(() => {
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:estimated-item-height="220"
|
||||
:overscan-rows="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCardText class="pa-3">
|
||||
<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 }}
|
||||
</div>
|
||||
<div
|
||||
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 }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="site-resource-card__chips mt-2">
|
||||
<VChip
|
||||
v-if="item.hit_and_run"
|
||||
variant="elevated"
|
||||
@@ -533,47 +533,82 @@ onMounted(() => {
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="site-resource-card__meta mt-4">
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.timeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.date_elapsed || item.pubdate || '-' }}</div>
|
||||
<div v-if="item.pubdate" class="text-caption text-medium-emphasis mt-1">{{ item.pubdate }}</div>
|
||||
<!-- 移动端在操作区前展示关键资源指标,方便点击前快速判断。 -->
|
||||
<div class="site-resource-card__summary mt-3">
|
||||
<div class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-clock-outline" size="15" />
|
||||
<span>{{ item.date_elapsed || item.pubdate || '-' }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.sizeColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ formatFileSize(item.size) }}</div>
|
||||
<div class="site-resource-card__stat">
|
||||
<VIcon icon="mdi-harddisk" size="15" />
|
||||
<span>{{ formatFileSize(item.size) }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.seedersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.seeders }}</div>
|
||||
<div class="site-resource-card__stat site-resource-card__stat--success">
|
||||
<VIcon icon="mdi-arrow-up" size="15" />
|
||||
<span>{{ item.seeders ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="site-resource-card__meta-item">
|
||||
<div class="text-caption text-medium-emphasis">{{ t('dialog.siteResource.peersColumn') }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.peers }}</div>
|
||||
<div class="site-resource-card__stat site-resource-card__stat--warning">
|
||||
<VIcon icon="mdi-arrow-down" size="15" />
|
||||
<span>{{ item.peers ?? '-' }}</span>
|
||||
</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') }}
|
||||
</VBtn>
|
||||
<div class="site-resource-card__secondary-actions mt-2">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-open-in-new"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="item.enclosure?.startsWith('http')"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-tray-arrow-down"
|
||||
@click="downloadTorrentFile(item.enclosure)"
|
||||
>
|
||||
{{ t('dialog.siteResource.downloadTorrent') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<VTooltip :text="t('common.viewDetails')" location="top">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<VBtn
|
||||
v-bind="tooltipProps"
|
||||
icon
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="site-resource-card__icon-btn"
|
||||
:aria-label="t('common.viewDetails')"
|
||||
@click="openTorrentDetail(item.page_url || '')"
|
||||
>
|
||||
<VIcon icon="mdi-open-in-new" />
|
||||
</VBtn>
|
||||
</template>
|
||||
</VTooltip>
|
||||
<VTooltip
|
||||
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>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -702,44 +737,107 @@ onMounted(() => {
|
||||
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 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-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;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: minmax(0, 1fr) 2.5rem 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item {
|
||||
background: rgba(var(--v-theme-surface), 0.78);
|
||||
min-block-size: 0;
|
||||
padding-block: 0.55rem;
|
||||
padding-inline: 0.65rem;
|
||||
.site-resource-card__download-btn {
|
||||
min-block-size: 2.5rem;
|
||||
min-inline-size: 0;
|
||||
box-shadow: 0 6px 16px rgba(var(--v-theme-primary), 0.17);
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-caption) {
|
||||
font-size: 0.72rem !important;
|
||||
line-height: 1.2;
|
||||
.site-resource-card__download-btn :deep(.v-btn__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-resource-card__meta-item :deep(.text-body-2) {
|
||||
font-size: 0.82rem !important;
|
||||
line-height: 1.25;
|
||||
.site-resource-card__icon-btn {
|
||||
block-size: 2.5rem;
|
||||
inline-size: 2.5rem;
|
||||
min-inline-size: 2.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.site-resource-card__secondary-actions :deep(.v-btn) {
|
||||
flex: 1 1 12rem;
|
||||
.site-resource-card__icon-btn :deep(.v-btn__content) {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
@@ -761,4 +859,14 @@ onMounted(() => {
|
||||
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>
|
||||
|
||||
@@ -117,12 +117,12 @@ async function saveSmbConfig() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="error" @click="handleReset" prepend-icon="mdi-restore" class="px-5 me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn color="error" variant="tonal" @click="handleReset" prepend-icon="mdi-restore">
|
||||
{{ t('dialog.smbConfig.reset') }}
|
||||
</VBtn>
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -89,8 +89,9 @@ function handleDone() {
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="flat" @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -559,12 +559,14 @@ onMounted(() => {
|
||||
</VWindow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="!props.default" color="error" @click="removeSubscribe" class="me-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn v-if="!props.default" color="error" variant="tonal" @click="removeSubscribe">
|
||||
{{ t('dialog.subscribeEdit.cancelSubscribe') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click=";`${props.default ? saveDefaultSubscribeConfig() : updateSubscribeInfo()}`"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import NoDataFound from '@/components/NoDataFound.vue'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
|
||||
|
||||
@@ -105,9 +105,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -141,4 +141,29 @@ function updateFilter(key: string, values: string[]) {
|
||||
gap: 1rem;
|
||||
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>
|
||||
|
||||
@@ -142,4 +142,24 @@ function handleDetail(item: Context) {
|
||||
max-block-size: 60vh;
|
||||
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>
|
||||
|
||||
@@ -85,7 +85,7 @@ function updateFilter(values: string[]) {
|
||||
@update:model-value="updateFilter"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in options"
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
@@ -97,12 +97,39 @@ function updateFilter(values: string[]) {
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</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>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VBtn
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-restore"
|
||||
class="px-5 me-3"
|
||||
@click="handleReset"
|
||||
>
|
||||
{{ t('dialog.u115Auth.reset') }}
|
||||
@@ -238,8 +238,10 @@ onUnmounted(() => {
|
||||
<VSpacer />
|
||||
|
||||
<VBtn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-check"
|
||||
class="px-5 me-3"
|
||||
class="px-5"
|
||||
@click="handleDone"
|
||||
>
|
||||
{{ t('dialog.u115Auth.complete') }}
|
||||
|
||||
@@ -612,12 +612,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="props.oper === 'add'"
|
||||
:disabled="isAdding"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="addUser"
|
||||
prepend-icon="mdi-plus"
|
||||
class="px-5"
|
||||
@@ -629,6 +630,7 @@ onMounted(() => {
|
||||
v-else
|
||||
:disabled="isUpdating"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="updateUser"
|
||||
prepend-icon="mdi-content-save"
|
||||
class="px-5"
|
||||
|
||||
@@ -6,8 +6,8 @@ import useDragAndDrop from '@core/utils/workflow'
|
||||
import { Workflow } from '@/api/types'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import WorkflowSidebar from '@/layouts/components/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/layouts/components/DropzoneBackground.vue'
|
||||
import WorkflowSidebar from '@/components/workflow/WorkflowSidebar.vue'
|
||||
import DropzoneBackground from '@/components/workflow/DropzoneBackground.vue'
|
||||
import ImportCodeDialog from '@/components/dialog/ImportCodeDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
|
||||
@@ -312,12 +312,19 @@ onMounted(() => {
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -125,9 +125,17 @@ const $toast = useToast()
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<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') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FileList from './filebrowser/FileList.vue'
|
||||
import FileToolbar from './filebrowser/FileToolbar.vue'
|
||||
import FileNavigator from './filebrowser/FileNavigator.vue'
|
||||
import FileList from './FileList.vue'
|
||||
import FileToolbar from './FileToolbar.vue'
|
||||
import FileNavigator from './FileNavigator.vue'
|
||||
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
|
||||
import { storageIconDict } from '@/api/constants'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
@@ -372,7 +372,7 @@ onMounted(() => {
|
||||
:key="key"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
:color="filterForm[key].length > 0 ? 'primary' : undefined"
|
||||
color="primary"
|
||||
:prepend-icon="getFilterIcon(key)"
|
||||
class="filter-btn"
|
||||
rounded="pill"
|
||||
@@ -555,7 +555,7 @@ onMounted(() => {
|
||||
v-for="(title, key) in filterTitles"
|
||||
v-show="filterOptions[key].length > 0"
|
||||
:key="key"
|
||||
variant="text"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="filter-btn-mobile"
|
||||
@click="toggleFilterMenu(key)"
|
||||
@@ -575,7 +575,7 @@ onMounted(() => {
|
||||
</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>
|
||||
<span class="filter-label">
|
||||
{{ t('torrent.allFilters') }}
|
||||
@@ -665,7 +665,6 @@ onMounted(() => {
|
||||
|
||||
.filter-btn {
|
||||
min-inline-size: 0;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.1);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
@@ -733,7 +732,6 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.08);
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
padding-block: 4px;
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { PropType } from 'vue'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
// 初始化 markdown-it
|
||||
// 版本历史可能来自插件市场或 Release 内容,禁止透传原始 HTML,避免外部内容注入脚本或事件属性。
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
@@ -27,23 +27,100 @@ function renderMarkdown(value: string) {
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
history: Object as PropType<{ [key: string]: string }>,
|
||||
hasAction: Function as PropType<(version: string) => boolean>,
|
||||
})
|
||||
|
||||
function shouldRenderAction(version: string) {
|
||||
return props.hasAction?.(version) ?? true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCardText>
|
||||
<VList>
|
||||
<VListItem v-for="(value, key) in props.history" :key="key">
|
||||
<VListItemTitle class="font-bold text-lg">
|
||||
{{ key }}
|
||||
</VListItemTitle>
|
||||
<div class="markdown-body text-gray-500" v-html="renderMarkdown(value)" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VCardText class="version-history">
|
||||
<div class="version-history__list">
|
||||
<section v-for="(value, key) in props.history" :key="key" class="version-history__item">
|
||||
<div
|
||||
class="version-history__top"
|
||||
:class="{ 'version-history__top--with-action': $slots.action && shouldRenderAction(String(key)) }"
|
||||
>
|
||||
<div class="version-history__header">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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(h2),
|
||||
.markdown-body :deep(h3) {
|
||||
@@ -112,4 +189,28 @@ const props = defineProps({
|
||||
border-inline-start: 3px solid rgba(127, 127, 127, 0.4);
|
||||
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>
|
||||
|
||||
@@ -163,9 +163,9 @@ const instructions = computed(() => {
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VCardActions class="app-dialog-actions">
|
||||
<VSpacer />
|
||||
<VBtn color="primary" variant="text" @click="showInstructions = false">
|
||||
<VBtn color="primary" variant="flat" class="px-5" @click="showInstructions = false">
|
||||
{{ t('pwa.gotIt') }}
|
||||
</VBtn>
|
||||
</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>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { checkPWAStatus, isPWADisplayMode } from '@/@core/utils/navigator'
|
||||
import { checkPWAStatus, isMobileDevice, isPWADisplayMode } from '@/@core/utils/navigator'
|
||||
|
||||
// 全局PWA状态,确保只初始化一次
|
||||
const globalPwaStatus = ref<{
|
||||
@@ -34,11 +34,14 @@ async function initializePWAGlobally() {
|
||||
globalPwaStatus.value = await checkPWAStatus()
|
||||
} catch (error) {
|
||||
console.error('Failed to detect PWA status', error)
|
||||
const isStandaloneMode = isPWADisplayMode()
|
||||
|
||||
// 即使检测失败,也设置一个合理的默认值
|
||||
globalPwaStatus.value = {
|
||||
hasPWAFeatures: false,
|
||||
isStandaloneMode: isPWADisplayMode(),
|
||||
isPWAEnvironment: isPWADisplayMode(),
|
||||
isStandaloneMode,
|
||||
// iOS Safari 浏览器模式可能取不到 Service Worker 注册信息,但移动端仍应使用 App 交互。
|
||||
isPWAEnvironment: isStandaloneMode || isMobileDevice(),
|
||||
isFullPWA: false,
|
||||
}
|
||||
} finally {
|
||||
@@ -56,7 +59,8 @@ export function usePWA() {
|
||||
|
||||
// 基于新的PWA状态结构
|
||||
const pwaMode = computed(() => {
|
||||
return globalPwaStatus.value?.isPWAEnvironment ?? false
|
||||
// PWA 状态异步恢复前先用移动端特征兜底,避免 Safari 浏览器首屏阶段缺少移动端交互。
|
||||
return globalPwaStatus.value?.isPWAEnvironment ?? isMobileDevice()
|
||||
})
|
||||
|
||||
const appMode = computed(() => {
|
||||
|
||||
@@ -85,7 +85,10 @@ export function usePullDownGesture(options: PullDownOptions = {}) {
|
||||
})
|
||||
|
||||
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)`
|
||||
})
|
||||
|
||||
// 弹窗检测函数
|
||||
|
||||
@@ -7,6 +7,7 @@ import { themeManager } from '@/utils/themeManager'
|
||||
|
||||
export const THEME_CUSTOMIZER_STORAGE_KEY = 'moviepilot-theme-customizer'
|
||||
export const THEME_CUSTOMIZER_CHANGE_EVENT = 'moviepilot-theme-customizer-change'
|
||||
export const THEME_CUSTOMIZER_OPEN_EVENT = 'moviepilot-theme-customizer-open'
|
||||
|
||||
export const themeCustomizerPrimaryColors = [
|
||||
{ name: 'Purple', value: '#9155FD' },
|
||||
@@ -23,9 +24,37 @@ export const themeCustomizerPrimaryColors = [
|
||||
{ name: 'Slate', value: '#607D8B' },
|
||||
] 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 ThemeCustomizerRadius = 'default' | 'extra' | 'huge' | 'large' | 'small'
|
||||
export type ThemeCustomizerShadow = 'none' | 'low' | 'medium' | 'high'
|
||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||
export type ThemeCustomizerSkin = 'bordered' | 'default'
|
||||
export type ThemeCustomizerTheme = 'auto' | 'dark' | 'light' | 'purple' | 'transparent'
|
||||
|
||||
@@ -43,10 +72,16 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||
const validRadii: ThemeCustomizerRadius[] = ['small', 'default', 'large', 'extra', 'huge']
|
||||
const validShadows: ThemeCustomizerShadow[] = ['none', 'low', 'medium', 'high']
|
||||
const validRadii: ThemeCustomizerRadius[] = ['none', 'small', 'default', 'large', 'extra']
|
||||
const validShadows: readonly ThemeCustomizerShadow[] = themeCustomizerShadowLevels
|
||||
const validSkins: ThemeCustomizerSkin[] = ['default', 'bordered']
|
||||
const validThemes: ThemeCustomizerTheme[] = ['auto', 'light', 'dark', 'purple', 'transparent']
|
||||
const legacyShadowMap: Record<string, ThemeCustomizerShadow> = {
|
||||
high: '24',
|
||||
low: '6',
|
||||
medium: '12',
|
||||
none: '0',
|
||||
}
|
||||
|
||||
let themeApplyVersion = 0
|
||||
|
||||
@@ -72,27 +107,35 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
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 {
|
||||
const fallback = getDefaultThemeCustomizerSettings()
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
|
||||
return {
|
||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
primaryColor: isHexColor(settings.primaryColor) ? settings.primaryColor.toUpperCase() : fallback.primaryColor,
|
||||
radius: validRadii.includes(settings.radius as ThemeCustomizerRadius)
|
||||
? (settings.radius as ThemeCustomizerRadius)
|
||||
radius: validRadii.includes(radius as ThemeCustomizerRadius)
|
||||
? (radius as ThemeCustomizerRadius)
|
||||
: fallback.radius,
|
||||
semiDarkMenu: typeof settings.semiDarkMenu === 'boolean' ? settings.semiDarkMenu : fallback.semiDarkMenu,
|
||||
shadow: validShadows.includes(settings.shadow as ThemeCustomizerShadow)
|
||||
? (settings.shadow as ThemeCustomizerShadow)
|
||||
: fallback.shadow,
|
||||
shadow: normalizeThemeCustomizerShadow(settings.shadow),
|
||||
skin: validSkins.includes(settings.skin as ThemeCustomizerSkin)
|
||||
? (settings.skin as ThemeCustomizerSkin)
|
||||
: fallback.skin,
|
||||
@@ -246,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
@@ -323,7 +366,7 @@ export function useThemeCustomizer() {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayout from './components/DefaultLayout.vue'
|
||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
import VerticalNavSectionTitle from '@/@layouts/components/VerticalNavSectionTitle.vue'
|
||||
import VerticalNavLayout from '@layouts/components/VerticalNavLayout.vue'
|
||||
import VerticalNavLink from '@layouts/components/VerticalNavLink.vue'
|
||||
import Footer from '@/layouts/components/Footer.vue'
|
||||
import UserNofification from '@/layouts/components/UserNotification.vue'
|
||||
import SearchBar from '@/layouts/components/SearchBar.vue'
|
||||
import ShortcutBar from '@/layouts/components/ShortcutBar.vue'
|
||||
import UserProfile from '@/layouts/components/UserProfile.vue'
|
||||
import QuickAccess from '@/layouts/components/QuickAccess.vue'
|
||||
import HeaderTab from '@/layouts/components/HeaderTab.vue'
|
||||
import { usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import Footer from './Footer.vue'
|
||||
import UserNofification from './UserNotification.vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import ShortcutBar from './ShortcutBar.vue'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
import QuickAccess from './QuickAccess.vue'
|
||||
import HeaderTab from './HeaderTab.vue'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import ThemeCustomizer from '@/components/theme/ThemeCustomizer.vue'
|
||||
import { useGlobalSettingsStore, usePluginSidebarNavStore, useUserStore } from '@/stores'
|
||||
import { getNavMenus } from '@/router/i18n-menu'
|
||||
import { filterPluginSidebarNavEntries } from '@/utils/pluginSidebarNav'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
@@ -24,14 +26,14 @@ import {
|
||||
hasPermission,
|
||||
type UserPermissionKey,
|
||||
} from '@/utils/permission'
|
||||
import { onUnreadMessage } from '@/utils/badge'
|
||||
import { usePullDownGesture } from '@/composables/usePullDownGesture'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import OfflinePage from '@/layouts/components/OfflinePage.vue'
|
||||
import OfflinePage from './OfflinePage.vue'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import {
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_CHANGE_EVENT,
|
||||
THEME_CUSTOMIZER_OPEN_EVENT,
|
||||
type ThemeCustomizerSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import logo from '@images/logo.svg?raw'
|
||||
@@ -43,17 +45,19 @@ const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeLayout = ref(readThemeCustomizerSettings().layout)
|
||||
const showThemeCustomizer = ref(false)
|
||||
|
||||
// 用户 Store
|
||||
const userStore = useUserStore()
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// ShortcutBar 引用
|
||||
const shortcutBarRef = ref<InstanceType<typeof ShortcutBar> | null>(null)
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 获取用户权限信息
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
|
||||
const showAgentAssistant = computed(
|
||||
() => globalSettingsStore.get('AI_AGENT_ENABLE') === true && globalSettingsStore.get('AI_AGENT_HIDE_ENTRY') !== true,
|
||||
)
|
||||
|
||||
// 开始菜单项
|
||||
const startMenus = ref<NavMenu[]>([])
|
||||
@@ -86,14 +90,14 @@ const horizontalNavGroups = computed(() =>
|
||||
)
|
||||
|
||||
const navbarExtraHeight = computed(() => {
|
||||
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.5 : 0
|
||||
const dynamicTabHeight = showDynamicHeaderTab.value ? 2.75 : 0
|
||||
const horizontalNavHeight = showHorizontalThemeNav.value ? 3.25 : 0
|
||||
|
||||
return `${dynamicTabHeight + horizontalNavHeight}rem`
|
||||
})
|
||||
|
||||
const mainContentPaddingTop = computed(() => {
|
||||
const dynamicTabPadding = showDynamicHeaderTab.value ? 3 : 0
|
||||
const dynamicTabPadding = showDynamicHeaderTab.value ? 3.25 : 0
|
||||
const horizontalNavPadding = showHorizontalThemeNav.value ? 3.5 : 0
|
||||
|
||||
return `${dynamicTabPadding + horizontalNavPadding}rem`
|
||||
@@ -283,6 +287,10 @@ function handleThemeCustomizerChange(event: Event) {
|
||||
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
|
||||
}
|
||||
|
||||
function handleThemeCustomizerOpen() {
|
||||
showThemeCustomizer.value = true
|
||||
}
|
||||
|
||||
function isHorizontalNavActive(item: NavMenu) {
|
||||
const targetPath = normalizeMenuPath(item.to)
|
||||
if (!targetPath) return false
|
||||
@@ -324,7 +332,7 @@ function closeHorizontalNavGroup() {
|
||||
}
|
||||
|
||||
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
|
||||
return isRef(value) ? value.value : value ?? fallback
|
||||
return isRef(value) ? value.value : (value ?? fallback)
|
||||
}
|
||||
|
||||
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
|
||||
@@ -382,18 +390,6 @@ function applyPendingHorizontalTab() {
|
||||
pendingHorizontalTab.value = null
|
||||
}
|
||||
|
||||
// 处理未读消息事件
|
||||
function handleUnreadMessage(count: number) {
|
||||
if (canAdmin.value && count > 0) {
|
||||
// 延迟一点时间确保组件已渲染
|
||||
setTimeout(() => {
|
||||
if (shortcutBarRef.value && typeof shortcutBarRef.value.openMessageDialog === 'function') {
|
||||
shortcutBarRef.value.openMessageDialog()
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭插件快速访问
|
||||
function handleClosePluginQuickAccess() {
|
||||
showPluginQuickAccess.value = false
|
||||
@@ -432,6 +428,10 @@ function appendPluginSidebarMenus() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 主题定制器由布局统一承载,监听需要尽早注册,避免异步加载菜单期间丢失打开事件。
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.addEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
|
||||
// 获取菜单列表
|
||||
startMenus.value = getMenuList(t('menu.start'))
|
||||
discoveryMenus.value = getMenuList(t('menu.discovery'))
|
||||
@@ -442,20 +442,15 @@ onMounted(async () => {
|
||||
await pluginSidebarNavStore.ensureSidebarNav()
|
||||
appendPluginSidebarMenus()
|
||||
|
||||
// 监听全局未读消息事件
|
||||
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||
|
||||
// 监听Service Worker消息
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
|
||||
window.addEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe()
|
||||
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
|
||||
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||
}
|
||||
@@ -472,6 +467,7 @@ onMounted(async () => {
|
||||
v-if="appMode && showPullIndicator"
|
||||
class="pull-indicator"
|
||||
:style="{
|
||||
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
|
||||
opacity: indicatorOpacity,
|
||||
transform: indicatorTransform,
|
||||
}"
|
||||
@@ -495,7 +491,7 @@ onMounted(async () => {
|
||||
<!-- 👉 Navbar -->
|
||||
<template #navbar="{ toggleVerticalOverlayNavActive }">
|
||||
<div
|
||||
class="theme-navbar-row d-flex h-14 align-center mx-1"
|
||||
class="theme-navbar-row d-flex h-16 align-center mx-1"
|
||||
:class="{ 'theme-navbar-row--horizontal': showHorizontalThemeNav }"
|
||||
>
|
||||
<RouterLink v-if="showHorizontalThemeNav" :to="canAdmin ? '/dashboard' : '/apps'" class="theme-horizontal-logo">
|
||||
@@ -521,7 +517,7 @@ onMounted(async () => {
|
||||
<!-- 👉 Horizontal Search Icon -->
|
||||
<SearchBar v-if="showHorizontalThemeNav" icon-only />
|
||||
<!-- 👉 Shortcuts -->
|
||||
<ShortcutBar v-if="canAdmin" ref="shortcutBarRef" />
|
||||
<ShortcutBar v-if="canAdmin" />
|
||||
<!-- 👉 Notification -->
|
||||
<UserNofification />
|
||||
<!-- 👉 UserProfile -->
|
||||
@@ -711,9 +707,17 @@ onMounted(async () => {
|
||||
@close="handleClosePluginQuickAccess"
|
||||
@plugin-click="handlePluginClick"
|
||||
/>
|
||||
|
||||
<!-- 👉 Theme Customizer -->
|
||||
<ThemeCustomizer v-if="showThemeCustomizer" @close="showThemeCustomizer = false" />
|
||||
|
||||
<!-- 👉 Agent Assistant -->
|
||||
<AgentAssistantWidget v-if="showAgentAssistant" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.main-content-wrapper {
|
||||
backface-visibility: hidden;
|
||||
block-size: 100%;
|
||||
@@ -727,6 +731,10 @@ onMounted(async () => {
|
||||
margin-inline: 0 !important;
|
||||
}
|
||||
|
||||
:deep(.layout-dynamic-header-tab) {
|
||||
padding-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
.theme-horizontal-logo {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
@@ -789,10 +797,10 @@ onMounted(async () => {
|
||||
|
||||
.theme-horizontal-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
align-items: center;
|
||||
block-size: 3.25rem;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.25rem 0.5rem;
|
||||
padding-inline: 0.5rem;
|
||||
scrollbar-width: none;
|
||||
@@ -821,6 +829,7 @@ onMounted(async () => {
|
||||
|
||||
.pull-indicator {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -828,12 +837,19 @@ onMounted(async () => {
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(var(--v-theme-surface), 0.3);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 10%), 0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: 80px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 10%),
|
||||
0 1px 3px rgba(0, 0, 0, 6%);
|
||||
inset-block-start: calc(
|
||||
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
@@ -853,7 +869,9 @@ html[class*='mica'] .pull-indicator,
|
||||
html[class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 20%);
|
||||
background: rgba(255, 255, 255, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 12%), 0 4px 16px rgba(0, 0, 0, 8%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 12%),
|
||||
0 4px 16px rgba(0, 0, 0, 8%);
|
||||
}
|
||||
|
||||
html[class*='transparent'] .indicator-icon,
|
||||
@@ -867,7 +885,9 @@ html[data-theme='dark'][class*='mica'] .pull-indicator,
|
||||
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
|
||||
border: 1px solid rgba(255, 255, 255, 10%);
|
||||
background: rgba(18, 18, 18, 95%);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 30%), 0 4px 16px rgba(0, 0, 0, 20%);
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 30%),
|
||||
0 4px 16px rgba(0, 0, 0, 20%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'][class*='transparent'] .indicator-icon,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user