mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-26 10:41:59 +08:00
Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27eeea6c0 | ||
|
|
39a9fe0541 | ||
|
|
92d153760b | ||
|
|
ff9dc26040 | ||
|
|
269da6eefe | ||
|
|
817bc1019d | ||
|
|
de8c22c180 | ||
|
|
8f074fb381 | ||
|
|
e16870859d | ||
|
|
e1ba13abf2 | ||
|
|
8d1f226035 | ||
|
|
5367a07a90 | ||
|
|
8e681dbbe7 | ||
|
|
c26c3c4284 | ||
|
|
461ed560f0 | ||
|
|
dd27bcdaca | ||
|
|
c076be868a | ||
|
|
d2634095e4 | ||
|
|
a5a3e1e1e4 | ||
|
|
09a20967c0 | ||
|
|
9a2af0a412 | ||
|
|
6bbac7db7d | ||
|
|
b212e066b6 | ||
|
|
f7d354fca4 | ||
|
|
693b081ecf | ||
|
|
d5f0fed954 | ||
|
|
67ef98bfee | ||
|
|
1094d80b9c | ||
|
|
98e6481812 | ||
|
|
7db088eb79 | ||
|
|
dec154c042 | ||
|
|
d06ce5d984 | ||
|
|
af604d0c5c | ||
|
|
fa39c3750c | ||
|
|
ce64fb03ce | ||
|
|
764f14a7f4 | ||
|
|
621200b3b2 | ||
|
|
60c46ebbaf | ||
|
|
4fe65b770e | ||
|
|
c010162e54 | ||
|
|
5cc4ea7740 | ||
|
|
8a19fbc584 | ||
|
|
24412989bb | ||
|
|
55d0693e41 | ||
|
|
0459a2d108 | ||
|
|
624b55576d | ||
|
|
c848b90599 | ||
|
|
79bc99e624 | ||
|
|
850f7ee9be | ||
|
|
4cc105287f | ||
|
|
527c0845bc | ||
|
|
2a3ef1a1f6 | ||
|
|
7539fa26aa | ||
|
|
3547c146a8 | ||
|
|
da630b0966 | ||
|
|
cff4c1fecc | ||
|
|
3f525c6457 | ||
|
|
8c1eb3e981 | ||
|
|
effc83b9a1 | ||
|
|
39ce8d93dd | ||
|
|
fa32ac9f48 | ||
|
|
ab243bbbb7 | ||
|
|
abcd3f5e81 | ||
|
|
92bf1ca790 | ||
|
|
faadf39b80 | ||
|
|
878c3c98b2 | ||
|
|
2d8ae9f462 | ||
|
|
00051a0eac | ||
|
|
88726c0f12 | ||
|
|
79bc9f0ff0 | ||
|
|
4492e211bb | ||
|
|
bb5299c812 | ||
|
|
f54a5285d6 | ||
|
|
03b80d6434 | ||
|
|
a916a1569d | ||
|
|
5c27c32914 | ||
|
|
c57a8b7b48 | ||
|
|
0aa7d37e45 | ||
|
|
d3b3b08318 | ||
|
|
5127ab6c08 | ||
|
|
a3810fb9e2 | ||
|
|
f05eac0b93 | ||
|
|
ef170b54c6 | ||
|
|
536844d291 | ||
|
|
21d4f9fd73 | ||
|
|
2aae0f08ad | ||
|
|
248984261e | ||
|
|
82ab594e0c | ||
|
|
5ca374349d | ||
|
|
7452e18fc3 | ||
|
|
75dffdb7c2 | ||
|
|
a3d74929b3 | ||
|
|
fd9c314d55 | ||
|
|
d02e98736f | ||
|
|
2e46ebe353 | ||
|
|
fffb526a87 | ||
|
|
98769a313f | ||
|
|
1c28d98d70 | ||
|
|
7a430b095a | ||
|
|
864af36b1c | ||
|
|
e3fa0b9dae | ||
|
|
ed28832484 | ||
|
|
e692a91113 | ||
|
|
7574719e04 | ||
|
|
ca800f7ae7 | ||
|
|
7d0550825b | ||
|
|
59abda63e3 | ||
|
|
0776bc9fa0 | ||
|
|
8c78f9ca90 | ||
|
|
1889d5d1e3 | ||
|
|
165247b263 | ||
|
|
9a2384bf02 | ||
|
|
71c4f587d3 | ||
|
|
8edbfa0b0e | ||
|
|
ee187a1c75 | ||
|
|
afb617724c | ||
|
|
05bbecba53 | ||
|
|
deb9b103da | ||
|
|
2d92315e17 | ||
|
|
077f0c1366 | ||
|
|
eab4caa279 | ||
|
|
3bba73abf1 | ||
|
|
8edf1f686a | ||
|
|
3440b377ee | ||
|
|
9161e5e300 | ||
|
|
25fdb09c61 | ||
|
|
25119b224d | ||
|
|
76f79e1656 | ||
|
|
b5230cef79 | ||
|
|
f871746efb | ||
|
|
d17ca69a28 | ||
|
|
e445437129 | ||
|
|
090c5476c6 | ||
|
|
646c7b33dd | ||
|
|
0902f16f84 | ||
|
|
ec4eaed1df | ||
|
|
175f610524 | ||
|
|
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 |
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"
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -37,3 +37,5 @@ src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
.codex-qa/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.11",
|
||||
"version": "2.14.2",
|
||||
"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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
|
||||
touch-action: pan-y;
|
||||
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
|
||||
@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
|
||||
// 格式化为Sxx
|
||||
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
|
||||
|
||||
/**
|
||||
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
|
||||
*/
|
||||
export function formatSeasonEpisode(
|
||||
season: number | string | null | undefined,
|
||||
episodeNumbers: number[],
|
||||
): string {
|
||||
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
|
||||
const normalizedNumbers = [...new Set(episodeNumbers)]
|
||||
.map(number => Number(number))
|
||||
.filter(number => Number.isFinite(number) && number > 0)
|
||||
.sort((first, second) => first - second)
|
||||
|
||||
if (!normalizedNumbers.length) return seasonText
|
||||
|
||||
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
|
||||
const ranges: string[] = []
|
||||
let start = normalizedNumbers[0]
|
||||
let end = normalizedNumbers[0]
|
||||
|
||||
for (let index = 1; index < normalizedNumbers.length; index++) {
|
||||
const currentNumber = normalizedNumbers[index]
|
||||
|
||||
if (currentNumber === end + 1) {
|
||||
end = currentNumber
|
||||
} else {
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
start = currentNumber
|
||||
end = currentNumber
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
|
||||
return `${seasonText}${ranges.join('、')}`
|
||||
}
|
||||
|
||||
// 格式化为xx[TGMK]B
|
||||
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
|
||||
// 负数标记
|
||||
|
||||
8
src/@layouts/types.d.ts
vendored
8
src/@layouts/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import type { Component, Ref, VNode } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -113,14 +113,18 @@ export interface NavLinkProps {
|
||||
rel?: ATagRelAttrValues
|
||||
}
|
||||
|
||||
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
|
||||
|
||||
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
title: string
|
||||
full_title?: string
|
||||
icon?: unknown
|
||||
iconColor?: NavMenuIconColor
|
||||
badgeContent?: string
|
||||
badgeClass?: string
|
||||
disable?: boolean
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenuTabItem {
|
||||
@@ -128,6 +132,8 @@ export interface NavMenuTabItem {
|
||||
icon?: string
|
||||
tab: string
|
||||
description?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenu extends NavLink {
|
||||
|
||||
350
src/App.vue
350
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
@@ -9,16 +9,28 @@ 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 {
|
||||
applyStoredTransparencySettings,
|
||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||
type TransparencyGlassQuality,
|
||||
type TransparencySettings,
|
||||
} from '@/composables/useTransparencySettings'
|
||||
import { completeLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import {
|
||||
useGlobalOfflineStatus,
|
||||
type ConnectionFailureReason,
|
||||
} from '@/composables/useOfflineStatus'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS = 180_000
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
@@ -48,6 +60,7 @@ const authStore = useAuthStore()
|
||||
const isLogin = computed(() => authStore.token)
|
||||
const route = useRoute()
|
||||
const { initializePWA } = usePWA()
|
||||
const offlineStatus = useGlobalOfflineStatus()
|
||||
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
@@ -58,66 +71,233 @@ const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isTransparentTheme.value),
|
||||
)
|
||||
const transparentBackgroundBlur = ref(16)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(
|
||||
localStorage.getItem('transparency-glass-quality') === 'realtime' ? 'realtime' : 'lightweight',
|
||||
)
|
||||
const shouldRenderGlobalBlurLayer = computed(
|
||||
() =>
|
||||
shouldUseTransparentBackgroundTreatment.value &&
|
||||
transparentBackgroundBlur.value > 0 &&
|
||||
transparencyGlassQuality.value === 'realtime',
|
||||
)
|
||||
const isRenderThrottled = ref(document.visibilityState === 'hidden')
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
let windowBlurRenderThrottleTimer: number | null = null
|
||||
|
||||
function getStoredNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const parsed = Number.parseFloat(localStorage.getItem(key) || '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||
function applyTransparentBackgroundSettings() {
|
||||
const settings = applyStoredTransparencySettings()
|
||||
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
transparentBackgroundBlur.value = settings.backgroundBlur
|
||||
transparencyGlassQuality.value = settings.glassQuality
|
||||
}
|
||||
|
||||
function applyTransparentBackgroundSettings() {
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-poster-opacity',
|
||||
(1 - getStoredNumber('transparency-background-poster-opacity', 0, 0, 1)).toString(),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--transparent-background-blur',
|
||||
`${getStoredNumber('transparency-background-blur', 16, 0, 30)}px`,
|
||||
)
|
||||
// 响应透明主题设置变更事件,刷新背景模糊和玻璃质量。
|
||||
function handleTransparencySettingsChanged(event: Event) {
|
||||
const { backgroundBlur, glassQuality } = (event as CustomEvent<TransparencySettings>).detail
|
||||
|
||||
transparentBackgroundBlur.value = backgroundBlur
|
||||
transparencyGlassQuality.value = glassQuality
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
// 心跳检测
|
||||
let heartbeatInterval: number | null = null
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
function clearWindowBlurRenderThrottleTimer() {
|
||||
if (windowBlurRenderThrottleTimer) {
|
||||
window.clearTimeout(windowBlurRenderThrottleTimer)
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
// 如果已经有心跳,则先停止
|
||||
if (heartbeatInterval) {
|
||||
stopHeartbeat()
|
||||
function restoreForegroundRendering() {
|
||||
const wasRenderThrottled = isRenderThrottled.value
|
||||
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
isRenderThrottled.value = false
|
||||
|
||||
if (wasRenderThrottled && backgroundImages.value.length > 1) {
|
||||
startBackgroundRotation()
|
||||
rotateBackgroundImage()
|
||||
}
|
||||
}
|
||||
|
||||
function throttleBackgroundRendering() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
resetBackgroundCrossfade()
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
|
||||
function handleWindowBlurRenderThrottle() {
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
if (document.visibilityState === 'hidden') {
|
||||
throttleBackgroundRendering()
|
||||
return
|
||||
}
|
||||
|
||||
// 开始心跳任务
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
try {
|
||||
if (isLogin.value) {
|
||||
await api.get('system/ping')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Heartbeat request failed:', error)
|
||||
windowBlurRenderThrottleTimer = window.setTimeout(() => {
|
||||
if (document.visibilityState === 'visible' && !document.hasFocus()) {
|
||||
isRenderThrottled.value = true
|
||||
}
|
||||
windowBlurRenderThrottleTimer = null
|
||||
}, WINDOW_BLUR_RENDER_THROTTLE_DELAY_MS)
|
||||
}
|
||||
|
||||
function handleWindowFocusRenderThrottle() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
let heartbeatInterval: number | null = null
|
||||
let connectionRetryTimer: number | null = null
|
||||
let connectionProbePromise: Promise<boolean> | null = null
|
||||
let connectionProbeFailures = 0
|
||||
let prefersColorSchemeMediaQuery: MediaQueryList | null = null
|
||||
|
||||
const SERVER_PROBE_TIMEOUT_MS = 8_000
|
||||
const SERVER_PROBE_FAILURE_THRESHOLD = 2
|
||||
const SERVER_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000] as const
|
||||
|
||||
/** 清除等待中的服务重连任务。 */
|
||||
function clearConnectionRetryTimer() {
|
||||
if (!connectionRetryTimer) return
|
||||
|
||||
window.clearTimeout(connectionRetryTimer)
|
||||
connectionRetryTimer = null
|
||||
}
|
||||
|
||||
/** 根据浏览器状态和请求错误判断本次探测失败原因。 */
|
||||
function resolveProbeFailureReason(error: unknown): ConnectionFailureReason {
|
||||
if (!offlineStatus.browserOnline.value) return 'browser-offline'
|
||||
|
||||
const errorCode = (error as { code?: string } | null)?.code
|
||||
if (errorCode === 'ECONNABORTED' || errorCode === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
return 'server-unreachable'
|
||||
}
|
||||
|
||||
/** 按退避间隔安排下一次 MoviePilot 服务探测。 */
|
||||
function scheduleConnectionRetry() {
|
||||
clearConnectionRetryTimer()
|
||||
|
||||
const retryIndex = Math.min(Math.max(connectionProbeFailures - 1, 0), SERVER_RETRY_DELAYS_MS.length - 1)
|
||||
connectionRetryTimer = window.setTimeout(() => {
|
||||
connectionRetryTimer = null
|
||||
void probeServerConnection()
|
||||
}, SERVER_RETRY_DELAYS_MS[retryIndex])
|
||||
}
|
||||
|
||||
/** 使用后端 ping 接口执行去重后的权威服务连通性探测。 */
|
||||
async function probeServerConnection(showChecking = false): Promise<boolean> {
|
||||
if (!isLogin.value) return false
|
||||
if (connectionProbePromise) return connectionProbePromise
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
if (showChecking) offlineStatus.markConnectionChecking(offlineStatus.connectionReason.value ?? undefined)
|
||||
|
||||
const successSequenceAtProbeStart = offlineStatus.serverSuccessSequence.value
|
||||
const probePromise = (async () => {
|
||||
try {
|
||||
await api.get(
|
||||
'system/ping',
|
||||
{
|
||||
skipConnectionTracking: true,
|
||||
timeout: SERVER_PROBE_TIMEOUT_MS,
|
||||
} as ConnectionAwareRequestConfig,
|
||||
)
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!isLogin.value) {
|
||||
offlineStatus.markServerOnline()
|
||||
return false
|
||||
}
|
||||
|
||||
// 探测期间若已有其他接口成功,则以更新的成功响应为准,避免旧失败覆盖新状态。
|
||||
if (offlineStatus.serverSuccessSequence.value > successSequenceAtProbeStart) {
|
||||
connectionProbeFailures = 0
|
||||
return true
|
||||
}
|
||||
|
||||
connectionProbeFailures += 1
|
||||
const failureReason = resolveProbeFailureReason(error)
|
||||
|
||||
if (connectionProbeFailures >= SERVER_PROBE_FAILURE_THRESHOLD) {
|
||||
offlineStatus.markServerOffline(failureReason)
|
||||
} else {
|
||||
offlineStatus.markConnectionChecking(failureReason)
|
||||
}
|
||||
|
||||
scheduleConnectionRetry()
|
||||
return false
|
||||
}
|
||||
})()
|
||||
|
||||
connectionProbePromise = probePromise
|
||||
try {
|
||||
return await probePromise
|
||||
} finally {
|
||||
if (connectionProbePromise === probePromise) connectionProbePromise = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动即时服务探测和五分钟在线心跳。 */
|
||||
function startHeartbeat() {
|
||||
if (heartbeatInterval) window.clearInterval(heartbeatInterval)
|
||||
|
||||
void probeServerConnection()
|
||||
|
||||
heartbeatInterval = window.setInterval(async () => {
|
||||
if (isLogin.value) await probeServerConnection()
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
/** 停止心跳和等待中的自动重连任务。 */
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatInterval) {
|
||||
window.clearInterval(heartbeatInterval)
|
||||
heartbeatInterval = null
|
||||
}
|
||||
|
||||
clearConnectionRetryTimer()
|
||||
connectionProbeFailures = 0
|
||||
}
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionCheckRequestId.value,
|
||||
() => {
|
||||
if (isLogin.value) void probeServerConnection(true)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.connectionStatus.value,
|
||||
status => {
|
||||
if (status !== 'online') return
|
||||
connectionProbeFailures = 0
|
||||
clearConnectionRetryTimer()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => offlineStatus.browserOnline.value,
|
||||
browserIsOnline => {
|
||||
if (!isLogin.value) return
|
||||
offlineStatus.requestConnectionCheck(browserIsOnline ? undefined : 'browser-offline')
|
||||
},
|
||||
)
|
||||
|
||||
// 更新data-theme属性以便CSS选择器能正确匹配
|
||||
function updateHtmlThemeAttribute(themeName: string) {
|
||||
document.documentElement.setAttribute('data-theme', themeName)
|
||||
@@ -125,6 +305,7 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
syncRootLaunchPalette()
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
@@ -148,22 +329,60 @@ function syncThemePreferenceFromStorage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||
function handleSystemThemeChange() {
|
||||
if ((localStorage.getItem('theme') || 'auto') === 'auto') {
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
syncThemePreferenceFromStorage()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
} else {
|
||||
throttleBackgroundRendering()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面从缓存或重新聚焦恢复时刷新主题偏好和异常连接状态。 */
|
||||
function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
restoreForegroundRendering()
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
}
|
||||
|
||||
// 清理背景图交叉淡入淡出定时器。
|
||||
function clearBackgroundCrossfadeTimer() {
|
||||
if (backgroundCrossfadeTimer) {
|
||||
window.clearTimeout(backgroundCrossfadeTimer)
|
||||
backgroundCrossfadeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 重置背景图交叉淡入淡出状态。
|
||||
function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
function activateBackgroundImage(nextIndex: number) {
|
||||
if (nextIndex === activeImageIndex.value) return
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
async function fetchBackgroundImages() {
|
||||
try {
|
||||
@@ -172,6 +391,7 @@ async function fetchBackgroundImages() {
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: backgroundRequestController.signal,
|
||||
})
|
||||
resetBackgroundCrossfade()
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
throw e
|
||||
@@ -180,6 +400,8 @@ async function fetchBackgroundImages() {
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (isRenderThrottled.value) return
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
@@ -187,7 +409,7 @@ function rotateBackgroundImage() {
|
||||
preloadImage(backgroundImages.value[nextIndex]).then(success => {
|
||||
// 只有图片成功加载才切换
|
||||
if (success) {
|
||||
activeImageIndex.value = nextIndex
|
||||
activateBackgroundImage(nextIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -212,6 +434,7 @@ function startBackgroundRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
// 停止登录页或透明主题背景图加载、重试和轮播。
|
||||
function stopBackgroundLoading() {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
@@ -221,9 +444,11 @@ function stopBackgroundLoading() {
|
||||
backgroundRetryTimer = null
|
||||
}
|
||||
|
||||
resetBackgroundCrossfade()
|
||||
removeBackgroundTimer('background-rotation')
|
||||
}
|
||||
|
||||
// 初始化登录后的全局设置和用户设置状态。
|
||||
async function initializeAuthenticatedState() {
|
||||
if (!isLogin.value) return
|
||||
|
||||
@@ -236,6 +461,7 @@ async function initializeAuthenticatedState() {
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟初始化登录态数据,避开登录成功后的即时路由跳转窗口。
|
||||
function scheduleAuthenticatedStateInitialization() {
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
@@ -360,6 +586,9 @@ onMounted(async () => {
|
||||
document.addEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.addEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
window.addEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.addEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
watch(
|
||||
@@ -395,6 +624,7 @@ onMounted(async () => {
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
stopHeartbeat()
|
||||
offlineStatus.markServerOnline()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -406,6 +636,7 @@ onUnmounted(() => {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
}
|
||||
clearWindowBlurRenderThrottleTimer()
|
||||
// 停止心跳
|
||||
stopHeartbeat()
|
||||
prefersColorSchemeMediaQuery?.removeEventListener('change', handleSystemThemeChange)
|
||||
@@ -413,26 +644,33 @@ onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityThemeSync)
|
||||
window.removeEventListener('pageshow', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handlePageShowThemeSync)
|
||||
window.removeEventListener('focus', handleWindowFocusRenderThrottle)
|
||||
window.removeEventListener('blur', handleWindowBlurRenderThrottle)
|
||||
window.removeEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-wrapper">
|
||||
<div class="app-wrapper" :class="{ 'app-wrapper--render-throttled': isRenderThrottled }">
|
||||
<!-- 透明主题背景 -->
|
||||
<div
|
||||
v-if="backgroundImages.length > 0 && (isTransparentTheme || !isLogin)"
|
||||
class="background-container"
|
||||
:class="{ 'is-transparent-theme': isTransparentTheme && isLogin }"
|
||||
:class="{
|
||||
'is-transparent-theme': shouldUseTransparentBackgroundTreatment,
|
||||
'is-transparent-glass-lightweight':
|
||||
shouldUseTransparentBackgroundTreatment && transparencyGlassQuality === 'lightweight',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex }"
|
||||
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="isLogin && isTransparentTheme" class="global-blur-layer"></div>
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
</div>
|
||||
<!-- 页面内容 -->
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
@@ -486,18 +724,36 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 2;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.previous {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous {
|
||||
filter: blur(var(--transparent-background-blur, 16px));
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active::after,
|
||||
.background-container.is-transparent-glass-lightweight .background-image.previous::after {
|
||||
background:
|
||||
linear-gradient(rgba(0, 0, 0, 30%) 0%, rgba(0, 0, 0, 60%) 100%),
|
||||
rgba(128, 128, 128, 30%);
|
||||
}
|
||||
|
||||
/* 全局磨砂层 */
|
||||
.global-blur-layer {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
z-index: 3;
|
||||
backdrop-filter: blur(var(--transparent-background-blur, 16px));
|
||||
background-color: rgba(128, 128, 128, 30%);
|
||||
block-size: 100%;
|
||||
@@ -506,6 +762,22 @@ onUnmounted(() => {
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.app-wrapper--render-throttled {
|
||||
.global-blur-layer {
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.login-bg-decor *,
|
||||
.login-logo,
|
||||
.login-logo-wrapper,
|
||||
.login-logo-wrapper::before,
|
||||
.login-title,
|
||||
.login-subtitle,
|
||||
.agent-assistant-fab * {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录页壁纸在 VApp 外层渲染,登录页 VApp 需要透明才能露出壁纸。 */
|
||||
.app-shell--login-wallpaper.v-application {
|
||||
background: transparent !important;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -30,42 +35,68 @@ api.interceptors.request.use(config => {
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface LocalizedApiPayload {
|
||||
detail?: unknown
|
||||
detail_i18n?: unknown
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
}
|
||||
|
||||
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
|
||||
function normalizeLocalizedMessage(payload: any): any {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
|
||||
|
||||
const localizedPayload = payload as LocalizedApiPayload
|
||||
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
|
||||
localizedPayload.message = localizedPayload.message_i18n
|
||||
}
|
||||
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
|
||||
localizedPayload.detail = localizedPayload.detail_i18n
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return normalizeLocalizedMessage(response.data)
|
||||
},
|
||||
error => {
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
@@ -75,12 +106,15 @@ api.interceptors.response.use(
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
} else {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -1000,6 +1000,12 @@ export interface MediaStatistic {
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -1020,6 +1026,18 @@ export interface Process {
|
||||
memory: number
|
||||
}
|
||||
|
||||
// 仪表板系统摘要
|
||||
export interface DashboardSystemInfo {
|
||||
// 主机名称
|
||||
hostname: string
|
||||
// 操作系统名称
|
||||
operating_system: string
|
||||
// MoviePilot 运行时间,单位秒
|
||||
runtime: number
|
||||
// MoviePilot 后端版本
|
||||
version: string
|
||||
}
|
||||
|
||||
// 下载器信息
|
||||
export interface DownloaderInfo {
|
||||
// 下载速度
|
||||
@@ -1034,18 +1052,86 @@ export interface DownloaderInfo {
|
||||
free_space: number
|
||||
}
|
||||
|
||||
// 仪表板系统内存信息
|
||||
export interface DashboardMemoryInfo {
|
||||
// 总内存字节数
|
||||
total: number
|
||||
// 已使用内存字节数,不包含缓存
|
||||
used: number
|
||||
// 缓存与缓冲区占用字节数
|
||||
cached: number
|
||||
// 可用内存字节数
|
||||
available: number
|
||||
// 已使用内存占总内存百分比,不包含缓存
|
||||
usage: number
|
||||
}
|
||||
|
||||
// 定时服务进度信息
|
||||
export interface ScheduleProgress {
|
||||
// ID
|
||||
id?: string
|
||||
// 名称
|
||||
name?: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider?: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 是否正在执行
|
||||
enable?: boolean
|
||||
// 当前完成百分比
|
||||
value?: number
|
||||
// 当前进度文本
|
||||
text?: string
|
||||
// 多语言进度文本
|
||||
text_i18n?: string
|
||||
// 执行状态
|
||||
status?: string
|
||||
// 最近一次执行是否成功
|
||||
success?: boolean
|
||||
// 最近一次开始时间
|
||||
started_at?: string
|
||||
// 最近一次结束时间
|
||||
finished_at?: string
|
||||
// 最近一次错误信息
|
||||
error?: string
|
||||
// 多语言错误信息
|
||||
error_i18n?: string
|
||||
// 扩展数据
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 定时服务信息
|
||||
export interface ScheduleInfo {
|
||||
// ID
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 状态
|
||||
status: string
|
||||
// 多语言状态
|
||||
status_i18n?: string
|
||||
// 下次运行时间
|
||||
next_run: string
|
||||
// 多语言下次运行时间
|
||||
next_run_i18n?: string
|
||||
// 当前完成百分比
|
||||
progress?: number
|
||||
// 进度文本
|
||||
progress_text?: string
|
||||
// 多语言进度文本
|
||||
progress_text_i18n?: string
|
||||
// 是否正在更新进度
|
||||
progress_enable?: boolean
|
||||
// 进度详情
|
||||
progress_detail?: ScheduleProgress
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1151,6 +1237,8 @@ export interface MediaServerLibrary {
|
||||
path?: string
|
||||
// 类型
|
||||
type?: string
|
||||
// 库内媒体数量
|
||||
item_count?: number
|
||||
// 图片
|
||||
image?: string
|
||||
// 图片列表
|
||||
@@ -1649,6 +1737,7 @@ export interface SubscribeShareStatistics {
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
|
||||
1864
src/components/agent/AgentAssistantEntry.vue
Normal file
1864
src/components/agent/AgentAssistantEntry.vue
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
28
src/components/agent/AgentAssistantWidget.vue
Normal file
28
src/components/agent/AgentAssistantWidget.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<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)
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
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>
|
||||
28
src/components/agent/pet/AgentPetStage.vue
Normal file
28
src/components/agent/pet/AgentPetStage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import CssRobotRenderer from './renderers/CssRobotRenderer.vue'
|
||||
import type { AgentPetActionName, AgentPetIntent, AgentPetRendererKind } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
renderer?: AgentPetRendererKind
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
renderer: 'css-robot',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CssRobotRenderer
|
||||
v-if="props.renderer === 'css-robot'"
|
||||
:action="props.action"
|
||||
:intent="props.intent"
|
||||
:thinking="props.thinking"
|
||||
/>
|
||||
</template>
|
||||
156
src/components/agent/pet/agentPetActions.ts
Normal file
156
src/components/agent/pet/agentPetActions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { AgentPetActionDefinition, AgentPetActionName } from './types'
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTION_MIN_DELAY = 8000
|
||||
export const AGENT_PET_RANDOM_ACTION_MAX_DELAY = 18000
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTIONS = [
|
||||
'wave',
|
||||
'sit',
|
||||
'eye-roll',
|
||||
'faint',
|
||||
'disassemble',
|
||||
'happy-jump',
|
||||
'sleep',
|
||||
'stretch',
|
||||
'peek',
|
||||
'scan',
|
||||
'charge',
|
||||
'spin-cheer',
|
||||
'shy',
|
||||
'confused',
|
||||
] as const
|
||||
|
||||
export const AGENT_PET_ACTIONS: Record<AgentPetActionName, AgentPetActionDefinition> = {
|
||||
wave: {
|
||||
name: 'wave',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-wave',
|
||||
duration: 2450,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sit: {
|
||||
name: 'sit',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-sit',
|
||||
duration: 4200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'eye-roll': {
|
||||
name: 'eye-roll',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-eye-roll',
|
||||
duration: 1900,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
faint: {
|
||||
name: 'faint',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-faint',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
disassemble: {
|
||||
name: 'disassemble',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-disassemble',
|
||||
duration: 6200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'happy-jump': {
|
||||
name: 'happy-jump',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-happy-jump',
|
||||
duration: 5200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sleep: {
|
||||
name: 'sleep',
|
||||
intent: 'sleeping',
|
||||
clip: 'agent-fab-action-sleep',
|
||||
duration: 5800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
stretch: {
|
||||
name: 'stretch',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-stretch',
|
||||
duration: 4400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
peek: {
|
||||
name: 'peek',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-peek',
|
||||
duration: 2800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
scan: {
|
||||
name: 'scan',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-scan',
|
||||
duration: 3200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
charge: {
|
||||
name: 'charge',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-charge',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'spin-cheer': {
|
||||
name: 'spin-cheer',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-spin-cheer',
|
||||
duration: 3600,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
shy: {
|
||||
name: 'shy',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-shy',
|
||||
duration: 3000,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
confused: {
|
||||
name: 'confused',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-confused',
|
||||
duration: 3400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** 获取指定宠物动作的播放时长。 */
|
||||
export function getAgentPetActionDuration(action: AgentPetActionName) {
|
||||
return AGENT_PET_ACTIONS[action].duration
|
||||
}
|
||||
|
||||
/** 生成下一次空闲趣味动作的随机等待时间。 */
|
||||
export function getAgentPetRandomActionDelay() {
|
||||
return (
|
||||
AGENT_PET_RANDOM_ACTION_MIN_DELAY +
|
||||
Math.round(Math.random() * (AGENT_PET_RANDOM_ACTION_MAX_DELAY - AGENT_PET_RANDOM_ACTION_MIN_DELAY))
|
||||
)
|
||||
}
|
||||
|
||||
/** 从动作池中选择一个不同于上一次的趣味动作。 */
|
||||
export function pickAgentPetRandomAction(lastAction: AgentPetActionName | null): AgentPetActionName {
|
||||
const candidates = AGENT_PET_RANDOM_ACTIONS.filter(action => action !== lastAction)
|
||||
|
||||
return candidates[Math.floor(Math.random() * candidates.length)] || AGENT_PET_RANDOM_ACTIONS[0]
|
||||
}
|
||||
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentPetActionName, AgentPetIntent } from '../types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="agent-assistant-fab__bot"
|
||||
:data-agent-pet-action="props.action || undefined"
|
||||
:data-agent-pet-intent="props.intent"
|
||||
:data-agent-pet-thinking="props.thinking ? 'true' : undefined"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="agent-assistant-fab__antenna" />
|
||||
<span class="agent-assistant-fab__head">
|
||||
<span class="agent-assistant-fab__face">
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--left" />
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--right" />
|
||||
<span class="agent-assistant-fab__smile" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-assistant-fab__body">
|
||||
<span class="agent-assistant-fab__core" />
|
||||
</span>
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--left" />
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--right" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--left" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--right" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style src="./cssRobot.scss" lang="scss"></style>
|
||||
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-charge-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-charge-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-charge-face 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-charge-core 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-charge-arm 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18%,
|
||||
42%,
|
||||
66% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translateY(-0.12rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(-20deg)
|
||||
scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
20%,
|
||||
84% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.9rem rgba(166, 118, 255, 56%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-arm {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.12rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-confused-antenna 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-confused-head 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-confused-eye 0.85s ease-in-out 4;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-confused-arm-left 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-confused-arm-right 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(58deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-28deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(42deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-10deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(10deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(0.22rem, -0.16rem);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(-0.22rem, 0.08rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(-0.12rem, -0.12rem) rotate(88deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-42deg);
|
||||
}
|
||||
}
|
||||
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-disassemble-bot 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-disassemble-antenna 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-disassemble-head 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-disassemble-body 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-disassemble-arm-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-disassemble-arm-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-disassemble-leg-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-disassemble-leg-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-bot {
|
||||
/* 34%-70% 让散落状态停留片刻,再用 84% 的轻微过冲表现回装吸附感。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
12% {
|
||||
transform: translateY(-0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(-5deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
72% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) rotate(0deg);
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(-0.04rem) scale(var(--agent-assistant-bot-scale)) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(0.02rem, -0.14rem) rotate(44deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(0.96rem, 3.42rem) rotate(268deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.24rem)) rotate(-8deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(-1.18rem, 2.72rem) rotate(-46deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.1rem) rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) rotate(4deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.26rem, 1.56rem) rotate(18deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.16rem, -0.08rem) rotate(78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-1.72rem, 1.84rem) rotate(248deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.04rem) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(1.58rem, 1.72rem) rotate(-238deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.08rem, -0.04rem) rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-0.12rem, 0.18rem) rotate(34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-0.98rem, 0.96rem) rotate(112deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.07rem, -0.08rem) rotate(-8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(0.12rem, 0.18rem) rotate(-34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.92rem, 1.04rem) rotate(-118deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.07rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-eye-roll-head 1.9s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-eye-roll 0.95s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-6deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(5deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
22% {
|
||||
transform: translate(0.22rem, -0.2rem);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(0, -0.3rem);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.22rem, -0.2rem);
|
||||
}
|
||||
}
|
||||
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
@@ -0,0 +1,138 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-faint-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-faint-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-faint-head 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-faint-body 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-faint-arm-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-faint-arm-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-faint-leg-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-faint-leg-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(12deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.58rem, 1.08rem) scale(var(--agent-assistant-bot-scale)) rotate(98deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.08rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(9deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.12rem, 0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.12rem, 0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.24rem, -0.02rem) rotate(82deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.24rem, -0.02rem) rotate(-82deg);
|
||||
}
|
||||
}
|
||||
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
@@ -0,0 +1,309 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-happy-jump-bot 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-happy-jump-antenna 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-happy-jump-head 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-happy-jump-eye 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-happy-jump-smile 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-happy-jump-body 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-happy-jump-leg-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-happy-jump-leg-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-bot {
|
||||
/* 三段蓄力、起跳、落地,让开心连跳比单纯上下移动更像真人动作。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(34deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-18deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(38deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(7deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(-5deg);
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1, 1);
|
||||
}
|
||||
|
||||
8%,
|
||||
36%,
|
||||
64%,
|
||||
92% {
|
||||
border-block-end-width: 0.18rem;
|
||||
transform: translate(0, -0.06rem) scale(1.08, 0.72);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.16, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
6%,
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateX(-50%) translateY(0.02rem) scale(0.92, 0.82);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateX(-50%) translateY(-0.04rem) scale(1.22, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
|
||||
rotate(-2deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
|
||||
rotate(4deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
|
||||
rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, 0.06rem) rotate(54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.24rem, -0.38rem) rotate(152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.1rem, -0.02rem) rotate(96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, 0.06rem) rotate(-54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.24rem, -0.38rem) rotate(-152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.1rem, -0.02rem) rotate(-96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, -0.22rem) rotate(82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.18rem, 0.08rem) rotate(-32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.12rem, -0.08rem) rotate(44deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, -0.22rem) rotate(-82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.18rem, 0.08rem) rotate(32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.12rem, -0.08rem) rotate(-44deg);
|
||||
}
|
||||
}
|
||||
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-peek-bot 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-peek-antenna 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-peek-head 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-peek-eye 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-peek-arm-left 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-peek-arm-right 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translateX(-0.48rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateX(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 11deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.12rem, -0.06rem) rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.18rem), calc(var(--agent-assistant-head-y) - 0.04rem))
|
||||
rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.18rem, -0.02rem) scale(1.12, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.24rem, -0.12rem) rotate(116deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(0.04rem, 0.06rem) rotate(-34deg);
|
||||
}
|
||||
}
|
||||
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-scan-head 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-scan-face 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-scan-eye 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-scan-core 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-8deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(8deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
18%,
|
||||
78% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.72rem rgba(114, 255, 240, 48%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
46% {
|
||||
transform: translate(0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(0, -0.18rem) scale(1.18, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1.24);
|
||||
}
|
||||
}
|
||||
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__antenna,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__head,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__face,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__body,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__core,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__arm,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__leg,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__eye,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__smile {
|
||||
transition: none;
|
||||
}
|
||||
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-shy-bot 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-shy-head 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-shy-eye 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-left 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-right 3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translateX(0.16rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 7deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) + 0.08rem), calc(var(--agent-assistant-head-y) + 0.14rem))
|
||||
rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-eye {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
opacity: 0.78;
|
||||
transform: translate(0, 0.08rem) scale(0.86, 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(0.02rem, -0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(-0.02rem, -0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sit-bot 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sit-head 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sit-body 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sit-arm-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sit-arm-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--left {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--right {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.22rem)) scaleY(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.08rem, 0.22rem) rotate(76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.08rem, 0.22rem) rotate(-76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.18rem, -0.18rem) rotate(94deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.18rem, -0.18rem) rotate(-94deg);
|
||||
}
|
||||
}
|
||||
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
@@ -0,0 +1,144 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sleep-bot 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-sleep-antenna 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sleep-head 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-sleep-eye 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sleep-body 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sleep-arm-left 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sleep-arm-right 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__leg {
|
||||
animation: agent-fab-action-sleep-leg 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.42rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
46%,
|
||||
64% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 10deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.62;
|
||||
transform: translate(0.1rem, 0.16rem) rotate(78deg) scale(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.12rem), calc(var(--agent-assistant-head-y) + 0.22rem))
|
||||
rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
18%,
|
||||
86% {
|
||||
border-block-end-width: 0.1rem;
|
||||
opacity: 0.62;
|
||||
transform: translate(0, 0.1rem) scale(1.18, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.26rem)) scaleY(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(0.12rem, 0.2rem) rotate(92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(-0.12rem, 0.2rem) rotate(-92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-leg {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(-0.16rem) scale(0.76);
|
||||
}
|
||||
}
|
||||
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-spin-cheer-bot 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-spin-cheer-antenna 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-spin-cheer-head 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-spin-cheer-eye 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-spin-cheer-smile 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-left 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-right 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 7deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateY(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 184deg));
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translateY(-0.14rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 356deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.18rem)) rotate(-28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.12rem)) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.18, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
16%,
|
||||
84% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-0.03rem) scale(1.24, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(-0.28rem, -0.44rem) rotate(148deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(0.28rem, -0.44rem) rotate(-148deg);
|
||||
}
|
||||
}
|
||||
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-stretch-bot 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-stretch-antenna 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-stretch-head 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-stretch-body 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-stretch-leg-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-stretch-leg-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) scaleY(0.92)
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translateY(-0.34rem) scale(var(--agent-assistant-bot-scale)) scaleY(1.06)
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-24deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.28rem)) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.12rem)) scaleY(0.88);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.08rem)) scaleY(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.24rem, -0.7rem) rotate(168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.24rem, -0.7rem) rotate(-168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.18rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.18rem, 0.02rem) rotate(18deg);
|
||||
}
|
||||
}
|
||||
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-wave-bot 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-wave-head 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-wave-arm-left 2.3s ease-in-out both;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-wave-arm-right 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 4deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
66% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(-0.08rem, -0.06rem) rotate(64deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
44% {
|
||||
transform: translate(-0.18rem, -0.2rem) rotate(92deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.1rem, -0.08rem) rotate(64deg);
|
||||
}
|
||||
|
||||
88% {
|
||||
transform: translate(-0.02rem, -0.02rem) rotate(28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
70% {
|
||||
transform: rotate(-28deg) translateY(0.06rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@use './shared';
|
||||
@use './wave';
|
||||
@use './sit';
|
||||
@use './eye-roll';
|
||||
@use './faint';
|
||||
@use './disassemble';
|
||||
@use './happy-jump';
|
||||
@use './sleep';
|
||||
@use './stretch';
|
||||
@use './peek';
|
||||
@use './scan';
|
||||
@use './charge';
|
||||
@use './spin-cheer';
|
||||
@use './shy';
|
||||
@use './confused';
|
||||
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@use './cssRobotBase';
|
||||
@use './actions/cssRobotActions';
|
||||
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab__bot,
|
||||
.agent-assistant-fab__bot span {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 4.7rem;
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow));
|
||||
inline-size: 3.85rem;
|
||||
inset-block-end: 0.1rem;
|
||||
inset-inline-end: 1.42rem;
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
transform-origin: 50% 72%;
|
||||
transition:
|
||||
inset-inline-end 0.24s ease,
|
||||
filter 0.18s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
inset-block-start: 0.72rem;
|
||||
inset-inline-start: 2.62rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
transform-origin: bottom center;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna::after {
|
||||
position: absolute;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 999px;
|
||||
background: var(--agent-assistant-robot-shell-start);
|
||||
block-size: 0.38rem;
|
||||
content: '';
|
||||
inline-size: 0.38rem;
|
||||
inset-block-start: -0.34rem;
|
||||
inset-inline-start: -0.13rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__head {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 100%
|
||||
);
|
||||
block-size: 2.05rem;
|
||||
box-shadow:
|
||||
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
|
||||
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 2.82rem;
|
||||
inset-block-start: 1.42rem;
|
||||
inset-inline-start: 0.88rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
|
||||
transform-origin: 50% 85%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__face {
|
||||
position: absolute;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--agent-assistant-robot-outline-soft);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--agent-assistant-robot-face-start) 0%,
|
||||
var(--agent-assistant-robot-face-end) 100%
|
||||
);
|
||||
block-size: 1.28rem;
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
inline-size: 2.1rem;
|
||||
inset-block-start: 0.33rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 0.36rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
|
||||
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--left {
|
||||
inset-inline-start: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--right {
|
||||
inset-inline-end: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__smile {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
block-size: 0.32rem;
|
||||
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.7rem;
|
||||
inset-block-start: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__body {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 82%
|
||||
);
|
||||
block-size: 1.34rem;
|
||||
box-shadow:
|
||||
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
|
||||
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 1.88rem;
|
||||
inset-block-start: 3.24rem;
|
||||
inset-inline-start: 1.32rem;
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
transform-origin: 50% 18%;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
box-shadow: none;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core::before {
|
||||
position: absolute;
|
||||
background: var(--agent-assistant-robot-play);
|
||||
block-size: 0.46rem;
|
||||
clip-path: polygon(18% 8%, 18% 92%, 90% 50%);
|
||||
content: '';
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-42%, -50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm,
|
||||
.agent-assistant-fab__leg {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-outline-soft) 100%
|
||||
);
|
||||
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm {
|
||||
border-radius: 999px;
|
||||
block-size: 1rem;
|
||||
inline-size: 0.46rem;
|
||||
inset-block-start: 3.3rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg {
|
||||
border-radius: 0.35rem;
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 4.36rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.agent-assistant-fab.is-bubble-visible .agent-assistant-fab__bubble:hover {
|
||||
box-shadow: var(--app-surface-hover-shadow);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble:hover .agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:hover .agent-assistant-fab__bot {
|
||||
filter: drop-shadow(0 0.7rem 0.7rem var(--agent-assistant-robot-shadow-strong));
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-pressed .agent-assistant-fab__bot {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-pressed-scale))
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__face {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.65rem rgba(114, 255, 240, 50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__core {
|
||||
animation: agent-fab-core-pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.42rem;
|
||||
transform: translateY(-0.2rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
|
||||
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__leg {
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--right {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left {
|
||||
z-index: 5;
|
||||
block-size: 0.95rem;
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 3.62rem;
|
||||
inset-inline-start: 1.86rem;
|
||||
opacity: 1;
|
||||
transform: rotate(78deg) scale(0.9);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__antenna {
|
||||
animation: none;
|
||||
opacity: 0.75;
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-blink {
|
||||
0%,
|
||||
4%,
|
||||
8%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
scale: 1 1;
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 0.45;
|
||||
scale: 1 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.agent-assistant-fab {
|
||||
block-size: 6.65rem;
|
||||
inline-size: min(12.4rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked {
|
||||
inline-size: 3.45rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles {
|
||||
inline-size: min(16.5rem, calc(100vw - 5.6rem));
|
||||
max-block-size: min(30rem, calc(100vh - 9.2rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-stack {
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble {
|
||||
padding-block: 0.56rem;
|
||||
padding-inline: 0.72rem 1.62rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble > span {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger {
|
||||
block-size: 4.8rem;
|
||||
inline-size: 5rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
inset-inline-end: 1.02rem;
|
||||
transform-origin: 70% 78%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab {
|
||||
--agent-assistant-bot-scale: 0.82;
|
||||
--agent-assistant-bot-pressed-scale: 0.78;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.72rem;
|
||||
transform: translateY(-0.16rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-assistant-fab,
|
||||
.agent-assistant-fab * {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
41
src/components/agent/pet/types.ts
Normal file
41
src/components/agent/pet/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type AgentPetActionName =
|
||||
| 'wave'
|
||||
| 'sit'
|
||||
| 'eye-roll'
|
||||
| 'faint'
|
||||
| 'disassemble'
|
||||
| 'happy-jump'
|
||||
| 'sleep'
|
||||
| 'stretch'
|
||||
| 'peek'
|
||||
| 'scan'
|
||||
| 'charge'
|
||||
| 'spin-cheer'
|
||||
| 'shy'
|
||||
| 'confused'
|
||||
|
||||
export type AgentPetIntent =
|
||||
| 'idle'
|
||||
| 'thinking'
|
||||
| 'speaking'
|
||||
| 'notify'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'dragging'
|
||||
| 'docked'
|
||||
| 'sleeping'
|
||||
| 'reaction'
|
||||
|
||||
export type AgentPetRendererKind = 'css-robot'
|
||||
|
||||
export interface AgentPetActionDefinition {
|
||||
name: AgentPetActionName
|
||||
intent: AgentPetIntent
|
||||
clip: string
|
||||
duration: number
|
||||
priority: number
|
||||
interruptible: boolean
|
||||
cooldown?: number
|
||||
next?: AgentPetActionName
|
||||
}
|
||||
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import {
|
||||
getAgentPetActionDuration,
|
||||
getAgentPetRandomActionDelay,
|
||||
pickAgentPetRandomAction,
|
||||
} from './agentPetActions'
|
||||
import type { AgentPetActionName } from './types'
|
||||
|
||||
interface AgentPetMachineOptions {
|
||||
active: MaybeRefOrGetter<boolean>
|
||||
thinking: MaybeRefOrGetter<boolean>
|
||||
docked: Ref<boolean>
|
||||
dragging: Ref<boolean>
|
||||
pressed: Ref<boolean>
|
||||
shouldAutoDock: () => boolean
|
||||
scheduleAutoDock: () => void
|
||||
}
|
||||
|
||||
/** 管理桌面宠物的空闲动作队列,避免入口组件直接维护动画计时器。 */
|
||||
export function useAgentPetMachine(options: AgentPetMachineOptions) {
|
||||
const currentAction = ref<AgentPetActionName | null>(null)
|
||||
|
||||
let lastAction: AgentPetActionName | null = null
|
||||
let randomActionTimer: number | null = null
|
||||
let actionEndTimer: number | null = null
|
||||
|
||||
/** 判断当前交互状态是否适合播放空闲趣味动作。 */
|
||||
function canRunRandomAction() {
|
||||
return (
|
||||
toValue(options.active) &&
|
||||
!options.docked.value &&
|
||||
!options.dragging.value &&
|
||||
!options.pressed.value &&
|
||||
!toValue(options.thinking)
|
||||
)
|
||||
}
|
||||
|
||||
/** 清理等待中的随机动作计时器。 */
|
||||
function clearRandomActionTimer() {
|
||||
if (randomActionTimer === null) return
|
||||
|
||||
window.clearTimeout(randomActionTimer)
|
||||
randomActionTimer = null
|
||||
}
|
||||
|
||||
/** 清理正在播放动作的结束计时器。 */
|
||||
function clearActionEndTimer() {
|
||||
if (actionEndTimer === null) return
|
||||
|
||||
window.clearTimeout(actionEndTimer)
|
||||
actionEndTimer = null
|
||||
}
|
||||
|
||||
/** 停止当前动作并清理全部宠物动作计时器。 */
|
||||
function clearAction() {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
}
|
||||
|
||||
/** 完成当前动作后恢复空闲态,并按贴边规则决定是否自动收起。 */
|
||||
function finishRandomAction() {
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
|
||||
if (!options.docked.value && options.shouldAutoDock()) {
|
||||
options.scheduleAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleRandomAction()
|
||||
}
|
||||
|
||||
/** 立即播放指定动作,供通知、交互或外部 renderer 编排主动触发。 */
|
||||
function playAction(action: AgentPetActionName) {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = action
|
||||
actionEndTimer = window.setTimeout(finishRandomAction, getAgentPetActionDuration(action))
|
||||
}
|
||||
|
||||
/** 播放一个随机趣味动作,由 renderer 根据动作名呈现具体动画。 */
|
||||
function runRandomAction() {
|
||||
if (!canRunRandomAction()) return
|
||||
|
||||
const action = pickAgentPetRandomAction(lastAction)
|
||||
|
||||
lastAction = action
|
||||
playAction(action)
|
||||
}
|
||||
|
||||
/** 安排下一次随机动作,只在宠物可见且空闲时生效。 */
|
||||
function scheduleRandomAction() {
|
||||
clearRandomActionTimer()
|
||||
if (!canRunRandomAction() || currentAction.value || actionEndTimer !== null) return
|
||||
|
||||
randomActionTimer = window.setTimeout(() => {
|
||||
randomActionTimer = null
|
||||
runRandomAction()
|
||||
}, getAgentPetRandomActionDelay())
|
||||
}
|
||||
|
||||
/** 根据入口交互状态同步随机动作队列。 */
|
||||
function syncSchedule() {
|
||||
if (canRunRandomAction()) {
|
||||
if (!currentAction.value && randomActionTimer === null && actionEndTimer === null) scheduleRandomAction()
|
||||
return
|
||||
}
|
||||
|
||||
clearAction()
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => toValue(options.active), () => toValue(options.thinking), options.docked, options.dragging, options.pressed],
|
||||
syncSchedule,
|
||||
)
|
||||
|
||||
onScopeDispose(clearAction)
|
||||
|
||||
return {
|
||||
clearAction,
|
||||
currentAction,
|
||||
playAction,
|
||||
scheduleRandomAction,
|
||||
syncSchedule,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ const props = defineProps({
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -46,22 +52,30 @@ 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
|
||||
:style="cardStyle"
|
||||
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">
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -86,7 +100,43 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.backdrop-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-placeholder,
|
||||
.backdrop-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</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%;
|
||||
}
|
||||
|
||||
@@ -25,18 +25,51 @@ const imageLoaded = ref(false)
|
||||
// 图片是否加载错误
|
||||
const imageError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 媒体库内条目数量,兼容不同后端字段。
|
||||
const libraryItemCount = computed(() => props.media?.item_count)
|
||||
|
||||
// 是否展示右上角数量折角。
|
||||
const showCountCorner = computed(() => typeof libraryItemCount.value === 'number' && libraryItemCount.value >= 0)
|
||||
|
||||
// 右上角数量文案。
|
||||
const countLabel = computed(() => {
|
||||
if (!showCountCorner.value) return ''
|
||||
return formatLibraryCount(libraryItemCount.value || 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化右上角媒体库数量。
|
||||
* @param count 媒体库内媒体数量
|
||||
*/
|
||||
function formatLibraryCount(count: number) {
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}万`
|
||||
return `${count}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载错误
|
||||
/**
|
||||
* 标记封面加载失败并切换默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
/**
|
||||
* 获取媒体服务器默认封面图。
|
||||
*/
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
@@ -47,14 +80,20 @@ function getDefaultImage() {
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
/**
|
||||
* 跳转到媒体服务器媒体库页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
/**
|
||||
* 生成图片代理路径。
|
||||
* @param url 原始图片地址
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
@@ -64,7 +103,11 @@ function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
/**
|
||||
* 根据多张图片生成媒体库封面。
|
||||
* @param imageList 媒体库封面候选图列表
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = [...imageList]
|
||||
@@ -96,7 +139,11 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 设置背景色为透明
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制图片
|
||||
/**
|
||||
* 绘制单张海报及其倒影。
|
||||
* @param imgSrc 海报图片地址
|
||||
* @param index 海报位置
|
||||
*/
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
if (!canvas) return
|
||||
|
||||
@@ -156,35 +203,162 @@ 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
|
||||
:style="cardStyle"
|
||||
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">
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.library-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0%) 0%, rgba(2, 6, 23, 2%) 56%, rgba(2, 6, 23, 28%) 100%),
|
||||
linear-gradient(90deg, rgba(2, 6, 23, 8%) 0%, rgba(2, 6, 23, 0%) 42%, rgba(2, 6, 23, 10%) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-label {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(6, 10, 17, 58%);
|
||||
block-size: 1.75rem;
|
||||
border-block-start: 1px solid rgba(255, 255, 255, 12%);
|
||||
border-end-end-radius: var(--app-surface-radius);
|
||||
border-end-start-radius: var(--app-surface-radius);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1;
|
||||
padding-inline: 0.75rem;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 48%);
|
||||
}
|
||||
|
||||
.library-card-label span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-card-count-corner {
|
||||
position: absolute;
|
||||
block-size: 3.35rem;
|
||||
color: #fff;
|
||||
inline-size: 3.35rem;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-count-corner::before {
|
||||
position: absolute;
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner::after {
|
||||
position: absolute;
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner span {
|
||||
position: absolute;
|
||||
inset-block-start: 0.63rem;
|
||||
inset-inline-end: 0.18rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 44%);
|
||||
transform: rotate(45deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.library-card-placeholder,
|
||||
.library-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.library-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeTouchMediaCardId = ref<number | null>(null)
|
||||
let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, Site, Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
useMediaSubscribe,
|
||||
type SeasonSubscribeModes,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -49,9 +56,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -64,8 +68,15 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 当前媒体已订阅的季号
|
||||
const subscribedSeasons = ref<number[]>([])
|
||||
|
||||
// 当前媒体已订阅季的订阅模式
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
|
||||
const subscribedSeasonsLoaded = ref(false)
|
||||
|
||||
const subscribedSeasonsLoading = ref(false)
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -89,32 +100,12 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
const mediaCardId = ++mediaCardIdSeed
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
|
||||
const isTouchLikePointer = ref(
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
|
||||
)
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
@@ -155,10 +146,7 @@ async function querySelectedSites() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
@@ -177,6 +165,15 @@ function getExistsStatusKey() {
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -184,101 +181,6 @@ function getChipColor(type: string) {
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
type: props.media?.type,
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
@@ -291,6 +193,36 @@ async function handleCheckSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function querySubscribedSeasons() {
|
||||
if (
|
||||
props.media?.type !== '电视剧' ||
|
||||
!isSubscribed.value ||
|
||||
subscribedSeasonsLoaded.value ||
|
||||
subscribedSeasonsLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedSeasonsLoading.value = true
|
||||
try {
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
|
||||
)
|
||||
|
||||
subscribedSeasons.value = mediaSubscribes.map(item => item.season as number).sort((a, b) => a - b)
|
||||
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
|
||||
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
|
||||
return modes
|
||||
}, {})
|
||||
subscribedSeasonsLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
subscribedSeasonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
@@ -317,63 +249,20 @@ async function handleCheckExists() {
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
return subscribeActions.checkSubscribe(season)
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
function handleSubscribe() {
|
||||
if (isSubscribed.value) removeSubscribe()
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
async function handleSubscribe() {
|
||||
await querySubscribedSeasons()
|
||||
subscribeActions.handleSubscribe()
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
@@ -397,6 +286,42 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前卡片是否进入可操作详情态,桌面使用 hover,触摸端使用显式点击态。
|
||||
function isMediaCardActive(isHovering: boolean | null | undefined) {
|
||||
return activeTouchMediaCardId.value === mediaCardId || (!isTouchLikePointer.value && Boolean(isHovering))
|
||||
}
|
||||
|
||||
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
|
||||
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
|
||||
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
|
||||
}
|
||||
|
||||
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
|
||||
function resetMediaCardDetailState() {
|
||||
if (activeTouchMediaCardId.value === mediaCardId) {
|
||||
activeTouchMediaCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
|
||||
function handleMediaCardClick(isHovering: boolean | null | undefined) {
|
||||
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
|
||||
activeTouchMediaCardId.value = mediaCardId
|
||||
querySubscribedSeasons()
|
||||
return
|
||||
}
|
||||
|
||||
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isTouchLikePointer.value || activeTouchMediaCardId.value !== mediaCardId) return
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (mediaCardRef.value?.contains(event.target)) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
@@ -469,22 +394,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return mediaTypeDict[type]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => props.media,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => isExists.value,
|
||||
subscribedSeasons,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => props.media?.season ?? null,
|
||||
getSubscribeStatusKey,
|
||||
})
|
||||
|
||||
watch(isSubscribed, subscribed => {
|
||||
subscribedSeasonsLoaded.value = false
|
||||
if (!subscribed) {
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
if (isTouchLikePointer.value) {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
@@ -493,17 +450,18 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div ref="mediaCardRef">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area" @mouseenter="querySubscribedSeasons">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500 media-card"
|
||||
:ripple="false"
|
||||
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': isMediaCardActive(hover.isHovering),
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
@@ -522,7 +480,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 详情 -->
|
||||
<VCardText
|
||||
v-show="hover.isHovering || imageLoadError || searchMenuShow"
|
||||
v-show="isMediaCardDetailVisible(hover.isHovering)"
|
||||
class="w-full h-full flex flex-col flex-wrap justify-end align-left text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
style="background: linear-gradient(rgba(45, 55, 72, 40%) 0%, rgba(45, 55, 72, 90%) 100%)"
|
||||
>
|
||||
@@ -535,17 +493,11 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
<div v-if="props.media?.collection_id" class="mb-3" @click.stop=""></div>
|
||||
<div v-else class="flex align-center justify-between">
|
||||
<IconBtn
|
||||
v-if="canSearch"
|
||||
icon="mdi-magnify"
|
||||
color="white"
|
||||
size="small"
|
||||
@click.stop="clickSearch"
|
||||
/>
|
||||
<IconBtn v-if="canSearch" icon="mdi-magnify" color="white" size="small" @click.stop="clickSearch" />
|
||||
<VSpacer />
|
||||
<IconBtn
|
||||
v-if="canSubscribe"
|
||||
icon="mdi-heart"
|
||||
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
|
||||
:color="isSubscribed ? 'error' : 'white'"
|
||||
size="small"
|
||||
@click.stop="handleSubscribe"
|
||||
@@ -563,10 +515,10 @@ onBeforeUnmount(() => {
|
||||
{{ getMediaTypeText(props.media?.type) }}
|
||||
</VChip>
|
||||
<!-- 本地存在标识 -->
|
||||
<ExistIcon v-if="isExists && !hover.isHovering" />
|
||||
<ExistIcon v-if="isExists && !isMediaCardActive(hover.isHovering)" />
|
||||
<!-- 评分角标 -->
|
||||
<VChip
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !hover.isHovering)"
|
||||
v-if="isImageLoaded && props.media?.vote_average && !(isExists && !isMediaCardActive(hover.isHovering))"
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor('rating')"
|
||||
@@ -581,7 +533,7 @@ onBeforeUnmount(() => {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!hover.isHovering && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
>
|
||||
<VImg cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
@@ -591,6 +543,10 @@ onBeforeUnmount(() => {
|
||||
</VHover>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
@@ -600,4 +556,20 @@ onBeforeUnmount(() => {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
gap: 0.25rem;
|
||||
line-height: 1rem;
|
||||
min-block-size: 1.25rem;
|
||||
}
|
||||
|
||||
.media-card-subscribe-summary span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</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,
|
||||
|
||||
365
src/components/cards/PlayingBackdropCard.vue
Normal file
365
src/components/cards/PlayingBackdropCard.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '16 / 10',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 规范化后的播放进度,避免后端异常值撑破进度条。
|
||||
const progressValue = computed(() => {
|
||||
const percent = Number(props.media?.percent ?? 0)
|
||||
if (Number.isNaN(percent)) return 0
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
// 是否存在可续播进度。
|
||||
const hasProgress = computed(() => progressValue.value > 0)
|
||||
|
||||
// 右上角进度标签。
|
||||
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
|
||||
|
||||
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
|
||||
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
|
||||
|
||||
// 根据播放进度生成简短提示。
|
||||
const progressHint = computed(() => {
|
||||
if (!hasProgress.value) return '未开始'
|
||||
if (progressValue.value >= 90) return '快看完'
|
||||
if (progressValue.value <= 10) return '刚开始'
|
||||
return '继续上次进度'
|
||||
})
|
||||
|
||||
// 计算图片代理地址。
|
||||
const imageUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const useCookies = props.media?.use_cookies
|
||||
if (useCookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载失败并切换到默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到媒体服务器播放页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="playing-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="playing-card app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
|
||||
'playing-card--loaded': imageLoaded,
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goPlay"
|
||||
@keyup.enter="goPlay"
|
||||
@keyup.space="goPlay"
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="playing-card__placeholder">
|
||||
<VSkeletonLoader class="playing-card__skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<div class="playing-card__scrim" />
|
||||
<div class="playing-card__bottom-scrim" />
|
||||
|
||||
<div class="playing-card__percent">
|
||||
{{ progressLabel }}
|
||||
</div>
|
||||
|
||||
<div class="playing-card__play" aria-hidden="true">
|
||||
<VIcon icon="mdi-play" size="26" />
|
||||
</div>
|
||||
|
||||
<div class="playing-card__content">
|
||||
<div class="playing-card__title">
|
||||
{{ props.media?.title }}
|
||||
</div>
|
||||
<div class="playing-card__subtitle">
|
||||
{{ subtitleText }}
|
||||
</div>
|
||||
<div class="playing-card__meta">
|
||||
<span>{{ progressHint }}</span>
|
||||
<span v-if="props.media?.server_type" class="playing-card__server">
|
||||
{{ props.media.server_type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playing-card__progress" aria-hidden="true">
|
||||
<div class="playing-card__progress-track">
|
||||
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.playing-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.16);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.playing-card__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__placeholder,
|
||||
.playing-card__skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__image :deep(.v-img__img) {
|
||||
filter: saturate(0.96) brightness(0.92);
|
||||
opacity: 0;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
|
||||
.playing-card__image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.playing-card__scrim,
|
||||
.playing-card__bottom-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.playing-card__scrim {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__percent,
|
||||
.playing-card__play,
|
||||
.playing-card__content,
|
||||
.playing-card__progress {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playing-card__percent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 18%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(255, 255, 255, 16%);
|
||||
block-size: 26px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
inline-size: 52px;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-end: 12px;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 92%);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
|
||||
color: rgb(11, 15, 20);
|
||||
inline-size: 40px;
|
||||
inset-block-end: 46px;
|
||||
inset-inline-end: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__play {
|
||||
background: #fff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.playing-card__content {
|
||||
inset-block-end: 18px;
|
||||
inset-inline: 16px 66px;
|
||||
min-inline-size: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
overflow: hidden;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__subtitle {
|
||||
overflow: hidden;
|
||||
margin-block-start: 4px;
|
||||
color: rgba(255, 255, 255, 86%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__meta {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: 7px;
|
||||
color: rgba(255, 255, 255, 70%);
|
||||
font-size: 0.72rem;
|
||||
gap: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__server {
|
||||
overflow: hidden;
|
||||
max-inline-size: 6rem;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playing-card__progress {
|
||||
inset-block-end: 6px;
|
||||
inset-inline: 16px;
|
||||
}
|
||||
|
||||
.playing-card__progress-track {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 24%);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.playing-card__progress-bar {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
|
||||
block-size: 100%;
|
||||
min-inline-size: 8px;
|
||||
transition: inline-size 0.25s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.playing-card__content {
|
||||
inset-inline-end: 58px;
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playing-card__image :deep(.v-img__img),
|
||||
.playing-card__play,
|
||||
.playing-card__progress-bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img),
|
||||
.playing-card--hovering .playing-card__play {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -230,16 +230,17 @@ onUnmounted(() => {
|
||||
<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%)`"
|
||||
@@ -325,13 +326,18 @@ onUnmounted(() => {
|
||||
</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;
|
||||
|
||||
@@ -567,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%)`"
|
||||
@@ -669,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>
|
||||
|
||||
@@ -677,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 {
|
||||
|
||||
@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '2 / 3',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -47,20 +53,21 @@ 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
|
||||
:style="cardStyle"
|
||||
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"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -77,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
@@ -93,7 +100,41 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.poster-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.poster-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-responsive__sizer) {
|
||||
padding-bottom: 150%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</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;
|
||||
|
||||
@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
||||
const compactStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
if (subscribeState.value === 'P') {
|
||||
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
@@ -290,22 +301,6 @@ const dropdownItems = computed(() => [
|
||||
click: searchSubscribe,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.details'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.files'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
|
||||
value: 5,
|
||||
@@ -334,6 +329,22 @@ const dropdownItems = computed(() => [
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: t('subscribe.mediaDetail'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('subscribe.fileStatistics'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.unsubscribe'),
|
||||
value: 8,
|
||||
@@ -404,63 +415,162 @@ 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,
|
||||
}"
|
||||
>
|
||||
<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',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'subscribe-card-shell--selected': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="subscribe-card 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="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image v-if="display.smAndUp.value">
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</VImg>
|
||||
|
||||
<div
|
||||
v-if="props.media?.username || lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta"
|
||||
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
|
||||
>
|
||||
<div
|
||||
v-if="props.media?.username"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
|
||||
:title="props.media?.username"
|
||||
>
|
||||
<VIcon icon="mdi-account" size="14" />
|
||||
<span>{{ props.media?.username }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
|
||||
>
|
||||
<VIcon icon="mdi-download" size="14" />
|
||||
<span>{{ lastUpdateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-body">
|
||||
<div class="subscribe-card-mobile-title">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-footer">
|
||||
<div class="subscribe-card-mobile-meta">
|
||||
<div
|
||||
class="subscribe-card-mobile-state"
|
||||
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
||||
:title="compactStateDisplay.label"
|
||||
:aria-label="compactStateDisplay.label"
|
||||
>
|
||||
<VIcon :icon="compactStateDisplay.icon" size="18" />
|
||||
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
|
||||
{{ subscribeProgressText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn
|
||||
v-if="!props.sortable"
|
||||
class="subscribe-card-mobile-menu"
|
||||
size="small"
|
||||
@click.stop
|
||||
>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
|
||||
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
|
||||
<VProgressLinear
|
||||
:model-value="getPercentage()"
|
||||
:bg-color="compactStateDisplay.color"
|
||||
:color="compactStateDisplay.color"
|
||||
bg-opacity="0.18"
|
||||
height="4"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
@@ -567,14 +677,185 @@ function handleCardClick() {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
|
||||
*/
|
||||
.subscribe-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.subscribe-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 1;
|
||||
flex-shrink: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media .v-img {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
isolation: isolate;
|
||||
line-height: 1.2;
|
||||
padding-block: 0.0625rem;
|
||||
padding-inline: 0.125rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
content: '';
|
||||
filter: blur(3px);
|
||||
inset: -0.25rem -0.55rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 1rem);
|
||||
inset-block-start: 0.5rem;
|
||||
inset-inline-start: 0.5rem;
|
||||
transition: inset-block-start 0.2s ease;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 4.25rem);
|
||||
inset-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user span {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__updated {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
inset-block-end: 0.5rem;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-meta {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress-text {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-menu {
|
||||
block-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
min-inline-size: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress {
|
||||
display: flex;
|
||||
block-size: 4px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress .v-progress-linear {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.subscribe-card-shell--selected::after {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
border: 2px solid rgb(var(--v-theme-primary));
|
||||
border-radius: inherit;
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
@@ -598,7 +879,7 @@ function handleCardClick() {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
|
||||
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
@@ -630,4 +911,30 @@ function handleCardClick() {
|
||||
background: rgba(255, 255, 255, 22%);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
|
||||
}
|
||||
|
||||
@media (width <= 599px) {
|
||||
.subscribe-card {
|
||||
min-block-size: 0 !important;
|
||||
}
|
||||
|
||||
.subscribe-card-paused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subscribe-card-paused .subscribe-card-mobile-media {
|
||||
filter: saturate(0.65);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.best-version-badge {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
|
||||
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</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,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</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,13 +317,18 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 优惠标签样式 */
|
||||
|
||||
@@ -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,12 +263,17 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,6 @@ type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
@@ -24,7 +23,6 @@ const props = withDefaults(
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
@@ -63,17 +61,42 @@ const elevatedValue = computed({
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => props.enabled,
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.elevated,
|
||||
value => {
|
||||
localElevated.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items.map(item => getItemValue(item)).join('\u0000'),
|
||||
() => {
|
||||
syncLocalEnabledItems()
|
||||
},
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
syncLocalEnabledItems()
|
||||
}
|
||||
|
||||
// 同步新增设置项的默认状态,避免刷新列表时覆盖用户正在编辑的本地选择。
|
||||
function syncLocalEnabledItems() {
|
||||
props.items.forEach(item => {
|
||||
const key = getItemValue(item)
|
||||
if (key && !(key in localEnabled.value)) {
|
||||
localEnabled.value[key] = Boolean(props.enabled[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
@@ -129,12 +152,13 @@ function submitSettings() {
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
type="button"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
:aria-pressed="Boolean(localEnabled[getItemValue(item)])"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
@@ -147,7 +171,7 @@ function submitSettings() {
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
@@ -186,43 +210,48 @@ function submitSettings() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
text-align: start;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--app-surface-radius);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
background: rgb(var(--v-theme-primary));
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@@ -230,6 +259,24 @@ function submitSettings() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item:focus-visible {
|
||||
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -240,4 +287,11 @@ function submitSettings() {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -9,12 +9,10 @@ const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
@@ -55,9 +53,16 @@ function submitOrder() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
width="35rem"
|
||||
class="settings-dialog"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
@@ -72,14 +77,11 @@ function submitOrder() {
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:animation="180"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<VCard variant="text" class="setting-item enabled">
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
@@ -102,6 +104,11 @@ function submitOrder() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
@@ -110,50 +117,86 @@ function submitOrder() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inline-size: 3px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.52);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.setting-item:hover .drag-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,9 +105,9 @@ function submitTemplate() {
|
||||
<style scoped>
|
||||
.notification-template-editor-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-editor-header {
|
||||
@@ -147,8 +147,8 @@ function submitTemplate() {
|
||||
|
||||
.template-ace-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,8 +51,14 @@ function downloadLogger() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -75,9 +81,29 @@ function downloadLogger() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (width <= 960px) {
|
||||
.logging-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.logging-dialog-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -228,21 +228,19 @@ onUnmounted(() => {
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="plugin-market-detail-actions">
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
@click="showUpdateHistory"
|
||||
prepend-icon="mdi-update"
|
||||
>
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<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) }) }}
|
||||
@@ -261,9 +259,17 @@ onUnmounted(() => {
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
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 {
|
||||
@@ -273,11 +279,15 @@ onUnmounted(() => {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
MediaInfo,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -18,7 +19,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'
|
||||
|
||||
@@ -124,6 +124,20 @@ interface TargetDirectoryOption {
|
||||
|
||||
const AUTO_TARGET_PATH_VALUE = '__moviepilot_auto_target_path__'
|
||||
|
||||
// 媒体类型映射到手动整理接口接受的类型名。
|
||||
function resolveTransferMediaType(type?: string) {
|
||||
const normalizedType = type?.trim().toLowerCase()
|
||||
if (!normalizedType) return undefined
|
||||
|
||||
const movieTypes = ['电影', 'movie']
|
||||
if (movieTypes.includes(normalizedType)) return '电影'
|
||||
|
||||
const tvTypes = ['电视剧', 'tv', 'series']
|
||||
if (tvTypes.includes(normalizedType)) return '电视剧'
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 生成文件项稳定键,用于去重和状态同步。
|
||||
function getFileItemKey(item?: FileItem) {
|
||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||
@@ -150,13 +164,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[]>([])
|
||||
@@ -305,6 +313,14 @@ const transferForm = reactive<TransferForm>({
|
||||
episode_group: null,
|
||||
})
|
||||
|
||||
// 处理媒体搜索结果选择,同步搜索结果中已识别的媒体类型。
|
||||
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
|
||||
const typeName = resolveTransferMediaType(item.type)
|
||||
if (!typeName) return
|
||||
|
||||
transferForm.type_name = typeName
|
||||
}
|
||||
|
||||
// 所有媒体库目录
|
||||
const directories = ref<TransferDirectoryConf[]>([])
|
||||
|
||||
@@ -419,9 +435,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 })
|
||||
})
|
||||
})
|
||||
|
||||
// 分页后的预览数据(含文件名解析)
|
||||
@@ -996,6 +1042,19 @@ function mergePreviewData(target: ManualTransferPreviewData, incoming?: ManualTr
|
||||
}
|
||||
}
|
||||
|
||||
// 从标准响应中提取可展示的整理预览数据,优先保留顶层本地化消息。
|
||||
function resolvePreviewResponseData(result: ApiResponse<ManualTransferPreviewData>) {
|
||||
if (!result.data) return result.data
|
||||
|
||||
const message = result.message_i18n || result.message || result.data.message
|
||||
if (!message || message === result.data.message) return result.data
|
||||
|
||||
return {
|
||||
...result.data,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
// 预览整理结果
|
||||
async function previewTransfer() {
|
||||
if (!props.logids && !normalizedItems.value.length) return
|
||||
@@ -1023,7 +1082,7 @@ async function previewTransfer() {
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
@@ -1055,7 +1114,7 @@ async function previewTransfer() {
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
@@ -1091,7 +1150,7 @@ async function previewTransfer() {
|
||||
return
|
||||
}
|
||||
|
||||
mergePreviewData(mergedPreviewData, result.data)
|
||||
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
|
||||
} catch (err: any) {
|
||||
console.warn(`预览请求异常: ${err?.message}`)
|
||||
mergePreviewData(
|
||||
@@ -1110,7 +1169,6 @@ async function previewTransfer() {
|
||||
|
||||
previewData.value = mergedPreviewData
|
||||
previewLoaded.value = true
|
||||
nextTick(() => updatePreviewPageSize())
|
||||
|
||||
if (previewHasFailures(mergedPreviewData)) {
|
||||
$toast.warning(getPreviewResultSummaryMessage(mergedPreviewData))
|
||||
@@ -1137,45 +1195,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 {
|
||||
@@ -1213,7 +1232,7 @@ async function handleTransferLog(logid: number, background: boolean = false) {
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressText.value = progress.text_i18n || progress.text
|
||||
progressValue.value = progress.value
|
||||
}
|
||||
}
|
||||
@@ -1303,7 +1322,6 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
stopLoadingProgress()
|
||||
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
|
||||
previewFileBodyResizeObserver?.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1671,7 +1689,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}`"
|
||||
@@ -1732,12 +1750,14 @@ onUnmounted(() => {
|
||||
v-if="mediaSource === 'themoviedb'"
|
||||
v-model="transferForm.tmdbid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
<MediaIdSelector
|
||||
v-else
|
||||
v-model="transferForm.doubanid"
|
||||
@close="mediaSelectorDialog = false"
|
||||
@select="handleMediaSelected"
|
||||
:type="mediaSource"
|
||||
/>
|
||||
</VDialog>
|
||||
@@ -1894,6 +1914,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;
|
||||
@@ -1919,6 +1941,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;
|
||||
@@ -1970,8 +1994,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 {
|
||||
@@ -2011,9 +2039,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 {
|
||||
@@ -2024,13 +2052,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;
|
||||
@@ -2039,10 +2067,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);
|
||||
}
|
||||
@@ -2168,7 +2192,7 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-preview-list {
|
||||
margin-block-end: 1rem;
|
||||
margin-inline: 1rem;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VDialog, VMenu } from 'vuetify/components'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -15,10 +16,16 @@ const display = useDisplay()
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义props,接收modelValue
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
// 定义 props,接收浮层状态及是否显示响应式入口。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
showActivator?: boolean
|
||||
}>(),
|
||||
{
|
||||
showActivator: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
@@ -78,16 +85,33 @@ const dialog = computed({
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
// 桌面使用锚定下拉,小屏继续使用原有搜索弹窗。
|
||||
const searchOverlay = computed(() => (display.mdAndUp.value ? VMenu : VDialog))
|
||||
const searchOverlayProps = computed(() =>
|
||||
display.mdAndUp.value
|
||||
? {
|
||||
closeOnContentClick: false,
|
||||
location: 'bottom start' as const,
|
||||
offset: 8,
|
||||
scrollStrategy: 'reposition' as const,
|
||||
}
|
||||
: {
|
||||
fullscreen: true,
|
||||
maxWidth: '40rem',
|
||||
scrollable: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 检测操作系统是否是Mac
|
||||
/** 检测操作系统是否为 macOS。 */
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
@@ -95,7 +119,7 @@ function isMac() {
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
|
||||
// 保存近期搜索到本地
|
||||
/** 将有效关键词保存到近期搜索记录。 */
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
@@ -103,7 +127,7 @@ function saveRecentSearches(keyword: string) {
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
/** 从本地存储加载并裁剪近期搜索记录。 */
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
@@ -115,7 +139,7 @@ function loadRecentSearches() {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
/** 获取可参与全局搜索的导航菜单和设置入口。 */
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
@@ -129,6 +153,7 @@ function getMenus(): NavMenu[] {
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
permission: item.permission,
|
||||
feature: item.feature,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
@@ -170,7 +195,7 @@ const matchedMenuItems = computed(() => {
|
||||
// 所有插件(已安装)
|
||||
const pluginItems = ref<Plugin[]>([])
|
||||
|
||||
// 获取插件列表数据
|
||||
/** 加载已安装插件,供搜索结果匹配。 */
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
pluginItems.value = await api.get('plugin/', {
|
||||
@@ -194,7 +219,7 @@ const matchedPluginItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 获取订阅列表数据
|
||||
/** 加载订阅列表,供搜索结果匹配。 */
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
@@ -203,7 +228,7 @@ async function fetchSubscribes() {
|
||||
}
|
||||
}
|
||||
|
||||
// 从接口加载用户站点偏好设置
|
||||
/** 从接口加载用户的站点搜索偏好。 */
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
@@ -216,7 +241,7 @@ const loadUserSitePreferences = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
/** 查询所有启用站点,并初始化默认选择。 */
|
||||
async function queryAllSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
@@ -231,7 +256,7 @@ async function queryAllSites() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
/** 打开指定资源类型的站点选择对话框。 */
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
siteSearchType.value = type
|
||||
chooseSiteDialog.value = true
|
||||
@@ -247,7 +272,7 @@ const matchedSubscribeItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索多站点
|
||||
/** 使用选中的站点执行当前资源类型搜索。 */
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
@@ -258,7 +283,7 @@ function searchSites(sites: number[]) {
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
/** 使用当前关键词搜索站点资源。 */
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
// 记录搜索词
|
||||
@@ -273,12 +298,10 @@ function searchTorrent() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
// 关闭搜索对话框
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 搜索字幕资源
|
||||
/** 使用当前关键词搜索字幕资源。 */
|
||||
function searchSubtitle() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -291,11 +314,10 @@ function searchSubtitle() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
@@ -307,10 +329,10 @@ function searchMedia(searchType: string) {
|
||||
type: searchType,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
/** 跳转到包含当前关键词的历史记录页。 */
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -320,10 +342,10 @@ function searchHistory() {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到订阅分享页面
|
||||
/** 跳转到包含当前关键词的订阅分享页。 */
|
||||
function searchSubscribeShares() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -333,10 +355,10 @@ function searchSubscribeShares() {
|
||||
keyword: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
/** 打开匹配插件的已安装详情。 */
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
path: `/plugins/`,
|
||||
@@ -345,16 +367,16 @@ function showPlugin(pluginId: string) {
|
||||
id: pluginId,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转菜单页面
|
||||
/** 跳转到匹配的功能菜单。 */
|
||||
function goPage(to: string) {
|
||||
router.push(to)
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
/** 根据订阅类型跳转到对应订阅详情。 */
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
@@ -371,13 +393,29 @@ function goSubscribe(subscribe: Subscribe) {
|
||||
},
|
||||
})
|
||||
}
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
/** 关闭搜索浮层并通知外层入口同步状态。 */
|
||||
function closeSearch() {
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** 聚焦当前可见输入框,供快捷键入口复用。 */
|
||||
function focusSearchInput() {
|
||||
searchWordInput.value?.focus()
|
||||
}
|
||||
|
||||
watch(dialog, async isOpen => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
focusSearchInput()
|
||||
})
|
||||
|
||||
defineExpose({ focusSearchInput })
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
// 根据权限加载不同的数据
|
||||
if (hasAdminPermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
@@ -395,10 +433,20 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="search-dialog">
|
||||
<!-- 搜索输入框区域 -->
|
||||
<div class="search-header">
|
||||
<component :is="searchOverlay" v-model="dialog" v-bind="searchOverlayProps">
|
||||
<template v-if="showActivator" #activator="{ props: activatorProps }">
|
||||
<!-- 小屏入口保持图标形态,点击后打开全屏搜索弹窗。 -->
|
||||
<IconBtn
|
||||
v-if="!display.mdAndUp.value"
|
||||
v-bind="activatorProps"
|
||||
class="search-icon-trigger"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
>
|
||||
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上常驻搜索输入框,输入时直接在下方展示同一组选项。 -->
|
||||
<div v-else v-bind="activatorProps" class="search-desktop-activator">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-magnify" size="22" class="search-input-icon" />
|
||||
<input
|
||||
@@ -408,7 +456,26 @@ onMounted(() => {
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape="emit('close')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<kbd class="search-shortcut-badge">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCard class="search-dialog" :class="{ 'search-dialog--dropdown': display.mdAndUp.value }">
|
||||
<!-- 弹窗模式保留原有搜索输入区。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-header">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-text" size="22" class="search-input-icon" />
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<VBtn icon size="small" variant="text" class="search-submit-btn" @click="searchMedia('media')">
|
||||
<VIcon icon="mdi-magnify" size="20" />
|
||||
@@ -697,26 +764,14 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部区域 -->
|
||||
<!-- 桌面端:快捷键提示 -->
|
||||
<div v-if="display.mdAndUp.value" class="search-footer">
|
||||
<div class="shortcut-group">
|
||||
<kbd>Esc</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.escClose') }}</span>
|
||||
</div>
|
||||
<div class="shortcut-group">
|
||||
<kbd>{{ metaKey }}</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.openSearch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端:关闭图标 -->
|
||||
<div v-else class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="emit('close')">
|
||||
<!-- 弹窗形态保留底部关闭按钮,桌面下拉不显示页脚。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="closeSearch">
|
||||
<VIcon icon="mdi-close" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</component>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
@@ -731,12 +786,46 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.search-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
inline-size: min(32rem, calc(100vw - 2rem));
|
||||
max-block-size: min(72vh, 42rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator {
|
||||
flex: 0 1 22rem;
|
||||
inline-size: clamp(15rem, 32vw, 22rem);
|
||||
max-inline-size: calc(100vw - 10rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator .search-input-wrapper {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
block-size: 42px;
|
||||
padding-inline: 14px 8px;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.v-theme--transparent .search-desktop-activator .search-input-wrapper {
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
||||
}
|
||||
|
||||
.search-icon-trigger {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-icon-trigger__icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* 搜索头部区域 */
|
||||
.search-header {
|
||||
padding-block: 16px 12px;
|
||||
@@ -748,18 +837,18 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--app-vuetify-rounded-pill);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
||||
block-size: 48px;
|
||||
padding-inline: 14px 6px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
border-radius 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
@@ -797,13 +886,24 @@ onMounted(() => {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12) !important;
|
||||
}
|
||||
|
||||
.search-shortcut-badge {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown .search-content {
|
||||
max-block-size: min(58vh, 34rem);
|
||||
}
|
||||
|
||||
.search-list {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -848,28 +948,13 @@ onMounted(() => {
|
||||
|
||||
.recent-searches-section {
|
||||
inline-size: 100%;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部快捷键提示 */
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
gap: 16px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.15);
|
||||
border-radius: 5px;
|
||||
@@ -883,11 +968,6 @@ kbd {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端底部关闭图标 */
|
||||
.search-footer-mobile {
|
||||
display: flex;
|
||||
@@ -912,10 +992,5 @@ kbd {
|
||||
.search-native-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const props = defineProps({
|
||||
sites: {
|
||||
type: Array as PropType<Site[]>,
|
||||
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
|
||||
</script>
|
||||
<template>
|
||||
<!-- Site Selection Dialog -->
|
||||
<VDialog max-width="40rem" fullscreen-mobile>
|
||||
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
|
||||
<VCard class="site-dialog">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
|
||||
@@ -42,8 +42,14 @@ function allLoggingDownloadUrl() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -66,9 +72,29 @@ function allLoggingDownloadUrl() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (width <= 960px) {
|
||||
.logging-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.logging-dialog-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
@@ -41,6 +42,17 @@ const visible = computed({
|
||||
})
|
||||
|
||||
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
const isSchedulerDialog = computed(() => props.cardClass.split(/\s+/).includes('scheduler-shortcut-dialog-card'))
|
||||
|
||||
// 透明主题下仅定时服务全屏弹窗取消外层 VCard 的背景和模糊,避免整屏磨砂遮住界面。
|
||||
const cardClasses = computed(() => [
|
||||
props.cardClass,
|
||||
{
|
||||
'scheduler-shortcut-dialog-card--transparent':
|
||||
isFullscreen.value && isTransparentTheme.value && isSchedulerDialog.value,
|
||||
},
|
||||
])
|
||||
|
||||
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||
const bodyClasses = computed(() => [
|
||||
@@ -50,17 +62,17 @@ const bodyClasses = computed(() => [
|
||||
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="isFullscreen">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCard :class="cardClasses">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon :icon="props.icon" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
@@ -90,4 +102,49 @@ const bodyClasses = computed(() => [
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 959.98px) {
|
||||
.words-shortcut-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.words-shortcut-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.scheduler-shortcut-dialog-card--transparent {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.cache-shortcut-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .cache-shortcut-dialog-card,
|
||||
.v-theme--transparent .cache-shortcut-dialog-card {
|
||||
backdrop-filter: blur(var(--transparent-blur, 10px));
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
|
||||
.cache-shortcut-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 当前主题
|
||||
const theme = useTheme()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 是否透明主题
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 获取资源项唯一标识
|
||||
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||
}
|
||||
@@ -188,10 +195,12 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 切换移动端搜索栏
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
// 关闭移动端搜索栏
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
@@ -480,7 +489,11 @@ onMounted(() => {
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCard
|
||||
class="site-resource-card"
|
||||
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
|
||||
variant="flat"
|
||||
>
|
||||
<VCardText class="pa-3">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
|
||||
@@ -746,7 +759,7 @@ onMounted(() => {
|
||||
background: var(--site-resource-card-bg);
|
||||
}
|
||||
|
||||
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||
.site-resource-card--transparent {
|
||||
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
|
||||
@@ -64,8 +64,10 @@ const historyChartOptions = computed(() => {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
@@ -156,8 +158,10 @@ const seedingChartOptions = computed(() => {
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
@@ -96,6 +97,13 @@ const seasonItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
function getSubscribeDisplayName() {
|
||||
const name = subscribeForm.value.name || ''
|
||||
const season = subscribeForm.value.season
|
||||
if (season === null || season === undefined) return name
|
||||
return `${name} ${formatSeason(season.toString())}`
|
||||
}
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
@@ -158,11 +166,11 @@ async function updateSubscribeInfo() {
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${subscribeForm.value.name} 更新成功!`)
|
||||
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}!`)
|
||||
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -258,7 +266,7 @@ async function removeSubscribe() {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
|
||||
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
@@ -317,10 +325,7 @@ onMounted(() => {
|
||||
{{ props.default ? t('dialog.subscribeEdit.titleDefault') : t('dialog.subscribeEdit.titleEdit') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="!props.default">
|
||||
{{ subscribeForm.name }}
|
||||
<span v-if="subscribeForm.season">
|
||||
{{ t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season }) }}
|
||||
</span>
|
||||
{{ getSubscribeDisplayName() }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle v-else>
|
||||
{{ props.type }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
src/components/dialog/SubscribeModeDialog.vue
Normal file
64
src/components/dialog/SubscribeModeDialog.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: string
|
||||
modes?: SubscribeMode[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'choose', mode: SubscribeMode): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const modeItems = computed<SubscribeMode[]>(() =>
|
||||
props.modes?.length
|
||||
? props.modes
|
||||
: props.type === '电视剧'
|
||||
? ['normal', 'best_version', 'best_version_full']
|
||||
: ['normal', 'best_version'],
|
||||
)
|
||||
|
||||
const optionMeta: Record<SubscribeMode, { icon: string; title: string }> = {
|
||||
normal: {
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
title: t('dialog.subscribeMode.normal'),
|
||||
},
|
||||
best_version: {
|
||||
icon: 'mdi-refresh',
|
||||
title:
|
||||
props.type === '电视剧' ? t('dialog.subscribeMode.bestVersionEpisode') : t('dialog.subscribeMode.bestVersion'),
|
||||
},
|
||||
best_version_full: {
|
||||
icon: 'mdi-shimmer',
|
||||
title: t('dialog.subscribeMode.bestVersionFull'),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="28rem" @update:model-value="emit('update:modelValue', $event)">
|
||||
<VCard>
|
||||
<VCardTitle class="text-lg font-weight-bold px-5 pt-5">
|
||||
{{ t('dialog.subscribeMode.title') }}
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VList class="py-2">
|
||||
<VListItem
|
||||
v-for="mode in modeItems"
|
||||
:key="mode"
|
||||
:prepend-icon="optionMeta[mode].icon"
|
||||
:title="optionMeta[mode].title"
|
||||
@click="emit('choose', mode)"
|
||||
/>
|
||||
</VList>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -2,12 +2,28 @@
|
||||
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'
|
||||
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
type SubscribeModeOption = {
|
||||
icon: string
|
||||
title: string
|
||||
value: SubscribeMode
|
||||
}
|
||||
|
||||
type EpisodeGroupOption = {
|
||||
title: string
|
||||
subtitle: string
|
||||
value: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['subscribe', 'close'])
|
||||
@@ -15,6 +31,11 @@ const emit = defineEmits(['subscribe', 'close'])
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaInfo>,
|
||||
selectedSeason: Number,
|
||||
initialEpisodeGroup: String,
|
||||
subscribedSeasons: Array as PropType<number[]>,
|
||||
subscribedSeasonModes: Object as PropType<SeasonSubscribeModes>,
|
||||
defaultSubscribeMode: String as PropType<SubscribeMode>,
|
||||
})
|
||||
|
||||
// 从 provide 中获取全局设置
|
||||
@@ -25,8 +46,14 @@ const globalSettings = globalSettingsStore.globalSettings
|
||||
// 季详情
|
||||
const seasonInfos = ref<MediaSeason[]>([])
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 选中的订阅季号
|
||||
const seasonsSelected = ref<number[]>([])
|
||||
|
||||
// 各季订阅方式
|
||||
const seasonModes = ref<Record<number, SubscribeMode>>({})
|
||||
|
||||
// 用户已手动选择过模式的季号,入库状态异步刷新时不再覆盖。
|
||||
const manuallySelectedModeSeasons = ref(new Set<number>())
|
||||
|
||||
// 各季缺失状态:0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
|
||||
const seasonsNotExisted = ref<{ [key: number]: number }>({})
|
||||
@@ -38,35 +65,99 @@ const isRefreshed = ref(false)
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref('')
|
||||
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
}
|
||||
// 剧集组横向轨道
|
||||
const episodeGroupRail = ref<HTMLElement | null>(null)
|
||||
|
||||
// 剧集组轨道左右滚动状态
|
||||
const canScrollEpisodeGroupsBackward = ref(false)
|
||||
const canScrollEpisodeGroupsForward = ref(false)
|
||||
|
||||
const subscribeModeOptions = computed<SubscribeModeOption[]>(() => [
|
||||
{
|
||||
title: t('dialog.subscribeMode.normal'),
|
||||
value: 'normal',
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
},
|
||||
{
|
||||
title: t('dialog.subscribeMode.bestVersionEpisode'),
|
||||
value: 'best_version',
|
||||
icon: 'mdi-refresh',
|
||||
},
|
||||
{
|
||||
title: t('dialog.subscribeMode.bestVersionFull'),
|
||||
value: 'best_version_full',
|
||||
icon: 'mdi-shimmer',
|
||||
},
|
||||
])
|
||||
|
||||
// 获取订阅模式的主题色。
|
||||
function getSubscribeModeColor(mode: SubscribeMode) {
|
||||
if (mode === 'normal') return 'primary'
|
||||
if (mode === 'best_version') return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 校验弹窗输入是否为支持的订阅模式。
|
||||
function isSubscribeMode(value: unknown): value is SubscribeMode {
|
||||
return value === 'normal' || value === 'best_version' || value === 'best_version_full'
|
||||
}
|
||||
|
||||
const subscribedSeasonSet = computed(() => new Set(props.subscribedSeasons ?? []))
|
||||
|
||||
const selectedSeasonSet = computed(() => new Set(seasonsSelected.value))
|
||||
|
||||
const visibleSeasonNumbers = computed(() =>
|
||||
seasonInfos.value
|
||||
.map(item => item.season_number)
|
||||
.filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
|
||||
const hasSelectionChanges = computed(() => {
|
||||
const visibleSeasons = new Set(visibleSeasonNumbers.value)
|
||||
|
||||
for (const season of visibleSeasons) {
|
||||
if (subscribedSeasonSet.value.has(season) !== selectedSeasonSet.value.has(season)) return true
|
||||
if (
|
||||
subscribedSeasonSet.value.has(season) &&
|
||||
selectedSeasonSet.value.has(season) &&
|
||||
(props.subscribedSeasonModes?.[season] ?? 'normal') !== (seasonModes.value[season] ?? 'normal')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const submitButtonText = computed(() => {
|
||||
if (!hasSelectionChanges.value && seasonsSelected.value.length === 0) return t('dialog.subscribeSeason.selectSeasons')
|
||||
|
||||
return t('dialog.subscribeSeason.submit')
|
||||
})
|
||||
|
||||
// 剧集组选项
|
||||
const episodeGroupOptions = computed(() => {
|
||||
let options = (episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]).map(
|
||||
item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
|
||||
'dialog.subscribeSeason.episodeCount',
|
||||
{ count: item.episode_count },
|
||||
)}`,
|
||||
value: item.id,
|
||||
}
|
||||
},
|
||||
)
|
||||
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
|
||||
const options = (
|
||||
episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]
|
||||
).map(item => {
|
||||
return {
|
||||
title: item.name,
|
||||
subtitle: `${t('dialog.subscribeSeason.seasonCount', { count: item.group_count })} • ${t(
|
||||
'dialog.subscribeSeason.episodeCount',
|
||||
{ count: item.episode_count },
|
||||
)}`,
|
||||
value: item.id,
|
||||
icon: 'mdi-folder-play-outline',
|
||||
}
|
||||
})
|
||||
// 添加不使用选项
|
||||
options.unshift({
|
||||
title: t('dialog.subscribeSeason.defaultGroup'),
|
||||
subtitle: t('dialog.subscribeSeason.seasonCount', { count: seasonInfos.value.length }),
|
||||
value: '',
|
||||
icon: 'mdi-layers-outline',
|
||||
})
|
||||
return options
|
||||
})
|
||||
@@ -89,6 +180,8 @@ async function getEpisodeGroups() {
|
||||
episodeGroups.value = await api.get(`media/groups/${props.media?.tmdb_id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
nextTick(updateEpisodeGroupScrollState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,9 +218,10 @@ async function getGroupSeasons() {
|
||||
async function checkSeasonsNotExists() {
|
||||
// 开始处理
|
||||
try {
|
||||
let tmpMedia = props.media ?? { episode_group: '' }
|
||||
if (episodeGroup.value) tmpMedia.episode_group = episodeGroup.value
|
||||
else tmpMedia.episode_group = ''
|
||||
const tmpMedia = {
|
||||
...(props.media ?? {}),
|
||||
episode_group: episodeGroup.value || '',
|
||||
}
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', tmpMedia)
|
||||
if (result) {
|
||||
result.forEach(item => {
|
||||
@@ -183,8 +277,141 @@ function getYear(airDate: string) {
|
||||
return date.getFullYear()
|
||||
}
|
||||
|
||||
// 切换当前剧集组并清空上一组的派生数据。
|
||||
function setEpisodeGroup(value: string) {
|
||||
if (episodeGroup.value === value) return
|
||||
|
||||
seasonsNotExisted.value = {}
|
||||
seasonInfos.value = []
|
||||
episodeGroup.value = value
|
||||
nextTick(updateEpisodeGroupScrollState)
|
||||
}
|
||||
|
||||
// 刷新剧集组横向轨道的左右滚动按钮状态。
|
||||
function updateEpisodeGroupScrollState() {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) {
|
||||
canScrollEpisodeGroupsBackward.value = false
|
||||
canScrollEpisodeGroupsForward.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
|
||||
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
|
||||
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
|
||||
}
|
||||
|
||||
// 按一屏内可辨识的距离横向滚动剧集组轨道。
|
||||
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) return
|
||||
|
||||
rail.scrollBy({
|
||||
behavior: 'smooth',
|
||||
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
|
||||
})
|
||||
}
|
||||
|
||||
// 提交当前剧集组下选中的季及其订阅模式。
|
||||
function subscribeSeasons() {
|
||||
emit('subscribe', seasonsSelected.value, seasonsNotExisted.value, episodeGroup.value)
|
||||
const selectedSeasons = seasonInfos.value.filter(item => {
|
||||
const seasonNumber = item.season_number ?? null
|
||||
return seasonNumber !== null && selectedSeasonSet.value.has(seasonNumber)
|
||||
})
|
||||
|
||||
emit(
|
||||
'subscribe',
|
||||
selectedSeasons,
|
||||
seasonsNotExisted.value,
|
||||
episodeGroup.value,
|
||||
{ ...seasonModes.value },
|
||||
visibleSeasonNumbers.value,
|
||||
)
|
||||
}
|
||||
|
||||
// 写入指定季的订阅模式。
|
||||
function setSeasonMode(season: number, mode: SubscribeMode) {
|
||||
seasonModes.value = {
|
||||
...seasonModes.value,
|
||||
[season]: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// 处理用户手动切换指定季的订阅模式。
|
||||
function updateSeasonMode(season: number, mode: unknown) {
|
||||
if (!isSubscribeMode(mode)) return
|
||||
manuallySelectedModeSeasons.value.add(season)
|
||||
setSeasonMode(season, mode)
|
||||
}
|
||||
|
||||
// 根据入库状态和系统配置计算指定季的默认订阅模式。
|
||||
function getDefaultSeasonMode(season: number) {
|
||||
if (!seasonsNotExisted.value[season]) return 'best_version_full'
|
||||
|
||||
return props.defaultSubscribeMode ?? 'normal'
|
||||
}
|
||||
|
||||
// 确保指定季已初始化订阅模式。
|
||||
function ensureSeasonMode(season: number) {
|
||||
if (!seasonModes.value[season]) setSeasonMode(season, props.subscribedSeasonModes?.[season] ?? getDefaultSeasonMode(season))
|
||||
}
|
||||
|
||||
// 在入库状态刷新后同步尚未手动修改的默认模式。
|
||||
function syncDefaultSeasonModes() {
|
||||
seasonsSelected.value.forEach(season => {
|
||||
if (subscribedSeasonSet.value.has(season) || manuallySelectedModeSeasons.value.has(season)) return
|
||||
setSeasonMode(season, getDefaultSeasonMode(season))
|
||||
})
|
||||
}
|
||||
|
||||
// 判断指定季是否已有订阅。
|
||||
function isSeasonSubscribed(season: number) {
|
||||
return subscribedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
// 判断指定季是否在本次提交选择中。
|
||||
function isSeasonSelected(season: number) {
|
||||
return selectedSeasonSet.value.has(season)
|
||||
}
|
||||
|
||||
// 设置指定季的选择状态并同步其订阅模式。
|
||||
function setSeasonSelected(season: number, selected: boolean | null) {
|
||||
const nextSeasons = new Set(seasonsSelected.value)
|
||||
if (selected) {
|
||||
nextSeasons.add(season)
|
||||
ensureSeasonMode(season)
|
||||
} else {
|
||||
nextSeasons.delete(season)
|
||||
}
|
||||
seasonsSelected.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
// 切换指定季的选择状态。
|
||||
function toggleSeasonSelected(season: number) {
|
||||
setSeasonSelected(season, !isSeasonSelected(season))
|
||||
}
|
||||
|
||||
// 将入口预选季和已有订阅同步到当前剧集组的可见季列表。
|
||||
function syncSelectedSeason() {
|
||||
if (!seasonInfos.value.length) return
|
||||
|
||||
const seasonNumbers = new Set<number>()
|
||||
props.subscribedSeasons?.forEach(season => seasonNumbers.add(season))
|
||||
if (props.selectedSeason !== undefined) seasonNumbers.add(props.selectedSeason)
|
||||
|
||||
const validSeasonNumbers = new Set(
|
||||
seasonInfos.value
|
||||
.map(item => item.season_number)
|
||||
.filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
|
||||
seasonsSelected.value = [...seasonNumbers].filter(season => validSeasonNumbers.has(season)).sort((a, b) => a - b)
|
||||
seasonsSelected.value.forEach(ensureSeasonMode)
|
||||
Object.entries(props.subscribedSeasonModes ?? {}).forEach(([season, mode]) => {
|
||||
const seasonNumber = Number(season)
|
||||
if (!validSeasonNumbers.has(seasonNumber)) return
|
||||
setSeasonMode(seasonNumber, mode)
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
@@ -193,33 +420,104 @@ watchEffect(() => {
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
watch(seasonInfos, syncSelectedSeason)
|
||||
|
||||
watch(seasonsNotExisted, syncDefaultSeasonModes, { deep: true })
|
||||
|
||||
watch(() => props.selectedSeason, syncSelectedSeason)
|
||||
|
||||
watch(() => props.subscribedSeasons, syncSelectedSeason)
|
||||
|
||||
watch(() => props.subscribedSeasonModes, syncSelectedSeason)
|
||||
|
||||
watch(episodeGroupOptions, () => nextTick(updateEpisodeGroupScrollState), { flush: 'post' })
|
||||
|
||||
onMounted(async () => {
|
||||
getMediaSeasons()
|
||||
window.addEventListener('resize', updateEpisodeGroupScrollState)
|
||||
// 自定义剧集组由 watchEffect 首次加载,避免默认季数据异步覆盖它。
|
||||
if (!episodeGroup.value) getMediaSeasons()
|
||||
getEpisodeGroups()
|
||||
checkSeasonsNotExists()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateEpisodeGroupScrollState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBottomSheet inset scrollable>
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="pe-10"> {{ t('dialog.subscribeSeason.title', { title: props.media?.title }) }} </VCardTitle>
|
||||
<VDialog max-width="45rem" scrollable :fullscreen="!mdAndUp">
|
||||
<VCard class="subscribe-season-dialog">
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-clipboard-list-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle class="pe-10">
|
||||
{{ t('dialog.subscribeSeason.selectSeasons') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ props.media?.title }}
|
||||
</VCardSubtitle>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VSelect
|
||||
v-model="episodeGroup"
|
||||
:items="episodeGroupOptions"
|
||||
:item-props="episodeGroupItemProps"
|
||||
:label="t('dialog.subscribeSeason.selectGroup')"
|
||||
persistent-hint
|
||||
/>
|
||||
<div class="subscribe-season-group-selector">
|
||||
<div class="subscribe-season-group-label">
|
||||
{{ t('dialog.subscribeSeason.selectGroup') }}
|
||||
</div>
|
||||
<div class="subscribe-season-group-rail-shell">
|
||||
<button
|
||||
v-if="mdAndUp && canScrollEpisodeGroupsBackward"
|
||||
type="button"
|
||||
class="subscribe-season-group-nav subscribe-season-group-nav--backward"
|
||||
:aria-label="t('media.episodeGroups.previous')"
|
||||
@click="scrollEpisodeGroups('backward')"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-left" />
|
||||
</button>
|
||||
<div
|
||||
ref="episodeGroupRail"
|
||||
class="subscribe-season-group-options"
|
||||
@scroll.passive="updateEpisodeGroupScrollState"
|
||||
>
|
||||
<button
|
||||
v-for="group in episodeGroupOptions"
|
||||
:key="group.value || 'default'"
|
||||
type="button"
|
||||
class="subscribe-season-group-option"
|
||||
:class="{ 'subscribe-season-group-option--active': episodeGroup === group.value }"
|
||||
@click="setEpisodeGroup(group.value)"
|
||||
>
|
||||
<VIcon :icon="group.icon" size="small" />
|
||||
<span class="subscribe-season-group-text">
|
||||
<span class="subscribe-season-group-title">{{ group.title }}</span>
|
||||
<span class="subscribe-season-group-subtitle">{{ group.subtitle }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="mdAndUp && canScrollEpisodeGroupsForward"
|
||||
type="button"
|
||||
class="subscribe-season-group-nav subscribe-season-group-nav--forward"
|
||||
:aria-label="t('media.episodeGroups.next')"
|
||||
@click="scrollEpisodeGroups('forward')"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<div v-else-if="seasonInfos.length > 0">
|
||||
<VList v-model:selected="seasonsSelected" lines="three" select-strategy="classic">
|
||||
<VListItem v-for="(item, i) in seasonInfos" :key="i" :value="item">
|
||||
<VList lines="three" class="subscribe-season-list">
|
||||
<VListItem
|
||||
v-for="(item, i) in seasonInfos"
|
||||
:key="i"
|
||||
:active="isSeasonSelected(item.season_number || 0)"
|
||||
rounded="lg"
|
||||
class="subscribe-season-list-item"
|
||||
@click="toggleSeasonSelected(item.season_number || 0)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
@@ -258,10 +556,41 @@ onMounted(async () => {
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
</VChip>
|
||||
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
|
||||
{{ t('media.status.subscribed') }}
|
||||
</VChip>
|
||||
</VListItemSubtitle>
|
||||
<template #append="{ isSelected }">
|
||||
<VListItemAction start>
|
||||
<VSwitch :model-value="isSelected" />
|
||||
<template #append>
|
||||
<VListItemAction start class="subscribe-season-actions">
|
||||
<VSwitch
|
||||
:model-value="isSeasonSelected(item.season_number || 0)"
|
||||
hide-details
|
||||
@click.stop
|
||||
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
|
||||
/>
|
||||
<VBtnToggle
|
||||
v-if="isSeasonSelected(item.season_number || 0)"
|
||||
:model-value="seasonModes[item.season_number || 0] || 'normal'"
|
||||
density="compact"
|
||||
divided
|
||||
mandatory
|
||||
variant="outlined"
|
||||
class="subscribe-season-mode-toggle"
|
||||
@click.stop
|
||||
@update:model-value="updateSeasonMode(item.season_number || 0, $event)"
|
||||
>
|
||||
<VBtn
|
||||
v-for="mode in subscribeModeOptions"
|
||||
:key="mode.value"
|
||||
:value="mode.value"
|
||||
:color="getSubscribeModeColor(mode.value)"
|
||||
size="small"
|
||||
class="subscribe-season-mode-button"
|
||||
>
|
||||
<VIcon :icon="mode.icon" size="small" />
|
||||
<span>{{ mode.title }}</span>
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</VListItemAction>
|
||||
</template>
|
||||
</VListItem>
|
||||
@@ -269,15 +598,246 @@ onMounted(async () => {
|
||||
</div>
|
||||
<NoDataFound v-else errorTitle="出错啦!" :errorDescription="`${props.media?.title} 未查询到季集信息`" />
|
||||
</VCardText>
|
||||
<div class="my-2 text-center">
|
||||
<VBtn :disabled="seasonsSelected.length === 0" width="30%" @click="subscribeSeasons">
|
||||
{{
|
||||
seasonsSelected.length === 0
|
||||
? t('dialog.subscribeSeason.selectSeasons')
|
||||
: t('dialog.subscribeSeason.submit')
|
||||
}}
|
||||
<VCardActions class="justify-center py-3">
|
||||
<VBtn
|
||||
:disabled="!hasSelectionChanges"
|
||||
width="30%"
|
||||
min-width="8rem"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
class="subscribe-season-submit"
|
||||
@click="subscribeSeasons"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-check" />
|
||||
</template>
|
||||
{{ submitButtonText }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.subscribe-season-actions {
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
align-self: stretch;
|
||||
block-size: 100%;
|
||||
gap: 0.5rem;
|
||||
justify-items: end;
|
||||
min-inline-size: 18rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-selector {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-rail-shell {
|
||||
position: relative;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-options {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.125rem 0.375rem;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: inline proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.subscribe-season-group-options::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
display: inline-flex;
|
||||
flex: 0 0 12rem;
|
||||
align-items: center;
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--v-theme-surface), 0.82);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: 0.75rem;
|
||||
scroll-snap-align: start;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-surface), 0.9);
|
||||
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.18);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.24);
|
||||
}
|
||||
|
||||
.subscribe-season-group-option:focus-visible,
|
||||
.subscribe-season-group-nav:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.subscribe-season-group-text {
|
||||
display: grid;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-title,
|
||||
.subscribe-season-group-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-season-group-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option--active .subscribe-season-group-subtitle {
|
||||
color: rgba(var(--v-theme-primary), 0.82);
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter, blur(var(--transparent-blur-light, 6px)));
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 9999px;
|
||||
background: rgba(var(--v-theme-surface), 0.96);
|
||||
block-size: 2.5rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 2.5rem;
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-55%);
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-surface), 0.96);
|
||||
box-shadow: 0 6px 18px rgba(var(--v-theme-on-surface), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav--backward {
|
||||
inset-inline-start: -0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-group-nav--forward {
|
||||
inset-inline-end: -0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__append) {
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-toggle {
|
||||
block-size: 2rem;
|
||||
max-inline-size: 18rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button {
|
||||
min-inline-size: 0;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button span {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
margin-inline-start: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.subscribe-season-actions {
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 6.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-toggle {
|
||||
inline-size: 6.75rem;
|
||||
max-inline-size: 6.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button {
|
||||
flex: 1 1 0;
|
||||
padding-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.subscribe-season-mode-button span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.subscribe-season-submit {
|
||||
inline-size: 100% !important;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__content) {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-season-group-option {
|
||||
flex-basis: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.subscribe-season-group-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useTransparencySettings } from '@/composables/useTransparencySettings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,15 +42,17 @@ const {
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
} = useTransparencySettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -118,6 +124,34 @@ const {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
|
||||
<VBtnToggle
|
||||
v-model="transparencyGlassQuality"
|
||||
mandatory
|
||||
divided
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
@update:model-value="onGlassQualityChange"
|
||||
>
|
||||
<VBtn value="lightweight" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityLightweight') }}
|
||||
</VBtn>
|
||||
<VBtn value="realtime" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityRealtime') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||
{{
|
||||
transparencyGlassQuality === 'realtime'
|
||||
? t('theme.transparencyGlassQualityRealtimeHint')
|
||||
: t('theme.transparencyGlassQualityLightweightHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
|
||||
<VBtnGroup density="compact" variant="outlined" class="w-full">
|
||||
|
||||
@@ -7,6 +7,14 @@ import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
USER_PERMISSION_FEATURES,
|
||||
buildDefaultFeaturePermissions,
|
||||
normalizeUserPermissions,
|
||||
type UserPermissionCategoryKey,
|
||||
type UserPermissionFeatureKey,
|
||||
type UserPermissions,
|
||||
} from '@/utils/permission'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -65,14 +73,6 @@ interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 权限类型定义
|
||||
interface UserPermissions {
|
||||
discovery: boolean // 发现权限
|
||||
search: boolean // 搜索权限
|
||||
subscribe: boolean // 订阅权限
|
||||
manage: boolean // 管理权限
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
@@ -88,6 +88,7 @@ const userForm = ref<ExtendedUser>({
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: false,
|
||||
features: buildDefaultFeaturePermissions(),
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
@@ -127,34 +128,115 @@ const permissionOptions = [
|
||||
description: t('dialog.userAddEdit.permissions.manageDesc'),
|
||||
icon: 'mdi-cog-outline',
|
||||
},
|
||||
]
|
||||
] as const
|
||||
|
||||
const activePermissionCategory = ref<UserPermissionCategoryKey>('discovery')
|
||||
|
||||
// 权限状态计算属性
|
||||
const userPermissions = computed({
|
||||
get: () => {
|
||||
const permissions = userForm.value.permissions as UserPermissions
|
||||
return {
|
||||
discovery: permissions?.discovery ?? true,
|
||||
search: permissions?.search ?? true,
|
||||
subscribe: permissions?.subscribe ?? true,
|
||||
manage: permissions?.manage ?? false,
|
||||
}
|
||||
return normalizeUserPermissions(userForm.value.permissions as Partial<UserPermissions>)
|
||||
},
|
||||
set: (value: UserPermissions) => {
|
||||
userForm.value.permissions = value
|
||||
},
|
||||
})
|
||||
|
||||
// 切换权限状态
|
||||
function togglePermission(key: keyof UserPermissions) {
|
||||
const permissionFeatureOptions = computed(() =>
|
||||
USER_PERMISSION_FEATURES.map(feature => ({
|
||||
...feature,
|
||||
title: t(feature.titleKey),
|
||||
description: t(feature.descriptionKey),
|
||||
})),
|
||||
)
|
||||
|
||||
const activePermissionOption = computed(() =>
|
||||
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
|
||||
)
|
||||
|
||||
const activePermissionFeatures = computed(() =>
|
||||
permissionFeatureOptions.value.filter(feature => feature.permission === activePermissionCategory.value),
|
||||
)
|
||||
|
||||
const enabledCategoryCount = computed(
|
||||
() => permissionOptions.filter(option => userPermissions.value[option.key]).length,
|
||||
)
|
||||
|
||||
const enabledFeatureCount = computed(
|
||||
() =>
|
||||
permissionFeatureOptions.value.filter(
|
||||
feature => userPermissions.value[feature.permission] && isFeatureEnabled(feature.key),
|
||||
).length,
|
||||
)
|
||||
|
||||
/** 切换当前查看的权限分类。 */
|
||||
function selectPermissionCategory(key: UserPermissionCategoryKey) {
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
/** 切换权限分类启用状态,并聚焦到该分类的功能列表。 */
|
||||
function togglePermission(key: UserPermissionCategoryKey) {
|
||||
const currentPermissions = userPermissions.value
|
||||
userPermissions.value = {
|
||||
...currentPermissions,
|
||||
[key]: !currentPermissions[key],
|
||||
}
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
/** 判断功能项是否已启用,缺省功能按启用处理以兼容旧权限数据。 */
|
||||
function isFeatureEnabled(key: UserPermissionFeatureKey) {
|
||||
return userPermissions.value.features?.[key] !== false
|
||||
}
|
||||
|
||||
/** 切换单个功能项的启用状态。 */
|
||||
function togglePermissionFeature(key: UserPermissionFeatureKey) {
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: {
|
||||
...(userPermissions.value.features ?? {}),
|
||||
[key]: !isFeatureEnabled(key),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 统计指定分类下已经勾选的功能数量。 */
|
||||
function getEnabledFeatureCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(
|
||||
feature => feature.permission === permission && isFeatureEnabled(feature.key),
|
||||
).length
|
||||
}
|
||||
|
||||
/** 返回指定分类下的功能总数。 */
|
||||
function getFeatureTotalCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(feature => feature.permission === permission).length
|
||||
}
|
||||
|
||||
/** 判断指定分类是否处于功能部分勾选状态。 */
|
||||
function isPermissionPartiallySelected(permission: UserPermissionCategoryKey) {
|
||||
const enabledCount = getEnabledFeatureCount(permission)
|
||||
const totalCount = getFeatureTotalCount(permission)
|
||||
return enabledCount > 0 && enabledCount < totalCount
|
||||
}
|
||||
|
||||
/** 批量设置当前分类下的所有功能项。 */
|
||||
function setCategoryFeatures(permission: UserPermissionCategoryKey, enabled: boolean) {
|
||||
const nextFeatures = { ...(userPermissions.value.features ?? {}) }
|
||||
for (const feature of permissionFeatureOptions.value.filter(item => item.permission === permission)) {
|
||||
nextFeatures[feature.key] = enabled
|
||||
}
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: nextFeatures,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将当前分类下的功能项恢复为默认全选状态。 */
|
||||
function resetCategoryFeatures(permission: UserPermissionCategoryKey) {
|
||||
setCategoryFeatures(permission, true)
|
||||
}
|
||||
|
||||
/** 校验并读取用户上传的新头像。 */
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
const { files } = file.target as HTMLInputElement
|
||||
@@ -182,19 +264,19 @@ function changeAvatar(file: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置默认头像
|
||||
/** 将头像恢复为系统默认头像。 */
|
||||
function resetDefaultAvatar() {
|
||||
currentAvatar.value = avatar1
|
||||
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 还原当前头像
|
||||
/** 还原为用户当前已保存的头像。 */
|
||||
function restoreCurrentAvatar() {
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
/** 从接口查询当前编辑用户的信息并回填表单。 */
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
@@ -210,7 +292,7 @@ async function fetchUserInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增用户
|
||||
/** 调用接口创建新用户。 */
|
||||
async function addUser() {
|
||||
if (isAdding.value) {
|
||||
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
|
||||
@@ -256,7 +338,7 @@ async function addUser() {
|
||||
isAdding.value = false
|
||||
}
|
||||
|
||||
// 调用API更新用户信息
|
||||
/** 调用接口更新当前用户信息。 */
|
||||
async function updateUser() {
|
||||
if (isUpdating.value) {
|
||||
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
|
||||
@@ -367,7 +449,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="64rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
|
||||
<template #prepend>
|
||||
@@ -572,43 +654,144 @@ onMounted(() => {
|
||||
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
|
||||
</VDivider>
|
||||
<!-- 权限设置 -->
|
||||
<div v-if="canControl">
|
||||
<VRow>
|
||||
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
|
||||
<VCard
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
|
||||
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer transition-all h-full"
|
||||
@click="togglePermission(option.key as keyof UserPermissions)"
|
||||
hover
|
||||
<div v-if="canControl" class="user-permission-editor">
|
||||
<div class="permission-section-header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryTitle') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryHint') }}
|
||||
</div>
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal">
|
||||
{{
|
||||
t('dialog.userAddEdit.permissions.summary', {
|
||||
categories: enabledCategoryCount,
|
||||
features: enabledFeatureCount,
|
||||
})
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="permission-category-grid">
|
||||
<div
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.key"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="permission-category-option"
|
||||
:class="{
|
||||
'is-active': activePermissionCategory === option.key,
|
||||
'is-enabled': userPermissions[option.key],
|
||||
'is-partial': isPermissionPartiallySelected(option.key),
|
||||
}"
|
||||
@click="selectPermissionCategory(option.key)"
|
||||
@keydown.enter="selectPermissionCategory(option.key)"
|
||||
@keydown.space.prevent="selectPermissionCategory(option.key)"
|
||||
>
|
||||
<span class="permission-category-option__icon">
|
||||
<VIcon :icon="option.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-category-option__body">
|
||||
<span class="permission-category-option__title-row">
|
||||
<span class="permission-category-option__title">{{ option.title }}</span>
|
||||
<span class="permission-category-option__count">
|
||||
{{ getEnabledFeatureCount(option.key) }}/{{ getFeatureTotalCount(option.key) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="permission-category-option__desc">{{ option.description }}</span>
|
||||
</span>
|
||||
<VBtn
|
||||
:icon="userPermissions[option.key] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="userPermissions[option.key] ? 'primary' : 'secondary'"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="permission-category-option__toggle"
|
||||
@click.stop="togglePermission(option.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-panel">
|
||||
<div class="permission-feature-panel__header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ activePermissionOption.title }}{{ t('dialog.userAddEdit.permissions.featureTitleSuffix') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{
|
||||
userPermissions[activePermissionCategory]
|
||||
? t('dialog.userAddEdit.permissions.featureHint')
|
||||
: t('dialog.userAddEdit.permissions.disabledCategoryHint')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="permission-feature-panel__actions">
|
||||
<VBtn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, true)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, false)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.clear') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="resetCategoryFeatures(activePermissionCategory)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.default') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-list">
|
||||
<div
|
||||
v-for="feature in activePermissionFeatures"
|
||||
:key="feature.key"
|
||||
role="checkbox"
|
||||
:aria-checked="isFeatureEnabled(feature.key)"
|
||||
:aria-disabled="!userPermissions[activePermissionCategory]"
|
||||
:tabindex="userPermissions[activePermissionCategory] ? 0 : -1"
|
||||
class="permission-feature-item"
|
||||
:class="{
|
||||
'is-enabled': isFeatureEnabled(feature.key),
|
||||
'is-disabled': !userPermissions[activePermissionCategory],
|
||||
}"
|
||||
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
>
|
||||
<VCardText class="d-flex align-center pa-4">
|
||||
<VAvatar
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
|
||||
size="40"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon :icon="option.icon" />
|
||||
</VAvatar>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
|
||||
{{ option.title }}
|
||||
<VIcon
|
||||
v-if="userPermissions[option.key as keyof UserPermissions]"
|
||||
icon="mdi-check-circle"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="ms-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCheckboxBtn
|
||||
:model-value="isFeatureEnabled(feature.key)"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
color="primary"
|
||||
density="compact"
|
||||
class="permission-feature-item__check"
|
||||
@click.stop
|
||||
@update:model-value="togglePermissionFeature(feature.key)"
|
||||
/>
|
||||
<span class="permission-feature-item__icon">
|
||||
<VIcon :icon="feature.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-feature-item__body">
|
||||
<span class="permission-feature-item__title">{{ feature.title }}</span>
|
||||
<span class="permission-feature-item__desc">{{ feature.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -642,3 +825,273 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-permission-editor {
|
||||
--permission-editor-radius: min(var(--app-surface-radius, 8px), 8px);
|
||||
--permission-editor-border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
--permission-editor-muted-border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
--permission-editor-panel-bg: rgb(var(--v-theme-surface));
|
||||
--permission-editor-hover-bg: rgba(var(--v-theme-primary), 0.04);
|
||||
--permission-editor-active-bg: rgba(var(--v-theme-primary), 0.1);
|
||||
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 0.08);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option,
|
||||
.permission-feature-item {
|
||||
border: var(--permission-editor-muted-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: var(--permission-editor-panel-bg);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
min-block-size: 5.75rem;
|
||||
padding: 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-category-option:hover,
|
||||
.permission-feature-item:hover:not(.is-disabled) {
|
||||
border-color: rgba(var(--v-theme-primary), 0.36);
|
||||
background: var(--permission-editor-hover-bg);
|
||||
}
|
||||
|
||||
.permission-category-option:focus-visible,
|
||||
.permission-feature-item:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.permission-category-option.is-active {
|
||||
border-color: rgba(var(--v-theme-primary), 0.72);
|
||||
background: var(--permission-editor-active-bg);
|
||||
}
|
||||
|
||||
.permission-category-option.is-enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.34);
|
||||
}
|
||||
|
||||
.permission-category-option.is-partial .permission-category-option__count {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
background: rgba(var(--v-theme-warning), 0.12);
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-feature-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.permission-category-option__icon {
|
||||
inline-size: 2.25rem;
|
||||
block-size: 2.25rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__body,
|
||||
.permission-feature-item__body {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title,
|
||||
.permission-feature-item__title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-category-option__count {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc,
|
||||
.permission-feature-item__scope {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-feature-panel {
|
||||
overflow: hidden;
|
||||
border: var(--permission-editor-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.permission-feature-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-block-end: var(--permission-editor-muted-border);
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.625rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
min-block-size: 4rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__icon {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__title,
|
||||
.permission-feature-item.is-disabled .permission-feature-item__desc {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
.permission-feature-item.is-enabled {
|
||||
background: var(--permission-editor-selected-bg);
|
||||
}
|
||||
|
||||
.permission-feature-item__check {
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permission-feature-item__scope {
|
||||
max-inline-size: 8rem;
|
||||
margin-inline-start: 1rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.permission-category-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-section-header,
|
||||
.permission-feature-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.user-permission-editor {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-block-size: 3.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-category-option__desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -728,7 +728,7 @@ async function batchScrape() {
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressText.value = progress.text_i18n || progress.text
|
||||
progressValue.value = progress.value
|
||||
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||
}
|
||||
|
||||
@@ -722,7 +722,7 @@ onMounted(() => {
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
@@ -732,10 +732,23 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
block-size: 56px;
|
||||
min-block-size: 56px;
|
||||
min-inline-size: 0;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.filter-btn-mobile :deep(.v-btn__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
@@ -744,20 +757,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.15;
|
||||
max-inline-size: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.filter-buttons-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
overflow: hidden;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
|
||||
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
|
||||
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
|
||||
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
@@ -33,6 +34,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
|
||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||
recentImports: () => import('@/views/dashboard/DashboardRecentImports.vue'),
|
||||
quickActions: () => import('@/views/dashboard/DashboardQuickActions.vue'),
|
||||
systemInfo: () => import('@/views/dashboard/DashboardSystemInfo.vue'),
|
||||
}
|
||||
|
||||
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
||||
@@ -65,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
|
||||
const AnalyticsStorage = createAsyncDashboardComponent('storage')
|
||||
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
|
||||
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
|
||||
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
|
||||
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
|
||||
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
|
||||
@@ -74,6 +79,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
|
||||
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
|
||||
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -196,6 +204,7 @@ onUnmounted(() => {
|
||||
<!-- 系统内置的仪表板 -->
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
@@ -205,6 +214,9 @@ onUnmounted(() => {
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||
<DashboardRecentImports v-else-if="config?.id === 'recentImports'" />
|
||||
<DashboardQuickActions v-else-if="config?.id === 'quickActions'" />
|
||||
<DashboardSystemInfo v-else-if="config?.id === 'systemInfo'" :allow-refresh="props.allowRefresh" />
|
||||
<!-- 插件仪表板 -->
|
||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
|
||||
@@ -8,15 +8,22 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
interface TmdbItem {
|
||||
// 媒体标题
|
||||
title: string
|
||||
// 媒体简介,包含类型标签
|
||||
overview: string
|
||||
// TMDB ID
|
||||
tmdbid: number
|
||||
// 豆瓣 ID
|
||||
doubanid: string
|
||||
// 海报地址
|
||||
poster: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
}
|
||||
|
||||
// update:modelValue 事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
const emit = defineEmits(['update:modelValue', 'select', 'close'])
|
||||
|
||||
const items = ref<TmdbItem[]>([])
|
||||
|
||||
@@ -29,9 +36,10 @@ const loading = ref(false)
|
||||
// ref
|
||||
const inputKeyword = ref<HTMLElement | null>(null)
|
||||
|
||||
// 选中条目
|
||||
// 选中条目并通知父组件同步额外媒体信息。
|
||||
function selectMedia(item: TmdbItem) {
|
||||
emit('update:modelValue', item.tmdbid || item.doubanid)
|
||||
emit('select', item)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -66,6 +74,7 @@ async function searchMedias() {
|
||||
tmdbid: item.tmdb_id || 0,
|
||||
doubanid: item.douban_id || '',
|
||||
poster: getW500Image(item.poster_path),
|
||||
type: item.type,
|
||||
title: `${item.title}(${item.year})`,
|
||||
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取容器宽度不可用时的兜底视口宽度。
|
||||
*/
|
||||
function getFallbackViewportWidth() {
|
||||
if (typeof window === 'undefined') {
|
||||
return itemStep.value * Math.max(props.overscanItems, 1)
|
||||
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
|
||||
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析虚拟列表项的稳定 key。
|
||||
*/
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动状态指示计时器。
|
||||
*/
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前滚动位置更新虚拟渲染范围。
|
||||
*/
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
@@ -113,6 +125,9 @@ function updateVisibleRange() {
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步左右导航按钮的可用状态。
|
||||
*/
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -130,11 +145,17 @@ function updateDisabledState() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步虚拟列表布局与导航状态。
|
||||
*/
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前可视范围向左或向右滚动一屏。
|
||||
*/
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内容滚动并刷新滚动指示状态。
|
||||
*/
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
@@ -257,28 +281,20 @@ watch(
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-left"
|
||||
color="white"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-right"
|
||||
color="white"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -402,17 +418,18 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 14%);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
background: rgba(8, 18, 28, 52%) !important;
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
|
||||
color: rgb(255, 255, 255);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inline-size: 40px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
@@ -421,21 +438,22 @@ watch(
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
:deep(.v-icon) {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
|
||||
font-size: 28px;
|
||||
opacity: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(255, 255, 255, 28%);
|
||||
background: rgba(8, 18, 28, 68%) !important;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
|
||||
color: rgb(255, 255, 255);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
:deep(.v-icon) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
themeCustomizerPrimaryColors,
|
||||
themeCustomizerShadowLevels,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerLayout,
|
||||
type ThemeCustomizerRadius,
|
||||
@@ -55,6 +56,7 @@ function clearThemeCustomizerOpenState() {
|
||||
syncThemeCustomizerOpenState(false)
|
||||
}
|
||||
|
||||
// 处理固定面板的全局 Esc 关闭快捷键。
|
||||
function handleGlobalKeydown(event: KeyboardEvent) {
|
||||
// 固定侧栏不再依赖 Vuetify overlay,手动补上常见的 Esc 关闭行为。
|
||||
if (event.key === 'Escape') emit('close')
|
||||
@@ -86,62 +88,35 @@ const skinOptions = computed<Array<{ title: string; value: ThemeCustomizerSkin }
|
||||
{ title: t('theme.customizer.skinBordered'), value: 'bordered' },
|
||||
])
|
||||
|
||||
const shadowOptions = computed<
|
||||
Array<{
|
||||
title: string
|
||||
value: ThemeCustomizerShadow
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
title: t('theme.customizer.shadowNone'),
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowLow'),
|
||||
value: 'low',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowMedium'),
|
||||
value: 'medium',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.shadowHigh'),
|
||||
value: 'high',
|
||||
},
|
||||
])
|
||||
// 当前阴影滑杆数值,界面使用 number,主题设置继续存储 Vuetify elevation 字符串档位。
|
||||
const shadowSliderValue = computed(() => Number(settings.value.shadow))
|
||||
|
||||
const radiusOptions = computed<
|
||||
Array<{
|
||||
previewRadius: string
|
||||
title: string
|
||||
value: ThemeCustomizerRadius
|
||||
}>
|
||||
>(() => [
|
||||
{
|
||||
previewRadius: '4px',
|
||||
title: t('theme.customizer.radiusNone'),
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
title: t('theme.customizer.radiusSmall'),
|
||||
value: 'small',
|
||||
},
|
||||
{
|
||||
previewRadius: '8px',
|
||||
title: t('theme.customizer.radiusDefault'),
|
||||
value: 'default',
|
||||
},
|
||||
{
|
||||
previewRadius: '12px',
|
||||
title: t('theme.customizer.radiusLarge'),
|
||||
value: 'large',
|
||||
},
|
||||
{
|
||||
previewRadius: '16px',
|
||||
title: t('theme.customizer.radiusExtra'),
|
||||
value: 'extra',
|
||||
},
|
||||
{
|
||||
previewRadius: '24px',
|
||||
title: t('theme.customizer.radiusHuge'),
|
||||
value: 'huge',
|
||||
},
|
||||
])
|
||||
|
||||
const layoutOptions = computed<Array<{ icon: string; title: string; value: ThemeCustomizerLayout }>>(() => [
|
||||
@@ -156,7 +131,7 @@ const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== 'none' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
settings.value.theme !== 'auto'
|
||||
)
|
||||
@@ -172,16 +147,19 @@ const showSemiDarkMenuOption = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 打开原生颜色选择器。
|
||||
function openColorPicker() {
|
||||
customColorInput.value?.click()
|
||||
}
|
||||
|
||||
// 处理原生颜色选择器的自定义主色输入。
|
||||
function handleCustomColorInput(event: Event) {
|
||||
const color = (event.target as HTMLInputElement).value
|
||||
|
||||
setPrimaryColor(color)
|
||||
}
|
||||
|
||||
// 切换布局类型,App 模式下保留移动端固定布局。
|
||||
function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||
// App 模式固定使用移动端导航,避免切换桌面布局后破坏底部导航体验。
|
||||
if (appMode.value) return
|
||||
@@ -189,6 +167,20 @@ function handleLayoutChange(layout: ThemeCustomizerLayout) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 重置主题定制设置,App 模式只恢复可调整的外观项。
|
||||
async function handleResetSettings() {
|
||||
if (!appMode.value) {
|
||||
await resetSettings()
|
||||
@@ -199,7 +191,7 @@ async function handleResetSettings() {
|
||||
// App 模式共享定制器,但保留桌面导航相关偏好,只重置 App 侧可调整的外观设置。
|
||||
await setPrimaryColor(defaultPrimaryColor)
|
||||
await setRadius('default')
|
||||
await setShadow('none')
|
||||
await setShadow('0')
|
||||
await setSkin('default')
|
||||
await setTheme('auto')
|
||||
}
|
||||
@@ -230,7 +222,7 @@ async function handleResetSettings() {
|
||||
|
||||
<VDivider />
|
||||
|
||||
<PerfectScrollbar class="theme-customizer-body" :options="{ wheelPropagation: false }">
|
||||
<div class="theme-customizer-body">
|
||||
<section class="theme-customizer-section">
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.primaryColor') }}</h3>
|
||||
<div class="theme-customizer-color-grid">
|
||||
@@ -290,14 +282,15 @@ async function handleResetSettings() {
|
||||
: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 />
|
||||
<span class="theme-customizer-border-scene" :class="`theme-customizer-border-scene--${skin.value}`">
|
||||
<span class="theme-customizer-border-scene__card">
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<span class="mini-content">
|
||||
<span class="theme-customizer-border-scene__dialog">
|
||||
<i />
|
||||
</span>
|
||||
<span class="theme-customizer-border-scene__menu">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
@@ -320,7 +313,7 @@ async function handleResetSettings() {
|
||||
>
|
||||
<span
|
||||
class="theme-customizer-radius-scene"
|
||||
:style="{ '--theme-customizer-radius-preview': radius.previewRadius }"
|
||||
:class="`theme-customizer-radius-scene--${radius.value}`"
|
||||
>
|
||||
<span class="theme-customizer-radius-scene__card">
|
||||
<span class="theme-customizer-radius-scene__badge" />
|
||||
@@ -335,29 +328,41 @@ async function handleResetSettings() {
|
||||
<VDivider class="mt-7" />
|
||||
|
||||
<h3 class="theme-customizer-section-title">{{ t('theme.customizer.shadow') }}</h3>
|
||||
<div class="theme-customizer-preview-grid theme-customizer-preview-grid--shadow">
|
||||
<div
|
||||
v-for="shadow in shadowOptions"
|
||||
:key="shadow.value"
|
||||
class="theme-customizer-preview-option"
|
||||
:class="{ 'is-active': settings.shadow === shadow.value }"
|
||||
@click="setShadow(shadow.value)"
|
||||
>
|
||||
<span class="theme-customizer-shadow-scene" :class="`theme-customizer-shadow-scene--${shadow.value}`">
|
||||
<span class="theme-customizer-shadow-scene__panel">
|
||||
<span class="theme-customizer-shadow-scene__panel-line" />
|
||||
<span
|
||||
class="theme-customizer-shadow-scene__panel-line theme-customizer-shadow-scene__panel-line--short"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="theme-customizer-shadow-scene__card">
|
||||
<span class="theme-customizer-shadow-scene__badge" />
|
||||
<span class="theme-customizer-shadow-scene__line theme-customizer-shadow-scene__line--short" />
|
||||
<span class="theme-customizer-shadow-scene__line" />
|
||||
</span>
|
||||
<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>
|
||||
<span>{{ shadow.title }}</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>
|
||||
|
||||
@@ -401,7 +406,7 @@ async function handleResetSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PerfectScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -439,11 +444,17 @@ async function handleResetSettings() {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
block-size: 100vh;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
@supports (block-size: 100dvh) {
|
||||
.theme-customizer-panel {
|
||||
block-size: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-panel--dialog {
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
@@ -488,18 +499,15 @@ async function handleResetSettings() {
|
||||
.theme-customizer-body {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.ps__rail-x),
|
||||
:deep(.ps__rail-y) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-section {
|
||||
@@ -626,10 +634,6 @@ async function handleResetSettings() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--shadow {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.theme-customizer-preview-grid--radius {
|
||||
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
|
||||
}
|
||||
@@ -646,8 +650,8 @@ async function handleResetSettings() {
|
||||
box-shadow: none !important;
|
||||
|
||||
.theme-customizer-mini-layout,
|
||||
.theme-customizer-radius-scene,
|
||||
.theme-customizer-shadow-scene {
|
||||
.theme-customizer-border-scene,
|
||||
.theme-customizer-radius-scene {
|
||||
border-width: 2px;
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.04);
|
||||
@@ -702,6 +706,72 @@ async function handleResetSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
.theme-customizer-border-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: 74px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 92px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card,
|
||||
.theme-customizer-border-scene__dialog,
|
||||
.theme-customizer-border-scene__menu {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0 solid rgba(var(--v-theme-on-surface), 0.14);
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
gap: 5px;
|
||||
padding: 7px;
|
||||
box-shadow: var(--app-elevation-0);
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card {
|
||||
block-size: 36px;
|
||||
inline-size: 46%;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-start: 10px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__dialog {
|
||||
block-size: 42px;
|
||||
inline-size: 38%;
|
||||
inset-block-start: 18px;
|
||||
inset-inline-end: 13px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__menu {
|
||||
block-size: 30px;
|
||||
inline-size: 30%;
|
||||
inset-block-end: 10px;
|
||||
inset-inline-start: 34%;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene__card i,
|
||||
.theme-customizer-border-scene__dialog i,
|
||||
.theme-customizer-border-scene__menu i {
|
||||
display: block;
|
||||
border-radius: 3px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.theme-customizer-border-scene--bordered {
|
||||
.theme-customizer-border-scene__card,
|
||||
.theme-customizer-border-scene__dialog,
|
||||
.theme-customizer-border-scene__menu {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-sidebar,
|
||||
.mini-content {
|
||||
display: flex;
|
||||
@@ -727,14 +797,6 @@ async function handleResetSettings() {
|
||||
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;
|
||||
@@ -747,6 +809,29 @@ async function handleResetSettings() {
|
||||
block-size: 90px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
|
||||
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded);
|
||||
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-lg);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--none {
|
||||
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-sm);
|
||||
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-sm);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--small {
|
||||
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded);
|
||||
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--large {
|
||||
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-lg);
|
||||
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-lg);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene--extra {
|
||||
--theme-customizer-preview-control-radius: var(--app-vuetify-rounded-xl);
|
||||
--theme-customizer-preview-surface-radius: var(--app-vuetify-rounded-xl);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__card {
|
||||
@@ -754,7 +839,7 @@ async function handleResetSettings() {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: var(--theme-customizer-radius-preview);
|
||||
border-radius: var(--theme-customizer-preview-surface-radius);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
gap: 8px;
|
||||
inset: 16px;
|
||||
@@ -765,17 +850,18 @@ async function handleResetSettings() {
|
||||
.theme-customizer-radius-scene__badge,
|
||||
.theme-customizer-radius-scene__line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__badge {
|
||||
border-radius: var(--theme-customizer-preview-control-radius);
|
||||
block-size: 8px;
|
||||
inline-size: 42%;
|
||||
min-inline-size: 28px;
|
||||
}
|
||||
|
||||
.theme-customizer-radius-scene__line {
|
||||
border-radius: var(--theme-customizer-preview-control-radius);
|
||||
block-size: 7px;
|
||||
}
|
||||
|
||||
@@ -783,112 +869,89 @@ async function handleResetSettings() {
|
||||
inline-size: 66%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene {
|
||||
position: relative;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
.theme-customizer-shadow-slider {
|
||||
padding: 16px 18px 12px;
|
||||
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.06)),
|
||||
rgb(var(--v-theme-surface));
|
||||
block-size: 110px;
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
border-radius: var(--app-vuetify-rounded-lg);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel,
|
||||
.theme-customizer-shadow-scene__card {
|
||||
position: absolute;
|
||||
.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));
|
||||
box-shadow: none;
|
||||
transition: box-shadow 0.18s ease;
|
||||
block-size: 42px;
|
||||
gap: 5px;
|
||||
inline-size: 42px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
inset-block-start: 16px;
|
||||
inset-inline: 14px;
|
||||
min-block-size: 54px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
gap: 8px;
|
||||
inset-block-end: 12px;
|
||||
inset-inline: 20px 16px;
|
||||
min-block-size: 46px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line,
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
.theme-customizer-shadow-slider__sample-accent,
|
||||
.theme-customizer-shadow-slider__sample-line {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__badge {
|
||||
block-size: 6px;
|
||||
inline-size: 34%;
|
||||
min-inline-size: 28px;
|
||||
.theme-customizer-shadow-slider__sample-accent {
|
||||
background: rgba(var(--v-theme-primary), 0.48);
|
||||
block-size: 5px;
|
||||
inline-size: 44%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line,
|
||||
.theme-customizer-shadow-scene__line {
|
||||
block-size: 7px;
|
||||
.theme-customizer-shadow-slider__sample-line {
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
block-size: 4px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__panel-line--short,
|
||||
.theme-customizer-shadow-scene__line--short {
|
||||
inline-size: 62%;
|
||||
.theme-customizer-shadow-slider__sample-line--short {
|
||||
inline-size: 68%;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--low {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 8px 18px rgba(var(--v-theme-on-surface), 0.08),
|
||||
0 2px 6px rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 10px 22px rgba(var(--v-theme-on-surface), 0.1),
|
||||
0 4px 10px rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
.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-scene--medium {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 12px 28px rgba(var(--v-theme-on-surface), 0.12),
|
||||
0 4px 12px rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 16px 34px rgba(var(--v-theme-on-surface), 0.14),
|
||||
0 6px 16px rgba(var(--v-theme-on-surface), 0.09);
|
||||
}
|
||||
.theme-customizer-shadow-slider :deep(.v-slider.v-input) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene--high {
|
||||
.theme-customizer-shadow-scene__panel {
|
||||
box-shadow:
|
||||
0 16px 38px rgba(var(--v-theme-on-surface), 0.16),
|
||||
0 6px 18px rgba(var(--v-theme-on-surface), 0.1);
|
||||
}
|
||||
|
||||
.theme-customizer-shadow-scene__card {
|
||||
box-shadow:
|
||||
0 22px 48px rgba(var(--v-theme-on-surface), 0.18),
|
||||
0 8px 22px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
.theme-customizer-shadow-slider :deep(.v-slider-track__tick) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
@@ -1,11 +1,26 @@
|
||||
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
type UseSSEOptions = Partial<SSEManagerOptions> & {
|
||||
connectDelay?: number
|
||||
}
|
||||
|
||||
/** 为 SSE 请求补充当前前端语言,弥补 EventSource 不能设置自定义请求头的问题。 */
|
||||
function appendLocaleParam(url: string) {
|
||||
const locale = getCurrentLocale()
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin)
|
||||
parsedUrl.searchParams.set('locale', locale)
|
||||
return parsedUrl.toString()
|
||||
} catch {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}locale=${encodeURIComponent(locale)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台任务组合函数
|
||||
* 统一管理SSE连接和定时器,减少后台常驻活动。
|
||||
@@ -180,12 +195,15 @@ export function useBackground() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
let managerUrl = ''
|
||||
const getManager = () => {
|
||||
managerUrl = appendLocaleParam(url)
|
||||
return sseManagerSingleton.getIndependentManager(managerUrl, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
}
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
@@ -207,8 +225,9 @@ export function useBackground() {
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(managerUrl || appendLocaleParam(url), listenerId)
|
||||
manager = null
|
||||
managerUrl = ''
|
||||
}
|
||||
|
||||
isListening = false
|
||||
|
||||
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
toValue,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
|
||||
interface DashboardMediaGridCapacityOptions {
|
||||
contentSelector?: string
|
||||
gap?: number
|
||||
horizontalPadding?: number
|
||||
maxCount?: number
|
||||
minItemWidth: number
|
||||
rows?: MaybeRefOrGetter<number>
|
||||
}
|
||||
|
||||
interface DashboardMediaGridCapacityState {
|
||||
columnCount: ComputedRef<number>
|
||||
containerRef: Ref<HTMLElement | null>
|
||||
itemCount: ComputedRef<number>
|
||||
refreshCapacity: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_GAP = 16
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_ROWS = 2
|
||||
|
||||
/**
|
||||
* 根据仪表盘媒体卡片容器宽度和响应式行数计算请求数量。
|
||||
*
|
||||
* @param options 网格尺寸参数,需要与实际 ProgressiveCardGrid 参数保持一致
|
||||
* @returns 容器引用、列数、请求数量和手动刷新方法
|
||||
*/
|
||||
export function useDashboardMediaGridCapacity(options: DashboardMediaGridCapacityOptions): DashboardMediaGridCapacityState {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const measuredWidth = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let frameId: number | null = null
|
||||
|
||||
const safeGap = computed(() => Math.max(0, options.gap ?? DEFAULT_DASHBOARD_MEDIA_GRID_GAP))
|
||||
const safeHorizontalPadding = computed(() => Math.max(0, options.horizontalPadding ?? 0))
|
||||
const safeMaxCount = computed(() => Math.max(0, Math.floor(options.maxCount ?? Number.POSITIVE_INFINITY)))
|
||||
const safeMinItemWidth = computed(() => Math.max(1, options.minItemWidth))
|
||||
const safeRows = computed(() => {
|
||||
const rows = options.rows === undefined ? DEFAULT_DASHBOARD_MEDIA_GRID_ROWS : toValue(options.rows)
|
||||
|
||||
return Math.max(1, Math.floor(rows))
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (measuredWidth.value <= 0) return 0
|
||||
|
||||
return Math.max(1, Math.floor((measuredWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
|
||||
})
|
||||
|
||||
const itemCount = computed(() => {
|
||||
if (columnCount.value <= 0) return 0
|
||||
|
||||
const count = columnCount.value * safeRows.value
|
||||
|
||||
return safeMaxCount.value > 0 ? Math.min(count, safeMaxCount.value) : count
|
||||
})
|
||||
|
||||
/**
|
||||
* 读取元素扣除内边距后的内容宽度。
|
||||
*
|
||||
* @param element 需要测量的元素
|
||||
*/
|
||||
function measureContentBoxWidth(element: HTMLElement) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
|
||||
const paddingRight = Number.parseFloat(style.paddingRight) || 0
|
||||
|
||||
return Math.max(0, element.clientWidth - paddingLeft - paddingRight)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取容器可用于网格布局的实际宽度。
|
||||
*/
|
||||
function measureContainerWidth() {
|
||||
const element = containerRef.value
|
||||
|
||||
if (!element) return 0
|
||||
|
||||
const contentElement = options.contentSelector
|
||||
? element.querySelector<HTMLElement>(options.contentSelector)
|
||||
: null
|
||||
if (contentElement) {
|
||||
return measureContentBoxWidth(contentElement)
|
||||
}
|
||||
|
||||
const elementWidth = element.getBoundingClientRect().width || element.clientWidth
|
||||
|
||||
return Math.max(0, elementWidth - safeHorizontalPadding.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步当前容器宽度到响应式状态。
|
||||
*/
|
||||
function refreshCapacity() {
|
||||
measuredWidth.value = measureContainerWidth()
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并同一帧内的尺寸变更,避免拖拽调整仪表盘时重复计算。
|
||||
*/
|
||||
function queueRefreshCapacity() {
|
||||
if (typeof window === 'undefined' || frameId !== null) return
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null
|
||||
refreshCapacity()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新绑定当前容器的 ResizeObserver。
|
||||
*/
|
||||
function observeContainer() {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(queueRefreshCapacity)
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
queueRefreshCapacity()
|
||||
}
|
||||
|
||||
watch(containerRef, observeContainer, { flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
observeContainer()
|
||||
window.addEventListener('resize', queueRefreshCapacity, { passive: true })
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', queueRefreshCapacity)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
containerRef,
|
||||
itemCount,
|
||||
refreshCapacity,
|
||||
}
|
||||
}
|
||||
41
src/composables/useDashboardMotion.ts
Normal file
41
src/composables/useDashboardMotion.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TransitionPresets, usePreferredReducedMotion, useTransition, type UseTransitionOptions } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const fileSizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
|
||||
export function useDashboardMotionDisabled() {
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
|
||||
return computed(() => preferredMotion.value === 'reduce')
|
||||
}
|
||||
|
||||
export function useAnimatedDashboardNumber(source: MaybeRefOrGetter<number>, options: UseTransitionOptions = {}) {
|
||||
const disabled = useDashboardMotionDisabled()
|
||||
|
||||
return useTransition(source, {
|
||||
duration: 420,
|
||||
transition: TransitionPresets.easeOutQuart,
|
||||
disabled,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDashboardCount(value: number) {
|
||||
return Math.round(Math.max(Number(value) || 0, 0)).toLocaleString()
|
||||
}
|
||||
|
||||
export function formatDashboardFileSize(bytes: number, decimals = 2, targetBytes = bytes) {
|
||||
let size = Math.abs(Number(targetBytes) || Number(bytes) || 0)
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < fileSizeUnits.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
const divisor = 1024 ** unitIndex
|
||||
const value = (Math.abs(Number(bytes) || 0) / divisor).toFixed(decimals)
|
||||
const prefix = bytes < 0 ? '-' : ''
|
||||
|
||||
return `${prefix}${value} ${fileSizeUnits[unitIndex]}`
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
@@ -31,6 +31,8 @@ export interface DynamicButtonMenuItem {
|
||||
icon?: string
|
||||
color?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
disabled?: boolean
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@@ -60,11 +62,12 @@ export function useDynamicButton(options: {
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, permission, feature, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -78,6 +81,7 @@ export function useDynamicButton(options: {
|
||||
const resolvedShow = computed(() => resolveMaybeRef(show, true))
|
||||
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
|
||||
|
||||
/** 根据当前响应式配置生成可注册的动态按钮对象。 */
|
||||
function buildDynamicButton() {
|
||||
const buttonMenuItems = resolvedMenuItems.value
|
||||
|
||||
@@ -85,12 +89,13 @@ export function useDynamicButton(options: {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
permission,
|
||||
feature,
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态按钮
|
||||
/** 在当前页面激活时注册动态按钮。 */
|
||||
function setupDynamicButton() {
|
||||
if (!componentActive.value) return
|
||||
|
||||
@@ -132,7 +137,7 @@ export function useDynamicButton(options: {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册动态按钮
|
||||
/** 清理当前页面注册过的动态按钮。 */
|
||||
function cleanupDynamicButton() {
|
||||
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
|
||||
unregisterDynamicButton()
|
||||
@@ -146,7 +151,7 @@ export function useDynamicButton(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法:手动打开对话框
|
||||
/** 手动触发动态按钮主操作。 */
|
||||
function openDialog() {
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 动态标签页相关类型
|
||||
interface DynamicHeaderTabButton {
|
||||
@@ -11,6 +11,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
@@ -20,6 +21,8 @@ interface DynamicHeaderTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
interface DynamicHeaderTabConfig {
|
||||
@@ -30,6 +33,7 @@ interface DynamicHeaderTabConfig {
|
||||
onUpdateModelValue?: (value: string) => void
|
||||
}
|
||||
|
||||
/** 提供页面动态头部标签的注册、状态恢复和注销能力。 */
|
||||
export function useDynamicHeaderTab() {
|
||||
const route = useRoute()
|
||||
|
||||
@@ -37,7 +41,7 @@ export function useDynamicHeaderTab() {
|
||||
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
|
||||
|
||||
// 注册动态标签页
|
||||
/** 注册当前页面的动态头部标签配置。 */
|
||||
const registerHeaderTab = (config: {
|
||||
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
|
||||
modelValue: Ref<string>
|
||||
@@ -184,7 +188,7 @@ export function useDynamicHeaderTab() {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
/** 取消当前页面注册的动态头部标签。 */
|
||||
const unregisterHeaderTab = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
|
||||
503
src/composables/useMediaSubscribe.ts
Normal file
503
src/composables/useMediaSubscribe.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import { defineAsyncComponent, ref, type Ref } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
||||
|
||||
export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
interface SubscribePayload {
|
||||
best_version?: number
|
||||
best_version_full?: number
|
||||
}
|
||||
|
||||
interface SubscribeConfig {
|
||||
show_edit_dialog?: boolean
|
||||
best_version?: unknown
|
||||
best_version_full?: unknown
|
||||
}
|
||||
|
||||
interface AddSubscribeOptions {
|
||||
openEditDialog?: boolean
|
||||
}
|
||||
|
||||
interface RemoveSubscribeOptions {
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
interface UseMediaSubscribeOptions {
|
||||
media: () => MediaInfo | undefined
|
||||
canSubscribe: () => boolean
|
||||
isSubscribed?: Ref<boolean>
|
||||
isExists?: () => boolean
|
||||
seasonsSubscribed?: Ref<{ [key: number]: boolean }>
|
||||
subscribedSeasons?: Ref<number[]>
|
||||
subscribedSeasonModes?: Ref<SeasonSubscribeModes>
|
||||
primarySeason?: () => number | null
|
||||
getSubscribeStatusKey?: (season: number | null) => string
|
||||
onEditRemove?: () => void
|
||||
}
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeModeDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeModeDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
||||
|
||||
// 生成跨媒体源稳定的订阅媒体标识。
|
||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
||||
if (media?.douban_id) return `douban:${media.douban_id}`
|
||||
if (media?.bangumi_id) return `bangumi:${media.bangumi_id}`
|
||||
return `${media?.mediaid_prefix}:${media?.media_id}`
|
||||
}
|
||||
|
||||
// 将订阅模式转换为后端订阅字段。
|
||||
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
|
||||
return {
|
||||
best_version: mode === 'normal' ? 0 : 1,
|
||||
best_version_full: mode === 'best_version_full' ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容布尔值和数字、字符串形式的开关值。
|
||||
function isEnabledFlag(value: unknown) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 从订阅字段解析统一的订阅模式。
|
||||
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
|
||||
if (!isEnabledFlag(subscribe.best_version)) return 'normal'
|
||||
|
||||
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
|
||||
}
|
||||
|
||||
// 从默认订阅配置解析订阅模式。
|
||||
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
|
||||
return getSubscribeMode({
|
||||
best_version: config?.best_version,
|
||||
best_version_full: config?.best_version_full,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取订阅模式的本地化名称。
|
||||
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
|
||||
if (mode === 'normal') return t('dialog.subscribeMode.normal')
|
||||
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
|
||||
return t('dialog.subscribeMode.bestVersionFull')
|
||||
}
|
||||
|
||||
// 封装媒体卡片与详情页共用的订阅交互。
|
||||
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 获取调用方当前媒体,避免在异步流程中持有旧对象。
|
||||
function currentMedia() {
|
||||
return options.media()
|
||||
}
|
||||
|
||||
// 获取当前媒体的统一订阅标识。
|
||||
function getMediaId() {
|
||||
return getMediaSubscribeId(currentMedia())
|
||||
}
|
||||
|
||||
// 获取主订阅入口默认对应的季号。
|
||||
function getPrimarySeason() {
|
||||
return options.primarySeason?.() ?? currentMedia()?.season ?? null
|
||||
}
|
||||
|
||||
// 同步调用方状态和订阅状态缓存。
|
||||
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
|
||||
const media = currentMedia()
|
||||
|
||||
if (media?.type === '电影' || season === null) {
|
||||
if (options.isSubscribed) options.isSubscribed.value = subscribed
|
||||
} else {
|
||||
if (options.seasonsSubscribed) options.seasonsSubscribed.value[season] = subscribed
|
||||
else if (options.subscribedSeasons) {
|
||||
const nextSeasons = new Set(options.subscribedSeasons.value)
|
||||
if (subscribed) nextSeasons.add(season)
|
||||
else nextSeasons.delete(season)
|
||||
options.subscribedSeasons.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
if (options.isSubscribed) {
|
||||
if (subscribed) {
|
||||
options.isSubscribed.value = true
|
||||
} else if (options.seasonsSubscribed) {
|
||||
options.isSubscribed.value = Object.values(options.seasonsSubscribed.value).some(Boolean)
|
||||
} else if (options.subscribedSeasons) {
|
||||
options.isSubscribed.value = options.subscribedSeasons.value.length > 0
|
||||
} else {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (options.subscribedSeasonModes) {
|
||||
const nextModes = { ...options.subscribedSeasonModes.value }
|
||||
if (subscribed) nextModes[season] = mode
|
||||
else delete nextModes[season]
|
||||
options.subscribedSeasonModes.value = nextModes
|
||||
}
|
||||
}
|
||||
|
||||
if (options.getSubscribeStatusKey) {
|
||||
setCachedMediaSubscribeStatus(options.getSubscribeStatusKey(season), subscribed)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开已创建订阅的编辑弹窗。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: () => {
|
||||
if (options.onEditRemove) {
|
||||
options.onEditRemove()
|
||||
} else if (options.isSubscribed) {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅模式选择弹窗并转换选择结果。
|
||||
function openSubscribeModeDialog(
|
||||
modes: SubscribeMode[],
|
||||
choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
|
||||
) {
|
||||
openSharedDialog(
|
||||
SubscribeModeDialog,
|
||||
{ modes, type: currentMedia()?.type },
|
||||
{
|
||||
choose: (mode: SubscribeMode) => choose(getSubscribePayload(mode), mode),
|
||||
},
|
||||
{ closeOn: ['close', 'choose'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开季订阅弹窗,并保留发起入口当前使用的剧集组。
|
||||
async function openSubscribeSeasonDialog(selectedSeason?: number | null, initialEpisodeGroup = '') {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
|
||||
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{
|
||||
media,
|
||||
selectedSeason,
|
||||
initialEpisodeGroup,
|
||||
subscribedSeasons: options.subscribedSeasons?.value ?? [],
|
||||
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
|
||||
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
|
||||
},
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 查询系统默认订阅配置。
|
||||
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
||||
if (!options.canSubscribe()) return undefined
|
||||
|
||||
try {
|
||||
const media = currentMedia()
|
||||
const subscribeConfigUrl =
|
||||
media?.type === '电影'
|
||||
? 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
: 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
||||
|
||||
return result.data?.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 展示订阅新增结果通知。
|
||||
function showSubscribeAddToast(
|
||||
result: boolean,
|
||||
title: string,
|
||||
season: number | null,
|
||||
message: string,
|
||||
bestVersion: number,
|
||||
) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
const subname = bestVersion > 0 ? t('subscribe.versionSub') : t('subscribe.normalSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
|
||||
}
|
||||
|
||||
// 创建指定季和模式的订阅。
|
||||
async function addSubscribe(
|
||||
season: number | null = null,
|
||||
payload: SubscribePayload = {},
|
||||
addOptions: AddSubscribeOptions = {},
|
||||
) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: media.title,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
tmdbid: media.tmdb_id,
|
||||
doubanid: media.douban_id,
|
||||
bangumiid: media.bangumi_id,
|
||||
mediaid: media.media_id ? `${media.mediaid_prefix}:${media.media_id}` : '',
|
||||
season: media.type === '电影' ? null : season,
|
||||
...payload,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
|
||||
|
||||
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
|
||||
|
||||
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
||||
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除指定季的订阅。
|
||||
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
|
||||
if (removeOptions.confirm ?? true) {
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('dialog.subscribeEdit.cancelSubscribeConfirm'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season: media.type === '电影' ? null : season,
|
||||
},
|
||||
})
|
||||
let title = media.title ?? ''
|
||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前媒体指定季是否已订阅。
|
||||
async function checkSubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return false
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体指定季的订阅记录。
|
||||
async function querySubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return result.id ? result : null
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return null
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已有单季订阅的模式。
|
||||
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const subscribe = await querySubscribe(season)
|
||||
if (!subscribe?.id) {
|
||||
$toast.error(`${media.title ?? ''} ${formatSeason(season.toString())} ${t('subscribe.notFound')}`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = getSubscribePayload(mode)
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', {
|
||||
...subscribe,
|
||||
...payload,
|
||||
})
|
||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(season, true, mode)
|
||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理单季订阅入口,未订阅时将当前剧集组带入季选择弹窗。
|
||||
function handleSeasonSubscribe(season: number, initialEpisodeGroup = '') {
|
||||
if (options.seasonsSubscribed?.value[season]) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
openSubscribeSeasonDialog(season, initialEpisodeGroup)
|
||||
}
|
||||
|
||||
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
|
||||
function handlePrimarySubscribe() {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
const season = media.type === '电影' ? null : getPrimarySeason()
|
||||
|
||||
if (media.type === '电视剧') {
|
||||
openSubscribeSeasonDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isSubscribed?.value) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isExists?.()) {
|
||||
openSubscribeModeDialog(['normal', 'best_version'], payload => addSubscribe(null, payload))
|
||||
return
|
||||
}
|
||||
|
||||
addSubscribe(null)
|
||||
}
|
||||
|
||||
// 根据是否指定季号分发主订阅或单季订阅操作。
|
||||
function handleSubscribe(season?: number | null, initialEpisodeGroup = '') {
|
||||
if (season !== undefined && season !== null) {
|
||||
handleSeasonSubscribe(season, initialEpisodeGroup)
|
||||
return
|
||||
}
|
||||
|
||||
handlePrimarySubscribe()
|
||||
}
|
||||
|
||||
// 批量对齐弹窗中选择的季、订阅模式和当前订阅状态。
|
||||
function subscribeSeasons(
|
||||
seasons: MediaSeason[] = [],
|
||||
seasonExistsStates: { [key: number]: number } = {},
|
||||
groupId = '',
|
||||
seasonModes: SubscribeMode | SeasonSubscribeModes = 'normal',
|
||||
visibleSeasonNumbers: number[] = [],
|
||||
) {
|
||||
episodeGroup.value = groupId
|
||||
const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? [])
|
||||
const selectedSeasonSet = new Set(
|
||||
seasons.map(season => season.season_number).filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
const visibleSeasonSet = new Set(visibleSeasonNumbers)
|
||||
const seasonsToSubscribe = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
return seasonNumber !== null && !subscribedSeasonSet.has(seasonNumber)
|
||||
})
|
||||
const seasonsToUnsubscribe = [...subscribedSeasonSet].filter(
|
||||
season => visibleSeasonSet.has(season) && !selectedSeasonSet.has(season),
|
||||
)
|
||||
const seasonsToUpdateMode = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false
|
||||
|
||||
const nextMode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode
|
||||
})
|
||||
|
||||
seasonsToUnsubscribe.forEach(season => {
|
||||
removeSubscribe(season, { confirm: false })
|
||||
})
|
||||
|
||||
seasonsToUpdateMode.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
updateSubscribeMode(seasonNumber, mode)
|
||||
})
|
||||
|
||||
seasonsToSubscribe.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
const payload = getSubscribePayload(mode)
|
||||
addSubscribe(
|
||||
seasonNumber,
|
||||
payload,
|
||||
{
|
||||
openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
addSubscribe,
|
||||
checkSubscribe,
|
||||
handleSubscribe,
|
||||
openSubscribeSeasonDialog,
|
||||
removeSubscribe,
|
||||
subscribeSeasons,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,82 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
// 全局状态
|
||||
const isAppOffline = ref(false)
|
||||
const appOfflineReason = ref('')
|
||||
const consecutiveNetworkErrors = ref(0)
|
||||
const MAX_CONSECUTIVE_ERRORS = 3
|
||||
export type ConnectionStatus = 'online' | 'checking' | 'offline'
|
||||
export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
|
||||
|
||||
// 全局离线状态管理
|
||||
const browserOnline = useOnline()
|
||||
const connectionStatus = ref<ConnectionStatus>('online')
|
||||
const connectionReason = ref<ConnectionFailureReason | null>(null)
|
||||
const connectionCheckRequestId = ref(0)
|
||||
const serverSuccessSequence = ref(0)
|
||||
|
||||
/** 管理 MoviePilot 服务的全局连接状态。 */
|
||||
export function useGlobalOfflineStatus() {
|
||||
const isOnline = useOnline()
|
||||
const isOnline = computed(() => connectionStatus.value === 'online')
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const isOffline = computed(() => connectionStatus.value === 'offline')
|
||||
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
|
||||
|
||||
// 综合离线状态(网络离线 或 应用离线)
|
||||
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
|
||||
|
||||
// 是否可以执行网络操作
|
||||
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
|
||||
|
||||
// 设置应用离线状态
|
||||
const setAppOffline = (offline: boolean, reason?: string) => {
|
||||
isAppOffline.value = offline
|
||||
appOfflineReason.value = reason || ''
|
||||
|
||||
// 如果设置为在线状态,重置连续错误计数
|
||||
if (!offline) {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
}
|
||||
/** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
|
||||
function markServerOnline() {
|
||||
connectionStatus.value = 'online'
|
||||
connectionReason.value = null
|
||||
serverSuccessSequence.value += 1
|
||||
}
|
||||
|
||||
// 记录网络错误
|
||||
const recordNetworkError = (reason?: string) => {
|
||||
consecutiveNetworkErrors.value++
|
||||
|
||||
// 只有连续出现三次网络错误时才设置为离线模式
|
||||
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
|
||||
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
|
||||
}
|
||||
/** 将连接状态标记为待确认,但不直接阻断页面操作。 */
|
||||
function markConnectionChecking(reason?: ConnectionFailureReason) {
|
||||
connectionStatus.value = 'checking'
|
||||
if (reason) connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 重置连续错误计数
|
||||
const resetConsecutiveErrors = () => {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
/** 在权威探测失败后标记 MoviePilot 服务不可达。 */
|
||||
function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
|
||||
connectionStatus.value = 'offline'
|
||||
connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 获取离线消息
|
||||
const getOfflineMessage = () => {
|
||||
if (!isOnline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
if (isAppOffline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
return ''
|
||||
/** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
|
||||
function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
/** 请求立即检查 MoviePilot 服务连接。 */
|
||||
function requestConnectionCheck(reason?: ConnectionFailureReason) {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
browserOnline,
|
||||
connectionStatus,
|
||||
connectionReason,
|
||||
connectionCheckRequestId,
|
||||
serverSuccessSequence,
|
||||
isOnline,
|
||||
isChecking,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
setAppOffline,
|
||||
recordNetworkError,
|
||||
resetConsecutiveErrors,
|
||||
getOfflineMessage,
|
||||
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
|
||||
markServerOnline,
|
||||
markConnectionChecking,
|
||||
markServerOffline,
|
||||
reportNetworkError,
|
||||
requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// 单个组件的离线状态
|
||||
export function useOfflineStatus(initialMessage?: string) {
|
||||
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
const message = computed(() => {
|
||||
if (initialMessage) {
|
||||
return initialMessage
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
/** 为单个组件提供 MoviePilot 服务连接状态。 */
|
||||
export function useOfflineStatus() {
|
||||
const status = useGlobalOfflineStatus()
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
message,
|
||||
browserOnline: status.browserOnline,
|
||||
isOnline: status.isOnline,
|
||||
isChecking: status.isChecking,
|
||||
isOffline: status.isOffline,
|
||||
canPerformNetworkAction: status.canPerformNetworkAction,
|
||||
connectionReason: status.connectionReason,
|
||||
requestConnectionCheck: status.requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
114
src/composables/useScheduleProgress.ts
Normal file
114
src/composables/useScheduleProgress.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const SCHEDULE_PROGRESS_REFRESH_INTERVAL = 1000
|
||||
const SCHEDULE_RUNNING_STATUS = '正在运行'
|
||||
const SCHEDULE_WAITING_STATUS = '等待'
|
||||
|
||||
/** 判断定时服务是否仍在运行,兼容列表状态与进度详情两种后端信号。 */
|
||||
export function isScheduleRunning(schedule: ScheduleInfo) {
|
||||
return (
|
||||
schedule.status === SCHEDULE_RUNNING_STATUS ||
|
||||
schedule.progress_enable === true ||
|
||||
schedule.progress_detail?.enable === true ||
|
||||
schedule.progress_detail?.status === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断定时服务是否处于等待执行状态。 */
|
||||
export function isScheduleWaiting(schedule: ScheduleInfo) {
|
||||
return schedule.status === SCHEDULE_WAITING_STATUS
|
||||
}
|
||||
|
||||
/** 获取定时服务展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleName(schedule: ScheduleInfo) {
|
||||
return schedule.name_i18n || schedule.name || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务提供者展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleProvider(schedule: ScheduleInfo) {
|
||||
return schedule.provider_i18n || schedule.provider || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务状态展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleStatusText(schedule: ScheduleInfo) {
|
||||
return schedule.status_i18n || schedule.status || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务下次运行展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleNextRunText(schedule: ScheduleInfo) {
|
||||
return schedule.next_run_i18n || schedule.next_run || ''
|
||||
}
|
||||
|
||||
/** 为定时服务列表提供仅针对运行中任务的实时进度轮询。 */
|
||||
export function useScheduleProgress(schedules: Ref<ScheduleInfo[]>, refreshId: string) {
|
||||
const { useDataRefresh } = useBackground()
|
||||
const progressById = ref<Record<string, ScheduleProgress>>({})
|
||||
|
||||
/** 请求指定运行中任务的最新进度。 */
|
||||
async function loadScheduleProgress(schedule: ScheduleInfo) {
|
||||
const response = (await api.get(
|
||||
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
|
||||
)) as ApiResponse<ScheduleProgress>
|
||||
|
||||
return response.success ? response.data : undefined
|
||||
}
|
||||
|
||||
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
|
||||
async function refreshRunningProgress() {
|
||||
const runningSchedules = schedules.value.filter(
|
||||
schedule => schedule.id && isScheduleRunning(schedule),
|
||||
)
|
||||
const runningIds = new Set(runningSchedules.map(schedule => schedule.id))
|
||||
const nextProgress = Object.fromEntries(
|
||||
Object.entries(progressById.value).filter(([id]) => runningIds.has(id)),
|
||||
)
|
||||
|
||||
if (!runningSchedules.length) {
|
||||
progressById.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(runningSchedules.map(loadScheduleProgress))
|
||||
const currentRunningIds = new Set(
|
||||
schedules.value
|
||||
.filter(schedule => schedule.id && isScheduleRunning(schedule))
|
||||
.map(schedule => schedule.id),
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const schedule = runningSchedules[index]
|
||||
if (result.status === 'fulfilled' && result.value && currentRunningIds.has(schedule.id)) {
|
||||
nextProgress[schedule.id] = result.value
|
||||
}
|
||||
})
|
||||
|
||||
progressById.value = Object.fromEntries(
|
||||
Object.entries(nextProgress).filter(([id]) => currentRunningIds.has(id)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的百分比,并限制在合法范围内。 */
|
||||
function getScheduleProgressValue(schedule: ScheduleInfo) {
|
||||
const value = Number(progressById.value[schedule.id]?.value ?? schedule.progress ?? 0)
|
||||
|
||||
return Math.min(Math.max(value, 0), 100)
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的进度说明。 */
|
||||
function getScheduleProgressText(schedule: ScheduleInfo) {
|
||||
const progress = progressById.value[schedule.id]
|
||||
|
||||
return progress?.text_i18n || progress?.text || schedule.progress_text_i18n || schedule.progress_text || ''
|
||||
}
|
||||
|
||||
useDataRefresh(refreshId, refreshRunningProgress, SCHEDULE_PROGRESS_REFRESH_INTERVAL, true)
|
||||
|
||||
return {
|
||||
getScheduleProgressText,
|
||||
getScheduleProgressValue,
|
||||
refreshRunningProgress,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export interface WizardData {
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
userAgent: string
|
||||
temperature: number
|
||||
audioInputProvider: string
|
||||
audioInputApiKey: string
|
||||
audioInputBaseUrl: string
|
||||
@@ -253,6 +254,7 @@ const wizardData = ref<WizardData>({
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
userAgent: '',
|
||||
temperature: 0.3,
|
||||
audioInputProvider: 'openai',
|
||||
audioInputApiKey: '',
|
||||
audioInputBaseUrl: '',
|
||||
@@ -1436,6 +1438,7 @@ export function useSetupWizard() {
|
||||
// 保存智能助手设置
|
||||
async function saveAgentSettings() {
|
||||
try {
|
||||
const agentTemperature = Number(wizardData.value.agent.temperature ?? 0.3)
|
||||
const agentSettings = {
|
||||
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
|
||||
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
|
||||
@@ -1452,6 +1455,7 @@ export function useSetupWizard() {
|
||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
|
||||
LLM_TEMPERATURE: Number.isFinite(agentTemperature) ? agentTemperature : 0.3,
|
||||
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
||||
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
||||
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
||||
@@ -1567,6 +1571,8 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||
const agentTemperature = Number(result.data.LLM_TEMPERATURE ?? 0.3)
|
||||
wizardData.value.agent.temperature = Number.isFinite(agentTemperature) ? agentTemperature : 0.3
|
||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||
|
||||
157
src/composables/useShortcutTools.ts
Normal file
157
src/composables/useShortcutTools.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Component } from 'vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
hasItemPermission,
|
||||
type PermissionProtectedItem,
|
||||
} from '@/utils/permission'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
||||
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
||||
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
||||
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。
|
||||
export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box'
|
||||
|
||||
export type ShortcutToolItem = PermissionProtectedItem & {
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
component?: Component
|
||||
customDialog?: Component
|
||||
dialog: string
|
||||
dialogSubtitle?: string
|
||||
icon: string
|
||||
maxWidth?: string
|
||||
subtitle: string
|
||||
title: string
|
||||
titleText?: string
|
||||
}
|
||||
|
||||
/** 提供顶部捷径与仪表板共用的工具定义和打开逻辑。 */
|
||||
export function useShortcutTools() {
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
const shortcuts: ShortcutToolItem[] = [
|
||||
{
|
||||
title: t('shortcut.recognition.title'),
|
||||
subtitle: t('shortcut.recognition.subtitle'),
|
||||
icon: 'mdi-text-recognition',
|
||||
dialog: 'nameTest',
|
||||
component: NameTestView,
|
||||
maxWidth: '45rem',
|
||||
titleText: t('shortcut.recognition.title'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.rule.title'),
|
||||
subtitle: t('shortcut.rule.subtitle'),
|
||||
icon: 'mdi-filter-cog',
|
||||
dialog: 'ruleTest',
|
||||
component: RuleTestView,
|
||||
titleText: t('shortcut.rule.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.log.title'),
|
||||
subtitle: t('shortcut.log.subtitle'),
|
||||
icon: 'mdi-file-document',
|
||||
dialog: 'logging',
|
||||
customDialog: ShortcutLogDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.network.title'),
|
||||
subtitle: t('shortcut.network.subtitle'),
|
||||
icon: 'mdi-network',
|
||||
dialog: 'netTest',
|
||||
component: NetTestView,
|
||||
titleText: t('shortcut.network.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.words.title'),
|
||||
subtitle: t('shortcut.words.subtitle'),
|
||||
icon: 'mdi-file-word-box',
|
||||
dialog: 'words',
|
||||
bodyClass: 'words-shortcut-dialog-body pa-0',
|
||||
cardClass: 'words-shortcut-dialog-card',
|
||||
component: WordsView,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.words.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.cache.title'),
|
||||
subtitle: t('shortcut.cache.subtitle'),
|
||||
icon: 'mdi-database',
|
||||
dialog: 'cache',
|
||||
bodyClass: 'cache-shortcut-dialog-body',
|
||||
cardClass: 'cache-shortcut-dialog-card',
|
||||
component: CacheView,
|
||||
maxWidth: '90rem',
|
||||
titleText: t('shortcut.cache.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.scheduler.title'),
|
||||
subtitle: t('shortcut.scheduler.subtitle'),
|
||||
icon: SCHEDULER_SHORTCUT_ICON,
|
||||
dialog: 'scheduler',
|
||||
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
|
||||
cardClass: 'scheduler-shortcut-dialog-card',
|
||||
component: AccountSettingService,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.scheduler.subtitle'),
|
||||
dialogSubtitle: t('setting.scheduler.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
icon: 'mdi-cog',
|
||||
dialog: 'systemTest',
|
||||
bodyClass: 'system-health-dialog-body pa-0',
|
||||
cardClass: 'system-health-dialog-card',
|
||||
component: ModuleTestView,
|
||||
titleText: t('shortcut.system.subtitle'),
|
||||
},
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 打开工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: ShortcutToolItem) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
if (item.customDialog) {
|
||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.component) return
|
||||
|
||||
openSharedDialog(
|
||||
ShortcutToolDialog,
|
||||
{
|
||||
bodyClass: item.bodyClass,
|
||||
cardClass: item.cardClass,
|
||||
icon: item.icon,
|
||||
maxWidth: item.maxWidth ?? '35rem',
|
||||
subtitle: item.dialogSubtitle,
|
||||
title: item.titleText ?? item.title,
|
||||
view: item.component,
|
||||
},
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
openShortcutDialog,
|
||||
visibleShortcuts,
|
||||
}
|
||||
}
|
||||
@@ -24,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'
|
||||
|
||||
@@ -44,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
|
||||
|
||||
@@ -73,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,
|
||||
@@ -247,7 +289,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
@@ -324,7 +366,7 @@ export function useThemeCustomizer() {
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
semiDarkMenu: false,
|
||||
shadow: 'none',
|
||||
shadow: '0',
|
||||
skin: 'default',
|
||||
theme: 'auto',
|
||||
})
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export type TransparencyGlassQuality = 'lightweight' | 'realtime'
|
||||
|
||||
export interface TransparencySettings {
|
||||
backgroundBlur: number
|
||||
backgroundPosterOpacity: number
|
||||
blur: number
|
||||
glassQuality: TransparencyGlassQuality
|
||||
level: string
|
||||
opacity: number
|
||||
}
|
||||
|
||||
export const transparencyPresets = {
|
||||
low: { opacity: 0.1, blur: 5 },
|
||||
low: { opacity: 0.6, blur: 5 },
|
||||
medium: { opacity: 0.3, blur: 10 },
|
||||
high: { opacity: 0.6, blur: 15 },
|
||||
high: { opacity: 0.1, blur: 15 },
|
||||
}
|
||||
|
||||
export const TRANSPARENCY_SETTINGS_CHANGED_EVENT = 'transparency-settings-changed'
|
||||
|
||||
/** 将数值限制在指定范围内。 */
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
/** 读取玻璃质量档位,未知值回落到默认的轻量玻璃路径。 */
|
||||
function readGlassQuality(): TransparencyGlassQuality {
|
||||
const storedQuality = localStorage.getItem('transparency-glass-quality')
|
||||
|
||||
return storedQuality === 'realtime' ? 'realtime' : 'lightweight'
|
||||
}
|
||||
|
||||
/** 从本地存储读取透明主题设置。 */
|
||||
export function readTransparencySettings(): TransparencySettings {
|
||||
return {
|
||||
@@ -26,6 +38,7 @@ export function readTransparencySettings(): TransparencySettings {
|
||||
blur: parseFloat(localStorage.getItem('transparency-blur') || '10'),
|
||||
backgroundPosterOpacity: parseFloat(localStorage.getItem('transparency-background-poster-opacity') || '0'),
|
||||
backgroundBlur: parseFloat(localStorage.getItem('transparency-background-blur') || '16'),
|
||||
glassQuality: readGlassQuality(),
|
||||
level: localStorage.getItem('transparency-level') || 'medium',
|
||||
}
|
||||
}
|
||||
@@ -39,6 +52,7 @@ export function applyTransparencySettings(settings: TransparencySettings) {
|
||||
? clamp(settings.backgroundPosterOpacity, 0, 1)
|
||||
: 0,
|
||||
backgroundBlur: Number.isFinite(settings.backgroundBlur) ? clamp(settings.backgroundBlur, 0, 30) : 16,
|
||||
glassQuality: settings.glassQuality === 'realtime' ? 'realtime' : 'lightweight',
|
||||
level: settings.level,
|
||||
}
|
||||
|
||||
@@ -51,13 +65,20 @@ export function applyTransparencySettings(settings: TransparencySettings) {
|
||||
root.style.setProperty('--transparent-blur-heavy', `${normalized.blur * 1.6}px`)
|
||||
root.style.setProperty('--transparent-background-poster-opacity', (1 - normalized.backgroundPosterOpacity).toString())
|
||||
root.style.setProperty('--transparent-background-blur', `${normalized.backgroundBlur}px`)
|
||||
root.classList.toggle('transparent-blur-disabled', normalized.blur <= 0)
|
||||
root.classList.toggle('transparent-background-blur-disabled', normalized.backgroundBlur <= 0)
|
||||
root.classList.toggle('transparent-glass-lightweight', normalized.glassQuality === 'lightweight')
|
||||
root.classList.toggle('transparent-glass-realtime', normalized.glassQuality === 'realtime')
|
||||
|
||||
localStorage.setItem('transparency-opacity', normalized.opacity.toString())
|
||||
localStorage.setItem('transparency-blur', normalized.blur.toString())
|
||||
localStorage.setItem('transparency-background-poster-opacity', normalized.backgroundPosterOpacity.toString())
|
||||
localStorage.setItem('transparency-background-blur', normalized.backgroundBlur.toString())
|
||||
localStorage.setItem('transparency-glass-quality', normalized.glassQuality)
|
||||
localStorage.setItem('transparency-level', normalized.level)
|
||||
|
||||
window.dispatchEvent(new CustomEvent<TransparencySettings>(TRANSPARENCY_SETTINGS_CHANGED_EVENT, { detail: normalized }))
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -73,6 +94,7 @@ export function useTransparencySettings() {
|
||||
const transparencyBlur = ref(storedSettings.blur)
|
||||
const backgroundPosterOpacity = ref(storedSettings.backgroundPosterOpacity)
|
||||
const backgroundBlur = ref(storedSettings.backgroundBlur)
|
||||
const transparencyGlassQuality = ref<TransparencyGlassQuality>(storedSettings.glassQuality)
|
||||
const transparencyLevel = ref(storedSettings.level)
|
||||
|
||||
const currentPresetLevel = computed(() => {
|
||||
@@ -95,6 +117,7 @@ export function useTransparencySettings() {
|
||||
blur: transparencyBlur.value,
|
||||
backgroundPosterOpacity: backgroundPosterOpacity.value,
|
||||
backgroundBlur: backgroundBlur.value,
|
||||
glassQuality: transparencyGlassQuality.value,
|
||||
level: transparencyLevel.value,
|
||||
})
|
||||
|
||||
@@ -102,6 +125,7 @@ export function useTransparencySettings() {
|
||||
transparencyBlur.value = normalized.blur
|
||||
backgroundPosterOpacity.value = normalized.backgroundPosterOpacity
|
||||
backgroundBlur.value = normalized.backgroundBlur
|
||||
transparencyGlassQuality.value = normalized.glassQuality
|
||||
transparencyLevel.value = normalized.level
|
||||
}
|
||||
|
||||
@@ -149,12 +173,18 @@ export function useTransparencySettings() {
|
||||
syncTransparencySettings()
|
||||
}
|
||||
|
||||
/** 处理玻璃质量档位变化。 */
|
||||
function onGlassQualityChange() {
|
||||
syncTransparencySettings()
|
||||
}
|
||||
|
||||
/** 重置透明主题设置为默认值。 */
|
||||
function resetTransparencySettings() {
|
||||
transparencyOpacity.value = transparencyPresets.medium.opacity
|
||||
transparencyBlur.value = transparencyPresets.medium.blur
|
||||
backgroundPosterOpacity.value = 0
|
||||
backgroundBlur.value = 16
|
||||
transparencyGlassQuality.value = 'lightweight'
|
||||
transparencyLevel.value = 'medium'
|
||||
syncTransparencySettings()
|
||||
}
|
||||
@@ -167,10 +197,12 @@ export function useTransparencySettings() {
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
syncTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
transparencyLevel,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user