Compare commits

..

145 Commits

Author SHA1 Message Date
jxxghp
b27eeea6c0 Improve frontend dialog and layout behavior 2026-07-07 08:54:09 +08:00
jxxghp
39a9fe0541 fix: wrap words guide hint 2026-07-06 23:17:35 +08:00
jxxghp
92d153760b fix: sync media type when selecting media 2026-07-06 22:38:10 +08:00
jxxghp
ff9dc26040 feat: support backend i18n responses 2026-07-06 19:13:25 +08:00
jxxghp
269da6eefe fix(login): remove required attribute from stay logged in checkbox 2026-07-06 13:50:41 +08:00
jxxghp
817bc1019d Improve episode group carousel navigation 2026-07-05 20:39:06 +08:00
InfinityPacer
de8c22c180 fix(ui): improve fab layering and cookiecloud hint (#515) 2026-07-05 16:36:55 +08:00
jxxghp
8f074fb381 feat: add llm temperature setting 2026-07-05 13:04:51 +08:00
jxxghp
e16870859d fix: 优化媒体详情按钮布局 2026-07-05 11:56:11 +08:00
InfinityPacer
e1ba13abf2 feat(settings): expose cookiecloud upload secret (#514) 2026-07-05 09:46:14 +08:00
jxxghp
8d1f226035 Delete .codex-qa directory 2026-07-03 21:46:15 +08:00
jxxghp
5367a07a90 fix: remove redundant episode status icon 2026-07-03 21:40:52 +08:00
jxxghp
8e681dbbe7 fix: prevent nested scrolling in words shortcut 2026-07-03 20:32:16 +08:00
jxxghp
c26c3c4284 Remove playing backdrop status badge 2026-07-03 19:51:35 +08:00
jxxghp
461ed560f0 Improve logging dialogs and word rules layout 2026-07-03 19:45:58 +08:00
jxxghp
dd27bcdaca 更新 package.json 2026-07-03 18:47:06 +08:00
jxxghp
c076be868a feat: 桌面端日志级别改为按钮组 2026-07-03 18:06:57 +08:00
jxxghp
d2634095e4 feat: 增加仪表盘配置的更新时间戳和保存队列功能 2026-07-03 17:13:44 +08:00
jxxghp
a5a3e1e1e4 Add new animations and styles for robot actions
- Introduced new SCSS files for various robot actions including shy, sit, sleep, spin-cheer, stretch, and wave.
- Implemented keyframe animations for each action to enhance the robot's interactivity.
- Created a shared stylesheet for common styles and transitions.
- Updated the main CSS file to include all new action styles.
2026-07-03 14:15:10 +08:00
jxxghp
09a20967c0 feat: 增加宠物随机动作和相应的动画效果 2026-07-03 13:32:39 +08:00
jxxghp
9a2af0a412 feat: add agent pet types and state management for idle actions
- Introduced new types for agent pet actions and intents in `types.ts`.
- Implemented `useAgentPetMachine` to manage idle action scheduling and execution.
- Added functionality to handle random actions based on interaction states.
2026-07-03 13:19:30 +08:00
jxxghp
6bbac7db7d refactor: 重新设计词表页面 2026-07-03 08:37:44 +08:00
jxxghp
b212e066b6 添加 .codex-qa 到 .gitignore 2026-07-02 20:29:31 +08:00
jxxghp
f7d354fca4 Refine mobile subscribe cards and grouped list styling 2026-07-02 20:28:03 +08:00
jxxghp
693b081ecf 删除设计 QA 文档 2026-07-02 19:33:14 +08:00
jxxghp
d5f0fed954 Polish app center grouped menu styling 2026-07-02 19:32:39 +08:00
jxxghp
67ef98bfee Hide dashboard recommendation detail panel 2026-07-02 19:08:07 +08:00
jxxghp
1094d80b9c Refine episode group subscriptions and mobile carousel 2026-07-02 16:34:38 +08:00
jxxghp
98e6481812 Refine dashboard recommendations and transparent cards 2026-07-02 15:47:47 +08:00
jxxghp
7db088eb79 更新 package.json 2026-07-02 14:35:38 +08:00
jxxghp
dec154c042 Rename playback status label to continue watching 2026-07-02 12:42:56 +08:00
jxxghp
d06ce5d984 Refine offline status toasts and theme styling 2026-07-02 12:36:49 +08:00
jxxghp
af604d0c5c 增强离线状态管理,添加服务探测功能和连接状态提示 2026-07-02 11:56:34 +08:00
jxxghp
fa39c3750c Limit dashboard recommendations to TMDB sources 2026-07-02 10:51:43 +08:00
jxxghp
ce64fb03ce Add media recommendations dashboard and episode groups 2026-07-01 17:35:35 +08:00
jxxghp
764f14a7f4 Highlight today in full calendar with primary color 2026-07-01 10:37:13 +08:00
jxxghp
621200b3b2 Refine subscribe files dialog layout 2026-07-01 09:57:53 +08:00
jxxghp
60c46ebbaf Make search site dialog fullscreen on small screens 2026-07-01 09:48:41 +08:00
InfinityPacer
4fe65b770e perf(app): throttle inactive realtime blur rendering (#511) 2026-07-01 06:01:50 +08:00
jxxghp
c010162e54 fix subscribe files duplicate stats 2026-06-30 22:26:11 +08:00
jxxghp
5cc4ea7740 fix dashboard auto height growth 2026-06-30 22:22:47 +08:00
jxxghp
8a19fbc584 fix(subscribe): 调整统计卡片移动端布局 2026-06-30 21:33:57 +08:00
jxxghp
24412989bb fix(subscribe): 修复文件弹窗移动端布局 2026-06-30 21:20:41 +08:00
jxxghp
55d0693e41 fix(subscribe): 优化文件统计弹窗移动端样式 2026-06-30 20:49:52 +08:00
jxxghp
0459a2d108 feat(subscribe): 更新下拉菜单项并添加媒体详情和文件统计本地化字符串 2026-06-30 20:31:27 +08:00
jxxghp
624b55576d Refine login glass effect and plugin menu icon 2026-06-30 20:23:23 +08:00
jxxghp
c848b90599 Split movie release dates and polish poster radius 2026-06-30 20:13:37 +08:00
jxxghp
79bc99e624 feat(theme): 使用按钮切换替代标签,优化订阅文件选项卡样式 2026-06-30 20:02:32 +08:00
jxxghp
850f7ee9be Update subscription file terminology and add new localization strings
- Changed title from 'Downloaded Files' to 'Subscription Files' in English, Simplified Chinese, and Traditional Chinese locales.
- Added new localization strings related to subscription files, including status messages and file management terms across all three language files.
2026-06-30 20:02:32 +08:00
InfinityPacer
4cc105287f fix(auth): decouple webpush subscription from login (#510) 2026-06-30 19:59:51 +08:00
InfinityPacer
527c0845bc fix(theme): stabilize transparent background transitions (#509) 2026-06-30 19:16:31 +08:00
jxxghp
2a3ef1a1f6 fix theme border surfaces 2026-06-30 19:05:26 +08:00
jxxghp
7539fa26aa feat(theme): 更新主题定制器边框样式,优化视觉效果
fix(locales): 修改英文和中文边框相关文本
style(app): 调整应用界面边框和阴影样式
2026-06-30 18:42:39 +08:00
jxxghp
3547c146a8 Use overlay shadow for pull indicator 2026-06-30 18:03:32 +08:00
jxxghp
da630b0966 feat(theme): 添加主题定制器的圆角和阴影示例,优化透明主题下的视觉效果 2026-06-30 17:43:07 +08:00
InfinityPacer
cff4c1fecc fix(theme): restore transparent glass overlays (#508) 2026-06-30 14:47:42 +08:00
jxxghp
3f525c6457 fix: adjust dialog fullscreen breakpoint by width 2026-06-30 14:17:04 +08:00
jxxghp
8c1eb3e981 feat: add fullscreen support to TransparencySettingsDialog based on display size 2026-06-30 13:59:53 +08:00
InfinityPacer
effc83b9a1 feat: add transparent glass quality options (#507)
* feat(theme): add lightweight transparent glass mode

* perf(theme): reduce realtime glass backdrop filters

* fix(theme): align transparency presets
2026-06-30 13:39:22 +08:00
jxxghp
39ce8d93dd feat: add rounded corners to weekly bar chart for improved aesthetics and Safari compatibility 2026-06-30 13:37:00 +08:00
jxxghp
fa32ac9f48 feat: enhance compact FAB styles with backdrop filter and improved hover effects 2026-06-30 13:15:46 +08:00
jxxghp
ab243bbbb7 refactor: streamline transfer queue logic and improve styling 2026-06-30 12:45:10 +08:00
jxxghp
abcd3f5e81 refactor: simplify transfer queue header layout and styles 2026-06-30 12:41:53 +08:00
jxxghp
92bf1ca790 refine transfer queue layout 2026-06-30 12:07:36 +08:00
jxxghp
faadf39b80 feat: enhance transfer queue localization and UI improvements 2026-06-30 11:30:48 +08:00
jxxghp
878c3c98b2 fix: use native login inputs 2026-06-30 06:51:57 +08:00
jxxghp
2d8ae9f462 Refine folded FAB appearance 2026-06-30 06:24:36 +08:00
jxxghp
00051a0eac Fix dashboard settings and FAB folding 2026-06-30 06:21:37 +08:00
jxxghp
88726c0f12 Update dashboard localization labels 2026-06-29 22:37:44 +08:00
jxxghp
79bc9f0ff0 Make dashboard media grids responsive to screen size 2026-06-29 22:20:31 +08:00
jxxghp
4492e211bb 添加重新加载仪表板网格小部件的功能,以清理 GridStack 内部响应式布局缓存 2026-06-29 22:09:13 +08:00
jxxghp
bb5299c812 Fix dashboard media grid layout persistence and capacity 2026-06-29 22:07:31 +08:00
jxxghp
f54a5285d6 Polish library cards and storage dashboard 2026-06-29 21:47:37 +08:00
jxxghp
03b80d6434 添加 PlayingBackdropCard 组件并更新相关视图,优化媒体服务器设置和播放列表的加载逻辑 2026-06-29 21:20:00 +08:00
jxxghp
a916a1569d Fix dashboard task status and recent imports count 2026-06-29 20:34:03 +08:00
jxxghp
5c27c32914 Use dynamic viewport height for panels 2026-06-29 20:06:42 +08:00
jxxghp
c57a8b7b48 Switch panels to svh and native theme scrolling 2026-06-29 19:56:18 +08:00
jxxghp
0aa7d37e45 Fix panel viewport sizing on mobile 2026-06-29 19:54:06 +08:00
jxxghp
d3b3b08318 Preserve full path cache key for browse page 2026-06-29 19:40:31 +08:00
jxxghp
5127ab6c08 优化探索标签排序弹窗样式 2026-06-29 19:12:48 +08:00
jxxghp
a3810fb9e2 style(NotificationTemplateEditorDialog): adjust overflow property and update min-block-size for template editor
style(SearchBarDialog): change search icon and adjust dropdown width
style(common.scss): refine hover effects and update shadow settings for card components
style(AccountSettingNotification): improve layout and spacing for notification settings
2026-06-29 18:02:38 +08:00
InfinityPacer
f05eac0b93 fix(agent): stabilize assistant entry dragging (#505) 2026-06-29 15:50:29 +08:00
jxxghp
ef170b54c6 style(SearchBarDialog): adjust search desktop activator dimensions and disable specificity warning 2026-06-29 13:55:05 +08:00
jxxghp
536844d291 refactor(dashboard): remove unused color management logic 2026-06-29 13:48:59 +08:00
jxxghp
21d4f9fd73 Guard dashboard profile switches and tighten chart axis padding 2026-06-29 09:36:18 +08:00
jxxghp
2aae0f08ad 删除设计 QA 文档 2026-06-29 08:50:47 +08:00
jxxghp
248984261e Refine dashboard scheduler task layout 2026-06-29 08:49:52 +08:00
jxxghp
82ab594e0c fix(charts): disable zoom interactions 2026-06-29 08:08:53 +08:00
jxxghp
5ca374349d fix(dashboard): refine scheduler task list 2026-06-29 07:54:35 +08:00
jxxghp
7452e18fc3 feat(dashboard): refine network and scheduler visuals 2026-06-29 07:37:03 +08:00
jxxghp
75dffdb7c2 更新 package.json 2026-06-29 06:58:57 +08:00
jxxghp
a3d74929b3 feat(dashboard): add memory and scheduler progress interfaces, enhance dashboard layout and responsiveness 2026-06-28 22:59:36 +08:00
jxxghp
fd9c314d55 Refine card hover borders across subtitle and torrent cards 2026-06-28 21:39:29 +08:00
jxxghp
d02e98736f feat(calendar): enhance loading behavior and mobile layout for subscriptions 2026-06-28 21:22:43 +08:00
jxxghp
2e46ebe353 更新 package.json 2026-06-28 17:54:28 +08:00
jxxghp
fffb526a87 feat(dashboard): enhance media statistics and memory analytics
- Added monthly media counts (movies, TV shows, episodes) to the AnalyticsMediaStatistic component.
- Improved layout and styling for better responsiveness in the dashboard summary.
- Updated AnalyticsMemory component to display total and available memory with enhanced visuals.
- Refactored AnalyticsScheduler to include transfer queue management and improved task display.
- Enhanced AnalyticsSpeed component with a more structured speed overview.
- Introduced new components for quick actions and recent imports in the dashboard.
- Added system information display with runtime and version details in DashboardSystemInfo.
- Created a new composable for shortcut tools to streamline dialog management.
2026-06-28 17:48:50 +08:00
jxxghp
98769a313f Refine dashboard profile persistence and radius tokens 2026-06-28 16:28:25 +08:00
jxxghp
1c28d98d70 Update expired translation labels to aired 2026-06-28 15:33:42 +08:00
jxxghp
7a430b095a Limit mobile calendar history and simplify episode tags 2026-06-28 15:32:00 +08:00
jxxghp
864af36b1c Use theme primary border for selected subscribe card 2026-06-28 13:59:25 +08:00
jxxghp
e3fa0b9dae Add batch selection support for subscriptions and history 2026-06-28 13:47:13 +08:00
jxxghp
ed28832484 Fix selected subscribe card border 2026-06-28 13:13:43 +08:00
jxxghp
e692a91113 Refine mobile cache and scheduler layouts 2026-06-28 12:46:47 +08:00
jxxghp
7574719e04 Refactor frontend dialogs and add missing annotations 2026-06-28 12:08:24 +08:00
jxxghp
ca800f7ae7 Refine transfer history mobile list and search styling 2026-06-28 11:09:47 +08:00
jxxghp
7d0550825b fix: adjust header margins for consistency in resource and site card list views 2026-06-28 10:56:33 +08:00
jxxghp
59abda63e3 Format season episodes in calendar tags 2026-06-28 09:58:11 +08:00
jxxghp
0776bc9fa0 fix: adjust dashboard item rows and improve storage display styling for better layout 2026-06-28 09:44:58 +08:00
jxxghp
8c78f9ca90 feat: update dashboard components to use 'dashboard-grid-fill' class for improved layout handling 2026-06-28 09:35:50 +08:00
jxxghp
1889d5d1e3 feat: enhance dashboard layout with fixed height components and improved styling for media cards 2026-06-28 09:05:31 +08:00
jxxghp
165247b263 fix: update storage used percentage display to use div elements for better layout 2026-06-28 08:30:24 +08:00
jxxghp
9a2384bf02 Refine subscribe calendar mobile filters and bump version 2026-06-27 22:17:47 +08:00
jxxghp
71c4f587d3 Enable hover open for horizontal nav submenus 2026-06-27 21:44:48 +08:00
jxxghp
8edbfa0b0e Adjust assistant entry anchor for mobile FAB 2026-06-27 21:39:05 +08:00
jxxghp
ee187a1c75 refactor: remove unused episode subtitle search functionality 2026-06-27 18:21:11 +08:00
jxxghp
afb617724c fix: recover web agent stream after background 2026-06-27 15:09:41 +08:00
InfinityPacer
05bbecba53 fix(subscribe): refine season mode defaults (#503) 2026-06-27 06:55:45 +08:00
jxxghp
deb9b103da Improve assistant input and choice handling 2026-06-26 13:43:47 +08:00
jxxghp
2d92315e17 Enhance command prompts in localization files
- Updated placeholder text in English, Simplified Chinese, and Traditional Chinese to guide users on using commands.
- Added loading and load failed messages for commands in all three languages.
2026-06-26 12:42:49 +08:00
jxxghp
077f0c1366 fix: improve login autofill compatibility 2026-06-25 23:26:57 +08:00
jxxghp
eab4caa279 fix: disable MediaCard ripple feedback 2026-06-25 23:15:40 +08:00
jxxghp
3bba73abf1 fix: remove unused formatSeason function and subscribedSeasonText for cleaner code 2026-06-25 22:59:29 +08:00
jxxghp
8edf1f686a fix: refactor media card interaction for improved touch handling and state management 2026-06-25 22:45:12 +08:00
jxxghp
3440b377ee fix: improve mobile interaction handling for media cards 2026-06-25 21:33:37 +08:00
jxxghp
9161e5e300 fix: replace VBottomSheet with VDialog for improved layout and accessibility 2026-06-25 20:11:52 +08:00
jxxghp
25fdb09c61 fix: correct ios safe area in subscribe sheet 2026-06-25 17:39:35 +08:00
jxxghp
25119b224d fix: refactor SubscribeSeasonDialog for improved readability and style consistency 2026-06-25 17:22:29 +08:00
jxxghp
76f79e1656 fix: align bubbles after assistant undocks 2026-06-25 16:11:39 +08:00
jxxghp
b5230cef79 fix: match agent bubble arrow style 2026-06-25 16:02:25 +08:00
jxxghp
f871746efb fix: reposition agent assistant bubbles 2026-06-25 15:53:52 +08:00
jxxghp
d17ca69a28 fix: expand subscribe season sheet on mobile 2026-06-25 15:12:50 +08:00
jxxghp
e445437129 fix: hide agent assistant message scrollbar 2026-06-25 13:56:46 +08:00
jxxghp
090c5476c6 feat: unify media subscription flow 2026-06-25 13:52:13 +08:00
jxxghp
646c7b33dd Tune pull and swipe gesture thresholds 2026-06-25 12:33:49 +08:00
jxxghp
0902f16f84 fix ui 2026-06-25 11:39:52 +08:00
InfinityPacer
ec4eaed1df fix: refine subscribe mode selection (#501)
* fix(subscribe): refine subscribe mode selection

* fix(subscribe): prompt mode for single existing season
2026-06-25 11:30:24 +08:00
jxxghp
175f610524 Animate dashboard metrics and standardize card sizing 2026-06-25 11:28:36 +08:00
jxxghp
530fe9d35b fix: 修复卡片 hover 上浮抖动 2026-06-24 21:34:38 +08:00
jxxghp
48ed396a19 Refresh notifications when opening the menu 2026-06-24 20:58:46 +08:00
jxxghp
b0356c217d feat: add welcome back message to login page and enhance styles 2026-06-24 17:38:03 +08:00
jxxghp
55eed1ecb5 Fix notification read and clear state 2026-06-24 16:58:08 +08:00
jxxghp
50ae739a4d fix: resolve stylelint warnings and enhance bubble layout for agent assistant 2026-06-24 14:33:55 +08:00
jxxghp
d9cbcc2991 Increase agent assistant bubble text size 2026-06-24 13:44:09 +08:00
jxxghp
ad12701fe2 chore: update version to 2.13.15 2026-06-24 12:55:32 +08:00
jxxghp
2b426a47c6 Route toast notifications to agent assistant bubbles 2026-06-24 12:55:01 +08:00
154 changed files with 22145 additions and 5491 deletions

1
.gitignore vendored
View File

@@ -38,3 +38,4 @@ public/plugin_icon/**
docs-lock/
.trae/
output/
.codex-qa/

View File

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

View File

@@ -3,6 +3,9 @@
// 👉 Apex chart
.apexcharts-canvas {
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
touch-action: pan-y;
// For RTL alignment
.apexcharts-yaxis-texts-g {
text-align: start;

View File

@@ -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) {
// 负数标记

View File

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

View File

@@ -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'
@@ -12,13 +12,25 @@ import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundMan
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;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,13 @@ 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,2 @@
@use './cssRobotBase';
@use './actions/cssRobotActions';

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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
})
@@ -494,16 +451,17 @@ onBeforeUnmount(() => {
<VHover>
<template #default="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area">
<div ref="mediaCardRef" v-bind="hover.props" class="media-card-hover-area" @mouseenter="querySubscribedSeasons">
<VCard
: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>
@@ -592,7 +544,7 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.media-card-hover-area {
width: 100%;
inline-size: 100%;
}
.media-card-title {
@@ -604,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>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
/* 优惠标签样式 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
MediaInfo,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -123,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('|')
@@ -298,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[]>([])
@@ -1019,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
@@ -1046,7 +1082,7 @@ async function previewTransfer() {
}),
)
} else {
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
}
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
@@ -1078,7 +1114,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1114,7 +1150,7 @@ async function previewTransfer() {
return
}
mergePreviewData(mergedPreviewData, result.data)
mergePreviewData(mergedPreviewData, resolvePreviewResponseData(result))
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
@@ -1196,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
}
}
@@ -1714,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', // 同步主题模式

View File

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

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

View File

@@ -5,9 +5,25 @@ import { PropType } from '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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 渲染模式 -->

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ function clearThemeCustomizerOpenState() {
syncThemeCustomizerOpenState(false)
}
// 处理固定面板的全局 Esc 关闭快捷键。
function handleGlobalKeydown(event: KeyboardEvent) {
// 固定侧栏不再依赖 Vuetify overlay手动补上常见的 Esc 关闭行为。
if (event.key === 'Escape') emit('close')
@@ -146,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
@@ -176,6 +180,7 @@ function handleShadowSliderChange(value: unknown) {
if (themeCustomizerShadowLevels.includes(shadow)) setShadow(shadow)
}
// 重置主题定制设置App 模式只恢复可调整的外观项。
async function handleResetSettings() {
if (!appMode.value) {
await resetSettings()
@@ -217,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">
@@ -277,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 />
@@ -400,7 +406,7 @@ async function handleResetSettings() {
</div>
</div>
</section>
</PerfectScrollbar>
</div>
</div>
</aside>
</template>
@@ -438,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%;
@@ -487,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 {
@@ -641,6 +650,7 @@ async function handleResetSettings() {
box-shadow: none !important;
.theme-customizer-mini-layout,
.theme-customizer-border-scene,
.theme-customizer-radius-scene {
border-width: 2px;
border-color: rgb(var(--v-theme-primary));
@@ -696,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;
@@ -721,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;
@@ -742,23 +810,28 @@ async function handleResetSettings() {
inline-size: 100%;
min-inline-size: 0;
--theme-customizer-preview-radius: var(--app-vuetify-rounded);
--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-radius: var(--app-vuetify-rounded-0);
--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-radius: var(--app-vuetify-rounded-sm);
--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-radius: var(--app-vuetify-rounded-lg);
--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-radius: var(--app-vuetify-rounded-xl);
--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 {
@@ -766,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-preview-radius);
border-radius: var(--theme-customizer-preview-surface-radius);
background: rgb(var(--v-theme-surface));
gap: 8px;
inset: 16px;
@@ -781,14 +854,14 @@ async function handleResetSettings() {
}
.theme-customizer-radius-scene__badge {
border-radius: var(--theme-customizer-preview-radius);
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-radius);
border-radius: var(--theme-customizer-preview-control-radius);
block-size: 7px;
}

View File

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

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

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

@@ -4,7 +4,14 @@ import DefaultLayout from './default/components/DefaultLayout.vue'
const route = useRoute()
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
const routeCacheKey = computed(() => route.meta.keepAliveKey?.toString() || route.path)
const routeCacheKey = computed(() => {
if (route.meta.keepAliveKey) return route.meta.keepAliveKey.toString()
// 部分列表页的 query 会参与接口参数,缓存 key 需要保留完整路由避免串用旧数据。
if (route.meta.keepAliveByFullPath) return route.fullPath
return route.path
})
// 页面过渡按实际页面身份触发keep-alive 页面避免 query 变化时反复入场。
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))

View File

@@ -24,6 +24,7 @@ import {
filterMenusByPermission,
hasItemPermission,
hasPermission,
type UserPermissionFeatureKey,
type UserPermissionKey,
} from '@/utils/permission'
import { usePullDownGesture } from '@/composables/usePullDownGesture'
@@ -107,7 +108,7 @@ const mainContentPaddingTop = computed(() => {
const showPluginQuickAccess = ref(false)
// 离线状态管理
const { setAppOffline, isOffline } = useGlobalOfflineStatus()
const { isOffline } = useGlobalOfflineStatus()
// 动态标签页相关
// 定义动态标签页类型
@@ -119,6 +120,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
@@ -128,6 +130,8 @@ interface DynamicHeaderTabItem {
title: string
icon?: string
tab: string
permission?: UserPermissionKey
feature?: UserPermissionFeatureKey
}
interface DynamicHeaderTab {
@@ -143,7 +147,7 @@ const dynamicHeaderTab = ref<DynamicHeaderTab | null>(null)
const openHorizontalNavGroup = ref<string | null>(null)
const pendingHorizontalTab = ref<{ path: string; tab: string } | null>(null)
// 提供一个方法让其他组件注册动态标签页
/** 注册页面动态标签页,并在页面可用后应用待切换的水平导航标签。 */
const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
// 保存注册标签页的路由路径
tab.routePath = route.path
@@ -152,14 +156,16 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
applyPendingHorizontalTab()
}
// 提供一个方法让其他组件取消注册动态标签页
/** 取消当前页面注册动态标签页。 */
const unregisterDynamicHeaderTab = () => {
dynamicHeaderTab.value = null
}
// 标签页值更新处理
/** 更新当前动态标签页选中值,并通知注册页面同步状态。 */
const handleTabChange = (newValue: string) => {
if (dynamicHeaderTab.value) {
if (!visibleDynamicHeaderTabItems.value.some(item => item.tab === newValue)) return
dynamicHeaderTab.value.modelValue = newValue
// 通知注册的页面更新值
if (dynamicHeaderTab.value.onUpdateModelValue) {
@@ -193,13 +199,15 @@ watch(
{ immediate: false },
)
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => {
return (
dynamicHeaderTab.value && dynamicHeaderTab.value.items.length > 0 && dynamicHeaderTab.value.routePath === route.path
)
const visibleDynamicHeaderTabItems = computed(() => {
if (!dynamicHeaderTab.value || dynamicHeaderTab.value.routePath !== route.path) return []
return filterItemsByPermission(dynamicHeaderTab.value.items, userPermissions.value)
})
// 当前路由是否注册了动态标签页。
const hasDynamicHeaderTab = computed(() => visibleDynamicHeaderTabItems.value.length > 0)
// 水平布局下动态标签页会并入顶部导航三级菜单,不再额外显示标签页栏。
const showDynamicHeaderTab = computed(() => hasDynamicHeaderTab.value && !showHorizontalThemeNav.value)
@@ -227,18 +235,7 @@ onUnmounted(() => {
}
})
// 监听Service Worker消息
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'OFFLINE_STATUS') {
if (event.data.offline) {
setAppOffline(true, t('common.serverConnectionFailed'))
} else {
setAppOffline(false)
}
}
}
// 检查是否可以使用下拉手势
/** 判断当前页面状态是否允许使用主界面下拉快捷入口手势。 */
const canUsePullGesture = () => {
// 检查是否在dashboard页面
const isDashboard = route.path === '/dashboard' || route.path === '/'
@@ -264,13 +261,22 @@ const {
config: PULL_CONFIG,
} = usePullDownGesture({
enabled: true,
config: {
START_THRESHOLD: 28,
SHOW_INDICATOR: 80,
TRIGGER_THRESHOLD: 140,
MAX_PULL_DISTANCE: 220,
PULL_RESISTANCE: 0.7,
CONTENT_FOLLOW_RATIO: 0.35,
TOLERANCE: 96,
},
canUsePullGesture,
onTrigger: () => {
showPluginQuickAccess.value = true
},
})
// 根据分类获取菜单列表
/** 根据菜单分组标题获取当前用户可见的菜单项。 */
const getMenuList = (header: string) => {
// 使用国际化菜单
const menus = getNavMenus(t)
@@ -278,19 +284,22 @@ const getMenuList = (header: string) => {
return filteredMenus.filter((item: NavMenu) => item.header === header)
}
// 返回上一页
/** 返回浏览历史中的上一页。 */
function goBack() {
history.back()
}
/** 同步主题定制器变更后的布局模式。 */
function handleThemeCustomizerChange(event: Event) {
themeLayout.value = (event as CustomEvent<ThemeCustomizerSettings>).detail.layout
}
/** 打开主题定制器面板。 */
function handleThemeCustomizerOpen() {
showThemeCustomizer.value = true
}
/** 判断水平导航菜单项是否匹配当前路由。 */
function isHorizontalNavActive(item: NavMenu) {
const targetPath = normalizeMenuPath(item.to)
if (!targetPath) return false
@@ -300,18 +309,22 @@ function isHorizontalNavActive(item: NavMenu) {
return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`)
}
/** 判断水平导航分组内是否存在当前路由激活项。 */
function isHorizontalNavGroupActive(group: { items: NavMenu[] }) {
return group.items.some(isHorizontalNavActive)
}
/** 判断菜单项是否存在可在水平导航中展示的动态标签。 */
function hasHorizontalDynamicTabs(item: NavMenu) {
return showHorizontalThemeNav.value && getHorizontalNavTabs(item).length > 0
}
/** 判断水平导航动态标签是否为当前选中项。 */
function isHorizontalDynamicTabActive(tab: DynamicHeaderTabItem) {
return dynamicHeaderTab.value?.modelValue === tab.tab
}
/** 切换水平导航中的动态标签,必要时先跳转到目标页面。 */
async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeaderTabItem) {
const targetPath = normalizeMenuPath(item.to)
const currentPath = normalizeMenuPath(route.path)
@@ -327,28 +340,34 @@ async function handleHorizontalDynamicTabSelect(item: NavMenu, tab: DynamicHeade
openHorizontalNavGroup.value = null
}
/** 关闭当前展开的水平导航分组菜单。 */
function closeHorizontalNavGroup() {
openHorizontalNavGroup.value = null
}
/** 读取可能是 Ref 的值,空值时回落到默认值。 */
function resolveMaybeRefValue<T>(value: T | ComputedRef<T> | undefined, fallback: T): T {
return isRef(value) ? value.value : (value ?? fallback)
}
/** 解析动态头部按钮颜色。 */
function resolveHeaderButtonColor(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.color, 'gray')
}
/** 解析动态头部按钮加载状态。 */
function resolveHeaderButtonLoading(button: DynamicHeaderTabButton) {
return resolveMaybeRefValue(button.loading, false)
}
/** 校验权限后执行动态头部按钮动作。 */
function handleHeaderButtonClick(button: DynamicHeaderTabButton) {
if (!hasItemPermission(button, userPermissions.value)) return
button.action?.()
}
/** 获取水平导航动态标签图标,不可渲染时使用默认圆点图标。 */
function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
const icon = tab.icon?.trim()
@@ -361,45 +380,49 @@ function getHorizontalTabIcon(tab: DynamicHeaderTabItem) {
return icon
}
/** 标准化菜单路径,移除末尾斜杠并保留根路径。 */
function normalizeMenuPath(value: unknown) {
if (typeof value !== 'string') return ''
return value.replace(/\/$/, '') || '/'
}
/** 获取水平导航菜单项可展示的标签列表。 */
function getHorizontalNavTabs(item: NavMenu): DynamicHeaderTabItem[] {
const targetPath = normalizeMenuPath(item.to)
if (targetPath && isHorizontalNavActive(item) && hasDynamicHeaderTab.value) {
return dynamicHeaderTab.value?.items ?? []
return visibleDynamicHeaderTabItems.value
}
return item.tabs ?? []
return filterItemsByPermission(item.tabs ?? [], userPermissions.value)
}
/** 在目标页面注册动态标签后应用此前暂存的标签切换。 */
function applyPendingHorizontalTab() {
if (!pendingHorizontalTab.value || !hasDynamicHeaderTab.value) return
const pending = pendingHorizontalTab.value
if (normalizeMenuPath(route.path) !== pending.path) return
const tabExists = dynamicHeaderTab.value?.items.some(item => item.tab === pending.tab)
const tabExists = visibleDynamicHeaderTabItems.value.some(item => item.tab === pending.tab)
if (!tabExists) return
handleTabChange(pending.tab)
pendingHorizontalTab.value = null
}
// 关闭插件快速访问
/** 关闭插件快速访问面板。 */
function handleClosePluginQuickAccess() {
showPluginQuickAccess.value = false
}
// 点击插件后关闭
/** 点击插件入口后关闭插件快速访问面板。 */
function handlePluginClick() {
showPluginQuickAccess.value = false
}
/** 将插件侧边栏菜单合并到对应的导航分组中。 */
function appendPluginSidebarMenus() {
for (const { navMenu, section } of filterPluginSidebarNavEntries(
pluginSidebarNavStore.items,
@@ -442,18 +465,10 @@ onMounted(async () => {
await pluginSidebarNavStore.ensureSidebarNav()
appendPluginSidebarMenus()
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
// 组件卸载时清理监听
onBeforeUnmount(() => {
window.removeEventListener(THEME_CUSTOMIZER_CHANGE_EVENT, handleThemeCustomizerChange)
window.removeEventListener(THEME_CUSTOMIZER_OPEN_EVENT, handleThemeCustomizerOpen)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
})
</script>
@@ -465,15 +480,15 @@ onMounted(async () => {
<!-- 👉 Pull Down Indicator -->
<div
v-if="appMode && showPullIndicator"
class="pull-indicator"
class="app-pull-indicator"
:style="{
'--pull-indicator-navbar-extra-height': navbarExtraHeight,
'--app-pull-indicator-navbar-extra-height': navbarExtraHeight,
opacity: indicatorOpacity,
transform: indicatorTransform,
}"
>
<div
class="indicator-icon"
class="app-pull-indicator__icon"
:style="{
transform: `scale(${
1 + Math.min((pullDistance - PULL_CONFIG.SHOW_INDICATOR) / PULL_CONFIG.MAX_PULL_DISTANCE, 0.5) * 0.3
@@ -514,8 +529,8 @@ onMounted(async () => {
class="theme-navbar-actions d-flex align-center"
:class="{ 'theme-navbar-actions--horizontal': showHorizontalThemeNav }"
>
<!-- 👉 Horizontal Search Icon -->
<SearchBar v-if="showHorizontalThemeNav" icon-only />
<!-- 👉 Horizontal Search Bar -->
<SearchBar v-if="showHorizontalThemeNav" />
<!-- 👉 Shortcuts -->
<ShortcutBar v-if="canAdmin" />
<!-- 👉 Notification -->
@@ -531,6 +546,9 @@ onMounted(async () => {
:model-value="openHorizontalNavGroup === group.title"
location="bottom start"
offset="8"
open-on-hover
:open-delay="0"
:close-delay="120"
:close-on-content-click="false"
@update:model-value="openHorizontalNavGroup = $event ? group.title : null"
>
@@ -560,7 +578,11 @@ onMounted(async () => {
:close-on-content-click="true"
>
<template #activator="{ props: subMenuProps }">
<VListItem v-bind="subMenuProps" :active="isHorizontalNavActive(item)">
<VListItem
v-bind="subMenuProps"
:active="isHorizontalNavActive(item)"
class="theme-horizontal-nav__submenu-activator"
>
<template #prepend>
<VIcon :icon="String(item.icon || '')" />
</template>
@@ -659,7 +681,7 @@ onMounted(async () => {
<template #dynamic-header-tab>
<div v-if="showDynamicHeaderTab">
<HeaderTab
:items="dynamicHeaderTab!.items"
:items="visibleDynamicHeaderTabItems"
:model-value="dynamicHeaderTab!.modelValue"
@update:model-value="handleTabChange"
>
@@ -820,6 +842,10 @@ onMounted(async () => {
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.theme-horizontal-nav__submenu-activator {
cursor: pointer;
}
.theme-horizontal-nav__actions {
display: flex;
flex: 0 0 auto;
@@ -827,72 +853,4 @@ onMounted(async () => {
margin-inline-start: auto;
}
.pull-indicator {
position: fixed;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 50%;
backdrop-filter: blur(20px);
background: rgba(var(--v-theme-surface), 0.3);
box-shadow:
0 1px 2px rgba(0, 0, 0, 10%),
0 1px 3px rgba(0, 0, 0, 6%);
inset-block-start: calc(
env(safe-area-inset-top, 0px) + 4rem + var(--pull-indicator-navbar-extra-height, 0rem) + 0.75rem
);
inset-inline-start: 50%;
pointer-events: none;
transform: translate3d(-50%, 0, 0);
transition:
opacity 0.2s ease,
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform;
}
.indicator-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.08);
block-size: 40px;
inline-size: 40px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 透明主题适配 */
html[class*='transparent'] .pull-indicator,
html[class*='mica'] .pull-indicator,
html[class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 20%);
background: rgba(255, 255, 255, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 12%),
0 4px 16px rgba(0, 0, 0, 8%);
}
html[class*='transparent'] .indicator-icon,
html[class*='mica'] .indicator-icon,
html[class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.12);
}
html[data-theme='dark'][class*='transparent'] .pull-indicator,
html[data-theme='dark'][class*='mica'] .pull-indicator,
html[data-theme='dark'][class*='acrylic'] .pull-indicator {
border: 1px solid rgba(255, 255, 255, 10%);
background: rgba(18, 18, 18, 95%);
box-shadow:
0 8px 32px rgba(0, 0, 0, 30%),
0 4px 16px rgba(0, 0, 0, 20%);
}
html[data-theme='dark'][class*='transparent'] .indicator-icon,
html[data-theme='dark'][class*='mica'] .indicator-icon,
html[data-theme='dark'][class*='acrylic'] .indicator-icon {
background: rgba(var(--v-theme-primary), 0.15);
}
</style>

View File

@@ -119,6 +119,7 @@ interface DynamicButton {
icon: string
action: () => void
permission?: DynamicButtonMenuItem['permission']
feature?: DynamicButtonMenuItem['feature']
show: boolean
routePath?: string // 添加路径属性,用于标识哪个路由注册的
menuItems?: DynamicButtonMenuItem[]
@@ -187,6 +188,7 @@ const legacyDynamicMenuTitleKeyMap: Record<string, string> = {
'components.pluginMarketSetting.title': 'dialog.pluginMarketSetting.title',
}
// 解析动态按钮菜单项标题,兼容旧版直接传入 i18n key 的写法。
function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
if (item.titleKey) {
return t(item.titleKey, item.titleParams as any)
@@ -202,14 +204,16 @@ function resolveDynamicMenuItemTitle(item: DynamicButtonMenuItem) {
return looksLikeI18nKey ? t(normalizedTitleKey, item.titleParams as any) : item.title
}
// 处理页面注册的动态按钮主操作点击。
function handleDynamicButtonClick() {
if (!dynamicButton.value || !hasItemPermission(dynamicButton.value, userPermissions.value)) return
dynamicButton.value.action()
}
// 处理页面注册的动态按钮菜单项点击。
function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
if (!hasItemPermission(item, userPermissions.value)) return
if (item.disabled || !hasItemPermission(item, userPermissions.value)) return
item.action()
}
@@ -292,6 +296,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
v-for="(item, index) in visibleDynamicButtonMenuItems"
:key="item.titleKey || item.title || index"
:base-color="item.color"
:disabled="item.disabled"
@click="handleDynamicMenuItemClick(item)"
>
<template #prepend>

View File

@@ -1,66 +1,60 @@
<script setup lang="ts">
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
import { useToast } from 'vue-toastification'
const OfflineStatusDialog = defineAsyncComponent(() => import('@/components/dialog/OfflineStatusDialog.vue'))
const { t } = useI18n()
const toast = useToast()
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
const lastConnectionPromptKey = ref('')
interface Props {
type?: 'offline' | 'online'
}
const props = withDefaults(defineProps<Props>(), {
type: 'offline',
const isChecking = computed(() => connectionStatus.value === 'checking')
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
const statusMessage = computed(() => {
if (connectionReason.value === 'browser-offline') return t('app.browserOfflineMessage')
if (connectionReason.value === 'timeout') return t('app.serviceTimeoutMessage')
if (isChecking.value) return t('app.connectionCheckingMessage')
return t('app.serviceUnavailableMessage')
})
const { canPerformNetworkAction } = useGlobalOfflineStatus()
let offlineDialogController: ReturnType<typeof openSharedDialog> | null = null
/** 拼接离线状态提示文案,供 Toast 或 Agent 助手气泡展示。 */
function buildConnectionPromptMessage() {
return `${statusTitle.value}${statusMessage.value}`
}
/** 打开离线状态共享弹窗。 */
function showOfflineDialog() {
if (offlineDialogController) {
offlineDialogController.updateProps({ type: props.type })
/** 根据当前连接状态选择 Toast 级别Agent 助手可用时会由全局 Toast 路由接管。 */
function showConnectionPrompt() {
const message = buildConnectionPromptMessage()
const options = {
timeout: isChecking.value ? 5000 : 7000,
}
if (isChecking.value) {
toast.warning(message, options)
return
}
offlineDialogController = openSharedDialog(
OfflineStatusDialog,
{
type: props.type,
},
{},
{ closeOn: false },
)
toast.error(message, options)
}
/** 关闭离线状态共享弹窗。 */
function closeOfflineDialog() {
offlineDialogController?.close()
offlineDialogController = null
/** 在连接状态变化时发出一次离线提示,并在恢复在线后允许下一轮提示重新出现。 */
function handleConnectionStatusChange() {
if (connectionStatus.value === 'online') {
lastConnectionPromptKey.value = ''
return
}
const promptKey = `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
if (promptKey === lastConnectionPromptKey.value) return
lastConnectionPromptKey.value = promptKey
showConnectionPrompt()
}
watch(
() => canPerformNetworkAction.value,
canPerform => {
if (canPerform) {
closeOfflineDialog()
return
}
showOfflineDialog()
},
{ immediate: true },
)
watch(
() => props.type,
() => {
offlineDialogController?.updateProps({ type: props.type })
},
)
onUnmounted(() => {
closeOfflineDialog()
watch([connectionStatus, connectionReason], handleConnectionStatusChange, {
flush: 'post',
})
</script>
<template></template>
<template>
<span class="d-none" aria-hidden="true" />
</template>

View File

@@ -51,10 +51,11 @@ const pluginBackgroundColors = ref<Record<string, string>>({})
// 上滑关闭配置常量
const SWIPE_CONFIG = {
START_THRESHOLD: 10, // 开始检测上滑的最小距离
CLOSE_THRESHOLD: 100, // 触发关闭的距离
START_THRESHOLD: 16, // 开始检测上滑的最小距离
CLOSE_THRESHOLD: 144, // 触发关闭的距离
QUICK_CLOSE_MIN_DISTANCE: 56, // 快速关闭所需的最小位移
MAX_DRAG_DISTANCE: 1000, // 最大拖拽距离
VELOCITY_THRESHOLD: 0.8, // 快速滑动速度阈值 (px/ms)
VELOCITY_THRESHOLD: 1.15, // 快速滑动速度阈值 (px/ms)
}
// 上滑关闭相关状态
@@ -367,7 +368,8 @@ function handleTouchEnd() {
if (isDraggingToClose.value) {
// 判断是否应该关闭:距离超过阈值或者快速上滑
const shouldClose =
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD || velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD
dragOffset.value >= SWIPE_CONFIG.CLOSE_THRESHOLD ||
(dragOffset.value >= SWIPE_CONFIG.QUICK_CLOSE_MIN_DISTANCE && velocity.value >= SWIPE_CONFIG.VELOCITY_THRESHOLD)
if (shouldClose) {
emit('close')

View File

@@ -1,109 +1,31 @@
<script lang="ts" setup>
import * as Mousetrap from 'mousetrap'
import SearchBarDialog from '@/components/dialog/SearchBarDialog.vue'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
iconOnly?: boolean
}>(),
{
iconOnly: false,
},
)
const searchOpen = ref(false)
const searchBar = ref<InstanceType<typeof SearchBarDialog> | null>(null)
const display = useDisplay()
const { t } = useI18n()
// 注册快捷键
Mousetrap.bind(['command+k', 'ctrl+k'], openSearchDialog)
/** 打开全局共享搜索弹窗。 */
function openSearchDialog() {
openSharedDialog(SearchBarDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
/** 打开全局搜索,并在桌面端聚焦常驻搜索输入框。 */
function openSearch() {
searchOpen.value = true
nextTick(() => searchBar.value?.focusSearchInput())
return false
}
/** 检测操作系统是否是 Mac。 */
function isMac() {
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
/** 关闭当前搜索浮层。 */
function closeSearch() {
searchOpen.value = false
}
// 计算属性:根据操作系统显示不同的按键提示
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
const showIconOnly = computed(() => props.iconOnly || !display.mdAndUp.value)
onMounted(() => {
Mousetrap.bind(['command+k', 'ctrl+k'], openSearch)
})
onBeforeUnmount(() => {
Mousetrap.unbind(['command+k', 'ctrl+k'])
})
</script>
<template>
<!-- 小屏或水平导航右侧工具区仅显示搜索图标 -->
<IconBtn
v-if="showIconOnly"
class="search-icon-trigger"
:aria-label="t('dialog.searchBar.openSearch')"
@click="openSearchDialog"
>
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
</IconBtn>
<!-- 中屏及以上与用户头像同尺寸的搜索入口 -->
<VTooltip v-else :text="`${t('dialog.searchBar.openSearch')} (${metaKey})`">
<template #activator="{ props: tooltipProps }">
<button
v-bind="tooltipProps"
class="search-trigger"
type="button"
:aria-label="t('dialog.searchBar.openSearch')"
@click="openSearchDialog"
>
<VIcon icon="mdi-magnify" size="24" class="search-trigger-icon" />
</button>
</template>
</VTooltip>
<SearchBarDialog ref="searchBar" v-model="searchOpen" show-activator @close="closeSearch" />
</template>
<style scoped>
.search-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 50%;
background: rgba(var(--v-theme-surface), 0.44);
block-size: 40px;
color: rgba(var(--v-theme-on-surface), 0.72);
cursor: pointer;
flex: 0 0 auto;
inline-size: 40px;
padding: 0;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
user-select: none;
}
.search-trigger:hover {
border-color: rgba(var(--v-theme-on-surface), 0.18);
background-color: rgba(var(--v-theme-surface), 0.62);
box-shadow: 0 4px 14px rgba(0, 0, 0, 6%);
}
.search-trigger-icon {
flex-shrink: 0;
}
html[data-theme='transparent'] .search-trigger,
.v-theme--transparent .search-trigger {
backdrop-filter: none;
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);
}
</style>

View File

@@ -1,40 +1,11 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { getQueryValue } from '@/@core/utils'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useShortcutTools } from '@/composables/useShortcutTools'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, hasItemPermission, type PermissionProtectedItem } from '@/utils/permission'
// 国际化
const { t } = useI18n()
const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
// 快捷工具只在弹窗打开时使用,按需加载避免默认布局首屏带上所有 system 视图。
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'))
type ShortcutItem = PermissionProtectedItem & {
bodyClass?: string
cardClass?: string
component?: Component
customDialog?: Component
dialog: string
dialogSubtitle?: string
icon: string
maxWidth?: string
subtitle: string
title: string
titleText?: string
}
const { visibleShortcuts, openShortcutDialog: openShortcutTool } = useShortcutTools()
// App捷径
const appsMenu = ref(false)
@@ -42,110 +13,10 @@ const appsMenu = ref(false)
// 菜单最大宽度
const menuMaxWidth = ref(420)
// 定义捷径列表
const shortcuts: ShortcutItem[] = [
{
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',
component: WordsView,
maxWidth: '60rem',
titleText: t('shortcut.words.subtitle'),
},
{
title: t('shortcut.cache.title'),
subtitle: t('shortcut.cache.subtitle'),
icon: 'mdi-database',
dialog: 'cache',
component: CacheView,
maxWidth: '90rem',
titleText: t('shortcut.cache.subtitle'),
},
{
title: t('shortcut.scheduler.title'),
subtitle: t('shortcut.scheduler.subtitle'),
icon: 'mdi-list-box',
dialog: 'scheduler',
bodyClass: 'pa-0',
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: (typeof shortcuts)[number]) {
if (!hasItemPermission(item, userPermissions.value)) return
function openShortcutDialog(item: (typeof visibleShortcuts.value)[number]) {
appsMenu.value = false
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'] },
)
openShortcutTool(item)
}
onMounted(() => {
@@ -192,23 +63,35 @@ onMounted(() => {
<div class="grid grid-cols-2 gap-3">
<!-- 循环渲染快捷方式 -->
<div v-for="(item, index) in visibleShortcuts" :key="index">
<VCard
flat
class="pa-2 d-flex align-center cursor-pointer transition-transform duration-300 hover:-translate-y-1 border h-full w-100"
hover
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
<VHover v-slot="hover">
<!-- Hover 命中区域保持静止避免卡片上浮后底边反复触发 mouseleave -->
<div v-bind="hover.props" class="shortcut-card-hover-area h-full">
<VCard
flat
:ripple="false"
class="app-hover-lift-card pa-2 d-flex align-center cursor-pointer border h-full w-100"
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
@click="openShortcutDialog(item)"
>
<VAvatar variant="text" size="48" rounded="lg">
<VIcon color="primary" :icon="item.icon" size="24" />
</VAvatar>
<div>
<div class="text-body-1 text-high-emphasis font-weight-medium">{{ item.title }}</div>
<div class="text-caption text-medium-emphasis">{{ item.subtitle }}</div>
</div>
</VCard>
</div>
</VCard>
</VHover>
</div>
</div>
</div>
</VCard>
</VMenu>
</template>
<style scoped>
.shortcut-card-hover-area {
inline-size: 100%;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { SystemNotification } from '@/api/types'
import api from '@/api'
import { clearUnreadMessages } from '@/utils/badge'
import { appUnreadMessageCount, clearUnreadMessages } from '@/utils/badge'
import { emitAgentAssistantNotificationBubble } from '@/utils/agentAssistantBubble'
import { formatDateDifference } from '@core/utils/formatters'
import { useBackground } from '@/composables/useBackground'
@@ -13,7 +13,6 @@ type NotificationDisplayItem =
| { kind: 'section'; key: string; title: string; count: number }
| { kind: 'notification'; key: string; notification: SystemNotification }
type NotificationClearScope = 'all' | 'system' | 'media'
type NotificationClearBefore = Record<NotificationClearScope, number>
const { t } = useI18n()
const { useDelayedSSE } = useBackground()
@@ -23,9 +22,6 @@ const createConfirm = useConfirm()
const PAGE_SIZE = 20
// 虚拟滚动的默认通知项高度,展开后的实际高度由 VVirtualScroll 的 itemRef 动态测量。
const NOTIFICATION_ITEM_HEIGHT = 136
const MAX_FILTERED_PAGES_PER_LOAD = 5
const CLEAR_NOTIFICATION_ENDPOINTS = ['message/notification', 'message/notification/clear']
const NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY = 'moviepilot-notification-clear-before'
const appsMenu = ref(false)
const hasNewMessage = ref(false)
@@ -35,10 +31,12 @@ const loading = ref(false)
const clearing = ref(false)
const hasMore = ref(true)
const notificationKeys = new Set<string>()
const notificationClearBefore = ref(readNotificationClearBefore())
const expandedNotificationKeys = ref(new Set<string>())
const hasUnreadNotifications = computed(() => notificationList.value.some(item => item.read === false))
const hasBadgeUnreadMessages = computed(() => appUnreadMessageCount.value > 0)
const canMarkAllAsRead = computed(() => hasUnreadNotifications.value || hasBadgeUnreadMessages.value)
const hasUnreadMessageIndicator = computed(() => hasNewMessage.value || canMarkAllAsRead.value)
const notificationDisplayList = computed(() => buildNotificationDisplayList(notificationList.value))
const notificationClearCounts = computed(() => getNotificationClearCounts())
const notificationClearOptions = computed(() => [
@@ -65,60 +63,6 @@ const notificationClearOptions = computed(() => [
},
])
/** 生成默认清理时间,分别记录全部、系统消息和媒体消息的清理范围。 */
function createDefaultNotificationClearBefore(): NotificationClearBefore {
return {
all: 0,
system: 0,
media: 0,
}
}
/** 规范化清理时间,兼容旧版本只存一个数字的本地存储格式。 */
function normalizeNotificationClearBefore(value: unknown): NotificationClearBefore {
const clearBefore = createDefaultNotificationClearBefore()
if (typeof value === 'number' && Number.isFinite(value)) {
clearBefore.all = value
return clearBefore
}
if (!value || typeof value !== 'object') return clearBefore
const scopes: NotificationClearScope[] = ['all', 'system', 'media']
scopes.forEach(scope => {
const scopeValue = Number((value as Partial<NotificationClearBefore>)[scope] || 0)
clearBefore[scope] = Number.isFinite(scopeValue) ? scopeValue : 0
})
return clearBefore
}
/** 从本地存储读取通知清理时间戳,用于过滤已清理的历史通知。 */
function readNotificationClearBefore() {
if (typeof localStorage === 'undefined') return createDefaultNotificationClearBefore()
const storedValue = localStorage.getItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY)
if (!storedValue) return createDefaultNotificationClearBefore()
try {
return normalizeNotificationClearBefore(JSON.parse(storedValue))
} catch {
return normalizeNotificationClearBefore(Number(storedValue))
}
}
/** 写入指定范围的通知清理时间戳,使清理结果在刷新后仍然生效。 */
function writeNotificationClearBefore(scope: NotificationClearScope, value: number) {
notificationClearBefore.value = {
...notificationClearBefore.value,
[scope]: value,
}
if (typeof localStorage === 'undefined') return
localStorage.setItem(NOTIFICATION_CLEAR_BEFORE_STORAGE_KEY, JSON.stringify(notificationClearBefore.value))
}
/** 将通知备注统一转换成稳定字符串,用于生成去重 key。 */
function normalizeNote(note: SystemNotification['note']) {
if (note == null) return ''
@@ -185,16 +129,6 @@ function parseNotificationTime(value: string) {
return new Date(value.includes('T') ? value : value.replaceAll(/-/g, '/')).getTime() || 0
}
/** 判断历史通知是否早于本地清理时间,需要从列表中过滤。 */
function isClearedHistoryNotification(item: SystemNotification) {
const scope = getNotificationClearScope(item)
const clearBefore = Math.max(notificationClearBefore.value.all, notificationClearBefore.value[scope])
if (!clearBefore) return false
const notificationTime = parseNotificationTime(getNotificationTime(item))
return notificationTime > 0 && notificationTime <= clearBefore
}
/** 按通知时间倒序重排当前列表。 */
function sortNotifications() {
notificationList.value = [...notificationList.value].sort(
@@ -284,8 +218,8 @@ function rebuildExpandedNotificationKeys() {
/** 列表内容变化后同步未读红点和应用角标状态。 */
function syncUnreadStateAfterListChange() {
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
// 只用当前列表更新通知中心红点,应用 badge 数量由 badge 工具维护。
hasNewMessage.value = canMarkAllAsRead.value
}
/** 统计当前已加载通知中各清理范围的数量,用于菜单展示和禁用空操作。 */
@@ -333,33 +267,12 @@ function getClearSuccessText(scope: NotificationClearScope) {
return t('notification.clearAllSuccess')
}
/** 通过后端接口清理全部通知历史,兼容新旧后端可能暴露的清理路径。 */
async function deleteNotificationHistory() {
let lastError: unknown = null
for (const endpoint of CLEAR_NOTIFICATION_ENDPOINTS) {
try {
return await api.delete(endpoint)
} catch (error: any) {
lastError = error
if (error?.response?.status !== 404 && error?.response?.status !== 405) break
}
}
throw lastError
}
/** 尝试调用后端清理接口;分类清理使用本地时间戳过滤以兼容当前全量接口语义。 */
/** 调用后端记录清理范围,后续分页查询会直接返回过滤后的通知。 */
async function tryDeleteNotificationHistory(scope: NotificationClearScope) {
if (scope !== 'all') return true
try {
const result: { [key: string]: any } = await deleteNotificationHistory()
return result?.success !== false
} catch (error: any) {
if (error?.response?.status === 404 || error?.response?.status === 405) return true
throw error
}
const result: { [key: string]: any } = await api.delete('message/notification', {
params: { scope },
})
return result?.success !== false
}
/** 确认并清空通知中心历史,同时同步清理未读角标。 */
@@ -382,7 +295,6 @@ async function clearNotifications(scope: NotificationClearScope) {
return
}
writeNotificationClearBefore(scope, Date.now())
removeNotificationsByScope(scope)
if (scope === 'all') {
await clearUnreadMessages()
@@ -396,6 +308,46 @@ async function clearNotifications(scope: NotificationClearScope) {
}
}
/** 按页请求历史通知,并合并到当前虚拟列表。 */
async function fetchNotificationPage() {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
return items
}
mergeNotifications(items, { read: true })
page.value += 1
hasMore.value = items.length >= PAGE_SIZE
return items
}
/** 刷新通知中心首屏数据,确保点开红点时能立即看到后端已有的新消息。 */
async function refreshNotificationsOnOpen() {
if (loading.value) return
try {
loading.value = true
page.value = 1
hasMore.value = true
notificationKeys.clear()
notificationList.value = compactNotifications(notificationList.value)
rebuildNotificationKeys()
await fetchNotificationPage()
} catch (error) {
console.error('刷新通知失败:', error)
} finally {
loading.value = false
}
}
/** 按页加载历史通知,并合并到当前虚拟列表。 */
async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'error') => void }) {
if (loading.value) {
@@ -410,28 +362,11 @@ async function loadNotifications({ done }: { done: (status: 'ok' | 'empty' | 'er
try {
loading.value = true
let accepted = false
let loadedPages = 0
// 清理后的分页里可能连续出现已被本地过滤的历史消息,循环跳过这些空页。
while (hasMore.value && !accepted && loadedPages < MAX_FILTERED_PAGES_PER_LOAD) {
const items = (await api.get('message/notification', {
params: {
page: page.value,
count: PAGE_SIZE,
},
})) as SystemNotification[]
if (items.length === 0) {
hasMore.value = false
break
}
const visibleItems = items.filter(item => !isClearedHistoryNotification(item))
accepted = mergeNotifications(visibleItems, { read: true })
page.value += 1
loadedPages += 1
hasMore.value = items.length >= PAGE_SIZE
const items = await fetchNotificationPage()
if (items.length === 0) {
done('empty')
return
}
done(hasMore.value ? 'ok' : 'empty')
@@ -449,7 +384,6 @@ function handleMessage(event: MessageEvent) {
try {
const notification = JSON.parse(event.data) as SystemNotification
if (isClearedHistoryNotification(notification)) return
if (mergeNotifications([notification], { prepend: true, read: false })) {
hasNewMessage.value = true
@@ -462,6 +396,8 @@ function handleMessage(event: MessageEvent) {
/** 将通知列表标记为已读,并同步清理应用角标、未读红点和通知弹窗。 */
function markAllAsRead() {
if (!canMarkAllAsRead.value) return
hasNewMessage.value = false
notificationList.value.forEach(item => {
item.read = true
@@ -536,11 +472,10 @@ function isNotificationExpanded(item: SystemNotification) {
return expandedNotificationKeys.value.has(getNotificationExpansionKey(item))
}
/** 标记单条通知为已读,并在全部已读时同步清理未读角标。 */
/** 标记单条通知为已读,仅同步当前通知中心列表的未读状态。 */
function markNotificationAsRead(item: SystemNotification) {
item.read = true
hasNewMessage.value = hasUnreadNotifications.value
if (!hasUnreadNotifications.value) void clearUnreadMessages()
}
/** 切换通知正文展开状态。 */
@@ -566,6 +501,13 @@ useDelayedSSE(
maxReconnectAttempts: 3,
},
)
/** 监听通知中心展开状态,展开时主动刷新首屏通知。 */
function handleNotificationMenuVisibleChange(open: boolean) {
if (open) void refreshNotificationsOnOpen()
}
watch(appsMenu, handleNotificationMenuVisibleChange)
</script>
<template>
@@ -579,7 +521,7 @@ useDelayedSSE(
scrim
>
<template #activator="{ props }">
<VBadge v-if="hasNewMessage" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<VBadge v-if="hasUnreadMessageIndicator" dot color="error" :offset-x="5" :offset-y="5" v-bind="props">
<IconBtn>
<VIcon icon="mdi-bell-outline" size="22" />
</IconBtn>
@@ -628,7 +570,7 @@ useDelayedSSE(
</VTooltip>
<VTooltip :text="t('notification.markRead')">
<template #activator="{ props }">
<IconBtn v-bind="props" :disabled="!hasUnreadNotifications" @click.stop="markAllAsRead">
<IconBtn v-bind="props" :disabled="!canMarkAllAsRead" @click.stop="markAllAsRead">
<VIcon icon="mdi-email-check-outline" size="20" />
</IconBtn>
</template>
@@ -652,10 +594,7 @@ useDelayedSSE(
</div>
</template>
<template #empty>
<div v-if="notificationList.length > 0" class="py-3 text-center text-caption text-medium-emphasis">
{{ t('message.noMoreData') }}
</div>
<div v-else class="notification-empty">
<div v-if="notificationList.length === 0" class="notification-empty">
<div class="notification-empty__icon">
<VIcon icon="mdi-bell-sleep-outline" size="22" />
</div>

View File

@@ -15,6 +15,8 @@ export default {
error: 'Error',
openInNewWindow: 'Open in new window',
download: 'Download',
uploadSpeed: 'Upload speed',
downloadSpeed: 'Download speed',
inputMessage: 'Enter message or command',
send: 'Send',
noData: 'No data',
@@ -152,6 +154,13 @@ export default {
transparencyBlur: 'Blur',
backgroundPosterOpacity: 'Background Opacity',
backgroundBlur: 'Background Frosted Blur',
transparencyGlassQuality: 'Glass Effect Quality',
transparencyGlassQualityLightweight: 'Lightweight',
transparencyGlassQualityRealtime: 'Realtime',
transparencyGlassQualityLightweightHint:
'Keeps a similar translucent glass look while reducing live background sampling on dense pages.',
transparencyGlassQualityRealtimeHint:
'Uses the full live glass rendering path for higher visual quality, but may increase GPU usage.',
transparencyReset: 'Reset',
transparencyLow: 'Low Transparency',
transparencyMedium: 'Medium Transparency',
@@ -166,8 +175,8 @@ export default {
primaryColor: 'Primary Color',
usePrimaryColor: 'Use {color} primary color',
chooseCustomColor: 'Choose custom primary color',
skins: 'Framework',
skinDefault: 'Default',
skins: 'Borders',
skinDefault: 'No Border',
skinBordered: 'Bordered',
radius: 'Corners',
radiusNone: 'Square',
@@ -202,6 +211,13 @@ export default {
restartFailed: 'Restart failed, please check system status',
offline: 'Application Offline',
offlineMessage: 'Network connection lost, some features may be limited',
connectionChecking: 'Reconnecting to MoviePilot',
connectionCheckingMessage: 'Checking the service status. You can continue browsing this page.',
serviceUnavailable: 'MoviePilot is temporarily unavailable',
serviceUnavailableMessage: 'The service may be starting up, or the reverse proxy may be temporarily unavailable.',
serviceTimeoutMessage: 'The MoviePilot service timed out. It will retry automatically.',
browserOfflineMessage: 'The browser reports no network connection. Trying MoviePilot directly.',
continueBrowsing: 'Continue browsing',
online: 'Application Online',
onlineMessage: 'Network connection restored',
},
@@ -279,9 +295,12 @@ export default {
login: {
wallpapers: 'Wallpapers',
tagline: 'Your smart media library',
welcomeBack: 'Welcome Back',
copyright: '© {year} MoviePilot',
username: 'Username',
password: 'Password',
showPassword: 'Show password',
hidePassword: 'Hide password',
otpCode: 'Verification Code',
stayLoggedIn: 'Stay Logged In',
login: 'Login',
@@ -721,7 +740,10 @@ export default {
untitledSession: 'Untitled chat',
emptyTitle: 'What should we handle today?',
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
placeholder: 'Ask MoviePilot...',
placeholder: 'Ask MoviePilot, Type / for commands',
processingPlaceholder: 'MoviePilot is working, please wait...',
commandLoading: 'Loading commands...',
commandLoadFailed: 'Failed to load commands',
stop: 'Stop generating',
download: 'Download',
attachFile: 'Choose image or file',
@@ -943,18 +965,34 @@ export default {
},
dashboard: {
storage: 'Storage',
storageSummary: '{available} available / {total} total',
mediaStatistic: 'Media Statistics',
weeklyOverview: 'Recent Imports',
recommendedMedia: 'Recommended Media',
selectRecommendSource: 'Select recommendation source',
previousRecommend: 'Previous recommendation',
nextRecommend: 'Next recommendation',
showRecommend: 'Show recommendation {index}',
noRecommendations: 'No recommendations from this source',
recommendLoadFailed: 'Failed to load recommendations',
weeklyOverview: 'Import Statistics',
realTimeSpeed: 'Real-time Speed',
scheduler: 'Background Tasks',
cpu: 'CPU',
cpuUsage: 'CPU Usage',
memory: 'Memory',
memoryUsage: 'Memory Usage',
memoryUsed: 'Used',
memoryCached: 'Cached',
memoryAvailable: 'Available',
averageUsage: 'Average',
network: 'Network Traffic',
upload: 'Upload',
download: 'Download',
library: 'My Media Library',
playing: 'Continue Watching',
latest: 'Recently Added',
latest: 'Latest Imports',
recentImports: 'Recent Transfers',
viewAll: 'View All',
settings: 'Dashboard Settings',
chooseContent: 'Choose content to display',
editLayout: 'Edit Layout',
@@ -964,7 +1002,32 @@ export default {
current: 'Current',
episodes: 'Episodes',
users: 'Users',
activeUsers: 'Active users',
noSchedulers: 'No Background Services',
transferQueue: 'Transfer Queue',
transferProgress: '{completed} / {total} files',
taskRunning: 'Running',
taskWaiting: 'Waiting',
noRecentImports: 'No recent transfer records',
monthlyAddition: '+{count} this month',
quickActions: {
title: 'Quick Actions',
downloadManager: 'Downloads',
globalSearch: 'Global Search',
fileManager: 'Files',
systemSettings: 'Settings',
cacheManager: 'Cache',
pluginCenter: 'Plugins',
},
systemInfo: {
title: 'System Information',
hostname: 'Host Name',
operatingSystem: 'Operating System',
runtime: 'Runtime',
runtimeValue: '{days}d {hours}h {minutes}m',
version: 'Version',
checkUpdate: 'Check Update',
},
weeklyOverviewDescription: 'Added {count} media in the last week',
speed: {
totalUpload: 'Total Upload',
@@ -989,6 +1052,8 @@ export default {
missing: 'Missing',
partiallyMissing: 'Partially Missing',
subscribed: 'Subscribed',
seasonsSubscribed: '{count} Seasons Subscribed',
allSeasonsSubscribed: 'All Seasons Subscribed',
},
minutes: 'minutes',
overview: 'Overview',
@@ -996,6 +1061,14 @@ export default {
specials: 'Specials',
seasonNumber: 'Season {number}',
episodeCount: '{count} Episodes',
episodeGroups: {
select: 'Select Episode Group',
default: 'Default',
summary: '{seasons} Seasons · {episodes} Episodes',
current: 'Current: {name} · {seasons} Seasons · {episodes} Episodes',
previous: 'View previous episode groups',
next: 'View more episode groups',
},
actions: {
searchResource: 'Search Resource',
searchSubtitle: 'Search Subtitle',
@@ -1048,8 +1121,10 @@ export default {
versionSub: 'Version Upgrade Subscribe',
addSuccess: 'Added {name} successfully!',
addFailed: 'Failed to add {name}: {message}!',
modeUpdateSuccess: 'Updated to {mode}!',
cancelSuccess: 'Subscription cancelled!',
cancelFailed: 'Failed to cancel subscription: {message}!',
notFound: 'Subscription not found!',
filterSubscriptions: 'Filter Subscriptions',
name: 'Name',
searchShares: 'Search Subscription Shares',
@@ -1088,6 +1163,8 @@ export default {
paused: 'Paused',
cardStatePaused: 'Paused',
cardStatePending: 'Pending',
mediaDetail: 'Media Details',
fileStatistics: 'File Statistics',
sortTitle: 'Sort',
sort: {
custom: 'Custom',
@@ -1097,6 +1174,9 @@ export default {
},
selectedCount: 'Selected {count}/{total} items',
noSelectedItems: 'Please select subscriptions to operate',
batchSelectAll: 'Select All Subscriptions',
batchDeselectAll: 'Deselect All Subscriptions',
exitBatchMode: 'Exit Batch Mode',
batchEnable: 'Batch Enable',
batchPause: 'Batch Pause',
batchDelete: 'Batch Delete',
@@ -1208,9 +1288,27 @@ export default {
currentEpisodeInLibrary: 'Current in library',
currentEpisodePartiallyInLibrary: 'Partially in library',
currentEpisodeNotInLibrary: 'Current not in library',
libraryStateComplete: 'In library',
libraryStatePartial: 'Partial',
libraryStateNone: 'Missing',
compactLibraryProgress: '{state} ({completed}/{total})',
libraryUpdatedAt: 'Updated {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: 'Show {count} more items for this day',
mobileFilterTitle: 'Subscription Calendar',
itemCount: '{count} items',
hideExpired: 'Hide expired',
showExpired: 'Show expired',
today: 'Today',
todayUpdated: 'Updated today',
upcoming: 'Upcoming',
expired: 'Aired',
episodeCount: '{count} episodes',
mobileEpisodeTitle: 'Episode {number}',
runtimeMinutes: '{minutes} min',
movie: 'Movie',
imageLoadFailed: 'Failed',
noMatchingEvents: 'No calendar items match the current filters',
},
storage: {
name: 'Name',
@@ -1430,6 +1528,7 @@ export default {
preparing: 'Preparing...',
totalModules: 'Total Modules',
recheck: 'Recheck',
requestFailed: 'Network request failed',
},
nameTest: {
recognize: 'Recognize',
@@ -1573,6 +1672,9 @@ export default {
'When enabled, Agent connections to the current LLM provider use the system proxy from advanced settings.',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.',
llmTemperature: 'Temperature',
llmTemperatureHint:
'Controls response randomness. Lower values are steadier and higher values are more varied. Backend default is 0.3; 0-2 is usually recommended.',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
@@ -1900,6 +2002,9 @@ export default {
userKeyHint: 'User KEY generated by CookieCloud browser plugin',
e2ePassword: 'End-to-End Encryption Password',
e2ePasswordHint: 'End-to-end encryption password generated by CookieCloud browser plugin',
cookieCloudAuthHeader: 'Upload Auth Header',
cookieCloudAuthHeaderHint:
'Leave blank to disable upload authentication, otherwise the uploader or reverse proxy must send X-CookieCloud-Auth',
autoSyncInterval: 'Auto Sync Interval',
autoSyncIntervalHint:
'Time interval for automatically syncing site cookies from CookieCloud server to MoviePilot',
@@ -1992,9 +2097,26 @@ export default {
custom: 'Custom Notification',
},
words: {
summary: '{sections} word lists · {entries} entries',
sectionListLabel: 'Word list categories',
switchHint: 'Select an item on the left to edit it',
entryCount: '{count} entries',
ruleCount: '{count} rules',
listLabel: 'Word list content (one rule per line)',
saved: 'Saved',
unsaved: 'Unsaved changes',
saveChanges: 'Save changes',
guideTitle: 'Syntax tips',
dragToSort: 'Drag to reorder',
noRules: 'No episode format rules yet',
identifiersShort: 'Identifiers',
releaseGroupsShort: 'Groups',
customizationShort: 'Placeholders',
excludeWordsShort: 'Block words',
episodeFormatRuleShort: 'Episode rules',
customIdentifiers: 'Custom Identifiers',
identifiersDesc: 'Add rules to preprocess torrent names or file names to correct identification',
identifiersPlaceholder: 'Support regular expressions, special characters need \\ escape, one line for each rule',
identifiersPlaceholder: 'Enter one identifier rule per line',
identifiersHint: 'Support regular expressions, special characters need \\ escape, one line for each rule',
formatTitle: 'Supported configuration formats (mind the spaces):',
formatContent:
@@ -2008,16 +2130,14 @@ export default {
customReleaseGroups: 'Custom Release/Subtitle Groups',
releaseGroupsDesc: 'Add release/subtitle groups that cannot be identified.',
releaseGroupsPlaceholder:
'Support regular expressions, special characters need \\ escape, one line for each group',
releaseGroupsPlaceholder: 'Enter one release/subtitle group per line',
releaseGroupsHint: 'Support regular expressions, special characters need \\ escape, one line for each group',
releaseGroupSaveSuccess: 'Custom release/subtitle groups saved successfully',
releaseGroupSaveFailed: 'Failed to save custom release/subtitle groups!',
customization: 'Custom Placeholders',
customizationDesc: 'Add custom placeholder regex patterns, use {customization} in rename format.',
customizationPlaceholder:
'Support regular expressions, special characters need \\ escape, separate multiple matches with new lines',
customizationPlaceholder: 'Enter one placeholder regex per line',
customizationHint:
'Support regular expressions, special characters need \\ escape, separate multiple matches with new lines',
customizationSaveSuccess: 'Custom placeholders saved successfully',
@@ -2025,8 +2145,7 @@ export default {
transferExcludeWords: 'File Organization Block Words',
excludeWordsDesc: 'Files or directories containing block words will not be organized.',
excludeWordsPlaceholder:
'Support regular expressions, special characters need \\ escape, one line for each block word',
excludeWordsPlaceholder: 'Enter one block word per line',
excludeWordsHint: 'Support regular expressions, special characters need \\ escape, one line for each block word',
excludeWordsSaveSuccess: 'File organization block words saved successfully',
excludeWordsSaveFailed: 'Failed to save file organization block words!',
@@ -2188,6 +2307,8 @@ export default {
stopped: 'Stopped',
waiting: 'Waiting',
executeSuccess: 'Scheduled job execution request submitted successfully!',
mobileWaitingAfter: 'In {time}',
mobileNoNextRun: 'No schedule',
},
subscribe: {
basicSettings: 'Basic Settings',
@@ -2239,6 +2360,7 @@ export default {
filterByTitle: 'Filter by Title',
filterBySite: 'Filter by Site',
selectSite: 'Select Site',
loadingMore: 'Loading...',
refresh: 'Refresh Cache',
deleteSelected: 'Delete Selected',
clearAll: 'Clear All Cache',
@@ -2289,6 +2411,7 @@ export default {
},
subscribeSeason: {
title: 'Subscribe - {title}',
selectMode: 'Subscription Type',
selectGroup: 'Select Episode Group',
defaultGroup: 'Default',
seasonCount: '{count} Seasons',
@@ -2364,6 +2487,44 @@ export default {
subscribeDesc: 'Manage movie and TV show subscriptions',
manage: 'Manage',
manageDesc: 'Access download management and site management etc.',
categoryTitle: 'Permission Categories',
categoryHint: 'Enable a category first, then choose the concrete features available in it',
summary: '{categories} categories / {features} features enabled',
featureTitleSuffix: ' Features',
featureHint: 'Disabled features are hidden from navigation, app center, and direct route access',
disabledCategoryHint: 'This category is disabled, so its features are not accessible',
selectAll: 'Select All',
clear: 'Clear',
default: 'Default',
featureScope: 'Feature scope',
features: {
recommend: 'Recommendations',
explore: 'Explore',
resourceSearch: 'Resource Search',
movieSubscribe: 'Movie Subscriptions',
tvSubscribe: 'TV Subscriptions',
calendar: 'Subscription Calendar',
subscribeShare: 'Subscription Share',
workflow: 'Workflow',
downloading: 'Download Manager',
history: 'Media Organize',
fileManager: 'File Manager',
site: 'Site Manager',
},
featureDescriptions: {
recommend: 'Recommendation page and ranking content',
explore: 'Explore page, discovery sources, and media detail browsing',
resourceSearch: 'Site resource search and result page',
movieSubscribe: 'Movie subscription list and popular movies',
tvSubscribe: 'TV subscription list, popular shows, and share tab',
calendar: 'Subscription schedule and calendar view',
subscribeShare: 'Subscription share list access',
workflow: 'Workflow list and shared workflows',
downloading: 'Current download task management',
history: 'Organize history and import records',
fileManager: 'File browsing and file management',
site: 'Site list, status, and maintenance',
},
},
},
searchBar: {
@@ -2670,6 +2831,14 @@ export default {
},
transferQueue: {
title: 'Organization Queue',
queueSummary: '{media} media · {tasks} files',
mediaList: 'Media Queue',
mediaProgressCount: '{completed} / {total} files',
overallCount: '{completed} / {total} files',
mediaFileSummary: '{count} files · {size} total',
fileName: 'File Name',
operation: 'Action',
cancelTask: 'Cancel Task',
name: 'Name',
type: 'Type',
state: 'Status',
@@ -2813,6 +2982,13 @@ export default {
processing: 'Processing ...',
successMessage: 'File {name} has been added to the organization queue!',
},
subscribeMode: {
title: 'Choose Subscription Type',
normal: 'Normal',
bestVersion: 'Version Upgrade',
bestVersionEpisode: 'Episode',
bestVersionFull: 'Full',
},
subscribeEdit: {
titleDefault: 'Default Subscription Rules',
titleEdit: 'Edit Subscription',
@@ -2868,7 +3044,7 @@ export default {
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
},
subscribeFiles: {
title: 'Downloaded Files',
title: 'Subscription Files',
noFilesMessage: 'No files',
close: 'Close',
downloadTab: 'Download Files',
@@ -2881,6 +3057,29 @@ export default {
loadingText: 'Loading...',
noData: 'No data',
season: 'Season {number}',
movieEpisode: 'Movie',
totalEpisodes: 'Episodes',
downloadedCount: 'Downloaded',
libraryCount: 'In Library',
missingCount: 'Missing {count}',
episodeList: 'Episodes',
episodeTotal: '{count} episodes',
filePath: 'File path',
copyPath: 'Copy path',
copySuccess: 'Path copied',
copyFailed: 'Copy failed',
noPath: 'No path',
noOverview: 'No overview',
noTabFiles: 'No {tab}',
statusInLibrary: 'In Library',
statusDownloaded: 'Downloaded',
statusMissing: 'Pending',
subscribeRunning: 'Running',
subscribePending: 'Pending',
subscribePaused: 'Paused',
subscribeNew: 'New',
unknownTorrent: 'Unknown torrent',
downloader: 'Downloader',
},
subscribeHistory: {
title: '{type} Subscription History',
@@ -3253,6 +3452,7 @@ export default {
loading: 'Loading...',
pageSize: 'Items Per Page',
pageInfo: '{begin} - {end} / {total}',
selectedCount: 'Selected {count}/{total} items',
aiRedoDisabled: 'Please enable the AI assistant in system settings first',
aiRedoQueued: 'Assistant organize task submitted: {title}',
aiRedoFailed: 'Failed to submit assistant organize task',
@@ -3260,6 +3460,11 @@ export default {
aiRedo: 'Assistant Organize',
aiRedoPending: 'Assistant Organizing...',
batchAiRedo: 'Assistant Batch Organize',
batchSelect: 'Batch Select',
exitBatchSelect: 'Exit Batch Select',
exitBatchMode: 'Exit Batch Mode',
selectAll: 'Select All',
deselectAll: 'Deselect All',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',

View File

@@ -152,6 +152,11 @@ export default {
transparencyBlur: '模糊度',
backgroundPosterOpacity: '背景透明度',
backgroundBlur: '背景磨砂效果',
transparencyGlassQuality: '玻璃效果质量',
transparencyGlassQualityLightweight: '轻量',
transparencyGlassQualityRealtime: '实时',
transparencyGlassQualityLightweightHint: '保持接近实时毛玻璃的透明观感,减少密集页面的背景实时采样。',
transparencyGlassQualityRealtimeHint: '使用完整实时毛玻璃路径,视觉质量更高,但可能增加 GPU 占用。',
transparencyReset: '重置',
transparencyLow: '低透明度',
transparencyMedium: '中等透明度',
@@ -166,9 +171,9 @@ export default {
primaryColor: '色调',
usePrimaryColor: '使用 {color} 色调',
chooseCustomColor: '选择自定义色调',
skins: '框',
skinDefault: '默认',
skinBordered: '边框',
skins: '框',
skinDefault: '无边框',
skinBordered: '边框',
radius: '圆角',
radiusNone: '无圆角',
radiusSmall: '小圆角',
@@ -202,6 +207,13 @@ export default {
restartFailed: '重启失败,请检查系统状态',
offline: '应用已离线',
offlineMessage: '网络连接已断开,部分功能可能受限',
connectionChecking: '正在重新连接 MoviePilot',
connectionCheckingMessage: '正在确认服务状态,当前页面仍可继续浏览。',
serviceUnavailable: '暂时无法连接 MoviePilot',
serviceUnavailableMessage: '服务可能正在启动,或反向代理暂时不可用。',
serviceTimeoutMessage: 'MoviePilot 服务响应超时,稍后将自动重试。',
browserOfflineMessage: '浏览器报告网络不可用,正在尝试直接连接 MoviePilot。',
continueBrowsing: '继续浏览',
online: '应用在线',
onlineMessage: '网络连接已恢复',
},
@@ -278,9 +290,12 @@ export default {
login: {
wallpapers: '壁纸',
tagline: '你的智能影视媒体库',
welcomeBack: '欢迎回来',
copyright: '© {year} MoviePilot',
username: '用户名',
password: '密码',
showPassword: '显示密码',
hidePassword: '隐藏密码',
otpCode: '验证码',
stayLoggedIn: '保持登录',
login: '登录',
@@ -717,7 +732,10 @@ export default {
untitledSession: '未命名会话',
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot...',
placeholder: '询问 MoviePilot,输入 / 使用命令',
processingPlaceholder: '智能体正在处理,请稍候...',
commandLoading: '正在加载命令...',
commandLoadFailed: '命令列表加载失败',
stop: '停止生成',
download: '下载',
attachFile: '选择图片或文件',
@@ -939,18 +957,36 @@ export default {
},
dashboard: {
storage: '存储空间',
storageSummary: '可用 {available} / 总容量 {total}',
mediaStatistic: '媒体统计',
weeklyOverview: '最近入库',
recommendedMedia: '推荐媒体',
selectRecommendSource: '选择推荐媒体来源',
previousRecommend: '上一项推荐',
nextRecommend: '下一项推荐',
showRecommend: '查看第 {index} 项推荐',
noRecommendations: '当前来源暂无推荐媒体',
recommendLoadFailed: '推荐媒体加载失败',
weeklyOverview: '入库统计',
realTimeSpeed: '实时速率',
scheduler: '后台任务',
cpu: 'CPU',
cpuUsage: 'CPU 使用率',
memory: '内存',
memoryUsage: '内存使用',
memoryUsed: '已使用',
memoryCached: '已缓存',
memoryAvailable: '可用',
averageUsage: '平均使用',
network: '网络流量',
upload: '上行',
download: '下行',
uploadSpeed: '上传速度',
downloadSpeed: '下载速度',
library: '我的媒体库',
playing: '继续观看',
latest: '最近添加',
latest: '最新入库',
recentImports: '近期整理',
viewAll: '查看全部',
settings: '设置仪表板',
chooseContent: '选择您想在页面显示的内容',
editLayout: '编辑布局',
@@ -960,7 +996,32 @@ export default {
current: '当前',
episodes: '剧集',
users: '用户',
activeUsers: '活跃用户',
noSchedulers: '没有后台服务',
transferQueue: '整理队列',
transferProgress: '{completed} / {total} 个文件',
taskRunning: '进行中',
taskWaiting: '等待中',
noRecentImports: '暂无近期整理记录',
monthlyAddition: '+{count} 本月新增',
quickActions: {
title: '快捷操作',
downloadManager: '下载管理',
globalSearch: '全局搜索',
fileManager: '文件管理',
systemSettings: '系统设置',
cacheManager: '缓存管理',
pluginCenter: '插件中心',
},
systemInfo: {
title: '系统信息',
hostname: '主机名称',
operatingSystem: '操作系统',
runtime: '运行时间',
runtimeValue: '{days}天 {hours}小时 {minutes}分',
version: '版本',
checkUpdate: '检查更新',
},
weeklyOverviewDescription: '最近一周入库了 {count} 部影片',
speed: {
totalUpload: '总上传量',
@@ -985,6 +1046,8 @@ export default {
missing: '缺失',
partiallyMissing: '部分缺失',
subscribed: '已订阅',
seasonsSubscribed: '已订阅 {count} 季',
allSeasonsSubscribed: '已全部订阅',
},
minutes: '分钟',
overview: '简介',
@@ -992,6 +1055,14 @@ export default {
specials: '特别篇',
seasonNumber: '第 {number} 季',
episodeCount: '{count}集',
episodeGroups: {
select: '选择剧集组',
default: '默认',
summary: '{seasons} 季 · {episodes} 集',
current: '当前:{name} · {seasons} 季 · {episodes} 集',
previous: '查看上一组剧集组',
next: '查看更多剧集组',
},
actions: {
searchResource: '搜索资源',
searchSubtitle: '搜索字幕',
@@ -1044,8 +1115,10 @@ export default {
versionSub: '洗版订阅',
addSuccess: '添加{name}成功!',
addFailed: '添加{name}失败:{message}',
modeUpdateSuccess: '已更新为{mode}',
cancelSuccess: '已取消订阅!',
cancelFailed: '取消订阅失败:{message}',
notFound: '订阅不存在!',
filterSubscriptions: '筛选订阅',
name: '名称',
searchShares: '搜索订阅分享',
@@ -1083,6 +1156,8 @@ export default {
paused: '暂停',
cardStatePaused: '已暂停',
cardStatePending: '待定中',
mediaDetail: '媒体详情',
fileStatistics: '文件统计',
sortTitle: '排序',
sort: {
custom: '自定义',
@@ -1092,6 +1167,9 @@ export default {
},
selectedCount: '已选择 {count}/{total} 项',
noSelectedItems: '请先选择要操作的订阅',
batchSelectAll: '选择全部订阅',
batchDeselectAll: '取消全选订阅',
exitBatchMode: '退出批量操作',
batchEnable: '批量启用',
batchPause: '批量暂停',
batchDelete: '批量删除',
@@ -1203,9 +1281,27 @@ export default {
currentEpisodeInLibrary: '本集已入库',
currentEpisodePartiallyInLibrary: '本集部分入库',
currentEpisodeNotInLibrary: '本集未入库',
libraryStateComplete: '已入库',
libraryStatePartial: '部分入库',
libraryStateNone: '未入库',
compactLibraryProgress: '{state} ({completed}/{total})',
libraryUpdatedAt: '最近更新 {time}',
libraryUpdatedAtShort: '{time}',
expandDayEvents: '展开当天剩余 {count} 个条目',
mobileFilterTitle: '订阅日历',
itemCount: '{count} 项',
hideExpired: '隐藏过期',
showExpired: '显示过期',
today: '今天',
todayUpdated: '今天更新',
upcoming: '即将播出',
expired: '已播出',
episodeCount: '{count} 集',
mobileEpisodeTitle: '第 {number} 集',
runtimeMinutes: '{minutes} 分钟',
movie: '电影',
imageLoadFailed: '加载失败',
noMatchingEvents: '暂无符合筛选条件的日历内容',
},
storage: {
name: '名称',
@@ -1425,6 +1521,7 @@ export default {
preparing: '准备检查...',
totalModules: '总模块数',
recheck: '重新检查',
requestFailed: '网络请求失败',
},
nameTest: {
recognize: '识别',
@@ -1564,6 +1661,8 @@ export default {
llmUseProxyHint: '启用后Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值',
llmTemperature: '温度参数',
llmTemperatureHint: '控制回复随机性,数值越低越稳定,越高越发散;后端默认 0.3,通常建议 0-2',
llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}',
@@ -1869,6 +1968,9 @@ export default {
userKeyHint: 'CookieCloud浏览器插件生成的用户KEY',
e2ePassword: '端对端加密密码',
e2ePasswordHint: 'CookieCloud浏览器插件生成的端对端加密密码',
cookieCloudAuthHeader: '上传认证 Header',
cookieCloudAuthHeaderHint:
'留空表示关闭上传认证,启用后上传端或反向代理需要发送 X-CookieCloud-Auth',
autoSyncInterval: '自动同步间隔',
autoSyncIntervalHint: '从CookieCloud服务器自动同步站点Cookie到MoviePilot的时间间隔',
syncBlacklist: '同步域名黑名单',
@@ -1958,9 +2060,26 @@ export default {
custom: '自定义通知',
},
words: {
summary: '共 {sections} 类词表 · {entries} 条配置',
sectionListLabel: '词表分类',
switchHint: '点击左侧项目切换编辑',
entryCount: '{count} 条',
ruleCount: '共 {count} 条规则',
listLabel: '词表内容(每行一个规则)',
saved: '已保存',
unsaved: '有未保存修改',
saveChanges: '保存更改',
guideTitle: '语法提示',
dragToSort: '拖动调整顺序',
noRules: '暂无集数定位规则',
identifiersShort: '识别词',
releaseGroupsShort: '制作组',
customizationShort: '占位符',
excludeWordsShort: '屏蔽词',
episodeFormatRuleShort: '集数规则',
customIdentifiers: '自定义识别词',
identifiersDesc: '添加规则对种子名或者文件名进行预处理以校正识别',
identifiersPlaceholder: '支持正则表达式,特殊字符需要\\转义,一行为一组',
identifiersPlaceholder: '每行输入一组识别规则',
identifiersHint: '支持正则表达式,特殊字符需要\\转义,一行为一组',
formatTitle: '支持的配置格式(注意空格):',
formatContent:
@@ -1974,21 +2093,21 @@ export default {
customReleaseGroups: '自定义制作组/字幕组',
releaseGroupsDesc: '添加无法识别的制作组/字幕组。',
releaseGroupsPlaceholder: '支持正则表达式,特殊字符需要\\转义,一行代表一个制作组/字幕组',
releaseGroupsPlaceholder: '每行输入一个制作组/字幕组',
releaseGroupsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个制作组/字幕组',
releaseGroupSaveSuccess: '自定义制作组/字幕组保存成功',
releaseGroupSaveFailed: '自定义制作组/字幕组保存失败!',
customization: '自定义占位符',
customizationDesc: '添加自定义占位符识别正则,重命名格式中添加{customization}使用。',
customizationPlaceholder: '支持正则表达式,特殊字符需要\\转义,多个匹配对象请换行分隔',
customizationPlaceholder: '每行输入一个占位符识别正则',
customizationHint: '支持正则表达式,特殊字符需要\\转义,多个匹配对象请换行分隔',
customizationSaveSuccess: '自定义占位符保存成功',
customizationSaveFailed: '自定义占位符保存失败!',
transferExcludeWords: '文件整理屏蔽词',
excludeWordsDesc: '目录名或文件名中包含屏蔽词时不进行整理。',
excludeWordsPlaceholder: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
excludeWordsPlaceholder: '每行输入一个屏蔽词',
excludeWordsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',
excludeWordsSaveFailed: '文件整理屏蔽词保存失败!',
@@ -2146,6 +2265,8 @@ export default {
stopped: '已停止',
waiting: '等待',
executeSuccess: '定时作业执行请求提交成功!',
mobileWaitingAfter: '{time}之后',
mobileNoNextRun: '暂无排期',
},
subscribe: {
basicSettings: '基础设置',
@@ -2194,6 +2315,7 @@ export default {
filterByTitle: '按标题筛选',
filterBySite: '按站点筛选',
selectSite: '选择站点',
loadingMore: '加载中...',
refresh: '刷新缓存',
deleteSelected: '删除选中',
clearAll: '清空缓存',
@@ -2244,6 +2366,7 @@ export default {
},
subscribeSeason: {
title: '订阅 - {title}',
selectMode: '选择订阅方式',
selectGroup: '选择剧集组',
defaultGroup: '默认',
seasonCount: '{count} 季',
@@ -2319,6 +2442,44 @@ export default {
subscribeDesc: '管理电影和电视剧订阅',
manage: '管理',
manageDesc: '访问下载管理和站点管理等功能',
categoryTitle: '权限分类',
categoryHint: '先启用权限分类,再选择该分类下允许访问的具体功能',
summary: '已启用 {categories} 类 / {features} 项功能',
featureTitleSuffix: '功能',
featureHint: '取消勾选后,该功能会从导航、应用中心和路由访问中隐藏',
disabledCategoryHint: '当前分类未启用,功能项将随分类一起不可访问',
selectAll: '全选',
clear: '清空',
default: '默认',
featureScope: '功能范围',
features: {
recommend: '推荐',
explore: '探索',
resourceSearch: '资源搜索',
movieSubscribe: '电影订阅',
tvSubscribe: '电视剧订阅',
calendar: '订阅日历',
subscribeShare: '订阅分享',
workflow: '工作流',
downloading: '下载管理',
history: '媒体整理',
fileManager: '文件管理',
site: '站点管理',
},
featureDescriptions: {
recommend: '推荐页与榜单内容入口',
explore: '探索页、媒体发现与详情浏览',
resourceSearch: '站点资源搜索与资源结果页',
movieSubscribe: '电影订阅列表和热门电影',
tvSubscribe: '电视剧订阅列表、热门剧集和分享标签',
calendar: '订阅排期与日历视图',
subscribeShare: '订阅分享列表访问',
workflow: '工作流列表与分享入口',
downloading: '当前下载任务管理',
history: '整理历史与入库记录',
fileManager: '文件浏览和文件管理',
site: '站点列表、站点状态和站点维护',
},
},
},
searchBar: {
@@ -2621,6 +2782,14 @@ export default {
},
transferQueue: {
title: '整理队列',
queueSummary: '{media} 部媒体 · {tasks} 个文件',
mediaList: '媒体队列',
mediaProgressCount: '{completed} / {total} 个文件',
overallCount: '{completed} / {total} 个文件',
mediaFileSummary: '{count} 个文件 · 共 {size}',
fileName: '文件名',
operation: '操作',
cancelTask: '取消任务',
name: '名称',
type: '类型',
state: '状态',
@@ -2762,6 +2931,13 @@ export default {
processing: '正在处理 ...',
successMessage: '文件 {name} 已加入整理队列!',
},
subscribeMode: {
title: '选择订阅方式',
normal: '普通订阅',
bestVersion: '洗版',
bestVersionEpisode: '分集洗版',
bestVersionFull: '全集洗版',
},
subscribeEdit: {
titleDefault: '默认订阅规则',
titleEdit: '编辑订阅',
@@ -2817,7 +2993,7 @@ export default {
cancelSubscribeConfirm: '是否确认取消订阅?',
},
subscribeFiles: {
title: '已下载文件',
title: '订阅文件',
noFilesMessage: '暂无文件',
close: '关闭',
downloadTab: '下载文件',
@@ -2830,6 +3006,29 @@ export default {
loadingText: '加载中...',
noData: '没有数据',
season: '第 {number} 季',
movieEpisode: '电影',
totalEpisodes: '总集数',
downloadedCount: '下载',
libraryCount: '入库',
missingCount: '缺失 {count}',
episodeList: '集数',
episodeTotal: '{count} 集',
filePath: '文件路径',
copyPath: '复制路径',
copySuccess: '路径已复制',
copyFailed: '复制失败',
noPath: '暂无路径',
noOverview: '暂无简介',
noTabFiles: '暂无{tab}',
statusInLibrary: '已入库',
statusDownloaded: '已下载',
statusMissing: '待入库',
subscribeRunning: '订阅中',
subscribePending: '待处理',
subscribePaused: '已暂停',
subscribeNew: '新订阅',
unknownTorrent: '未知种子',
downloader: '下载器',
},
subscribeHistory: {
title: '{type}订阅历史',
@@ -3196,6 +3395,7 @@ export default {
loading: '加载中...',
pageSize: '每页条数',
pageInfo: '{begin} - {end} / {total}',
selectedCount: '已选择 {count}/{total} 项',
aiRedoDisabled: '请先在系统设置中启用 AI 智能助手',
aiRedoQueued: '已提交智能助手整理任务:{title}',
aiRedoFailed: '提交智能助手整理任务失败',
@@ -3203,6 +3403,11 @@ export default {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
batchSelect: '批量选择',
exitBatchSelect: '退出批量选择',
exitBatchMode: '退出批量操作',
selectAll: '全部选中',
deselectAll: '取消全选',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',

Some files were not shown because too many files have changed in this diff Show More