mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-27 03:01:04 +08:00
Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27eeea6c0 | ||
|
|
39a9fe0541 | ||
|
|
92d153760b | ||
|
|
ff9dc26040 | ||
|
|
269da6eefe | ||
|
|
817bc1019d | ||
|
|
de8c22c180 | ||
|
|
8f074fb381 | ||
|
|
e16870859d | ||
|
|
e1ba13abf2 | ||
|
|
8d1f226035 | ||
|
|
5367a07a90 | ||
|
|
8e681dbbe7 | ||
|
|
c26c3c4284 | ||
|
|
461ed560f0 | ||
|
|
dd27bcdaca | ||
|
|
c076be868a | ||
|
|
d2634095e4 | ||
|
|
a5a3e1e1e4 | ||
|
|
09a20967c0 | ||
|
|
9a2af0a412 | ||
|
|
6bbac7db7d | ||
|
|
b212e066b6 | ||
|
|
f7d354fca4 | ||
|
|
693b081ecf | ||
|
|
d5f0fed954 | ||
|
|
67ef98bfee | ||
|
|
1094d80b9c | ||
|
|
98e6481812 | ||
|
|
7db088eb79 | ||
|
|
dec154c042 | ||
|
|
d06ce5d984 | ||
|
|
af604d0c5c | ||
|
|
fa39c3750c | ||
|
|
ce64fb03ce | ||
|
|
764f14a7f4 | ||
|
|
621200b3b2 | ||
|
|
60c46ebbaf | ||
|
|
4fe65b770e | ||
|
|
c010162e54 | ||
|
|
5cc4ea7740 | ||
|
|
8a19fbc584 | ||
|
|
24412989bb | ||
|
|
55d0693e41 | ||
|
|
0459a2d108 | ||
|
|
624b55576d | ||
|
|
c848b90599 | ||
|
|
79bc99e624 | ||
|
|
850f7ee9be | ||
|
|
4cc105287f | ||
|
|
527c0845bc | ||
|
|
2a3ef1a1f6 | ||
|
|
7539fa26aa | ||
|
|
3547c146a8 | ||
|
|
da630b0966 | ||
|
|
cff4c1fecc | ||
|
|
3f525c6457 | ||
|
|
8c1eb3e981 | ||
|
|
effc83b9a1 | ||
|
|
39ce8d93dd | ||
|
|
fa32ac9f48 | ||
|
|
ab243bbbb7 | ||
|
|
abcd3f5e81 | ||
|
|
92bf1ca790 | ||
|
|
faadf39b80 | ||
|
|
878c3c98b2 | ||
|
|
2d8ae9f462 | ||
|
|
00051a0eac | ||
|
|
88726c0f12 | ||
|
|
79bc9f0ff0 | ||
|
|
4492e211bb | ||
|
|
bb5299c812 | ||
|
|
f54a5285d6 | ||
|
|
03b80d6434 | ||
|
|
a916a1569d | ||
|
|
5c27c32914 | ||
|
|
c57a8b7b48 | ||
|
|
0aa7d37e45 | ||
|
|
d3b3b08318 | ||
|
|
5127ab6c08 | ||
|
|
a3810fb9e2 | ||
|
|
f05eac0b93 | ||
|
|
ef170b54c6 | ||
|
|
536844d291 | ||
|
|
21d4f9fd73 | ||
|
|
2aae0f08ad | ||
|
|
248984261e | ||
|
|
82ab594e0c | ||
|
|
5ca374349d | ||
|
|
7452e18fc3 | ||
|
|
75dffdb7c2 | ||
|
|
a3d74929b3 | ||
|
|
fd9c314d55 | ||
|
|
d02e98736f | ||
|
|
2e46ebe353 | ||
|
|
fffb526a87 | ||
|
|
98769a313f | ||
|
|
1c28d98d70 | ||
|
|
7a430b095a | ||
|
|
864af36b1c | ||
|
|
e3fa0b9dae | ||
|
|
ed28832484 | ||
|
|
e692a91113 | ||
|
|
7574719e04 | ||
|
|
ca800f7ae7 | ||
|
|
7d0550825b | ||
|
|
59abda63e3 | ||
|
|
0776bc9fa0 | ||
|
|
8c78f9ca90 | ||
|
|
1889d5d1e3 | ||
|
|
165247b263 | ||
|
|
9a2384bf02 | ||
|
|
71c4f587d3 | ||
|
|
8edbfa0b0e | ||
|
|
ee187a1c75 | ||
|
|
afb617724c | ||
|
|
05bbecba53 | ||
|
|
deb9b103da | ||
|
|
2d92315e17 | ||
|
|
077f0c1366 | ||
|
|
eab4caa279 | ||
|
|
3bba73abf1 | ||
|
|
8edf1f686a | ||
|
|
3440b377ee | ||
|
|
9161e5e300 | ||
|
|
25fdb09c61 | ||
|
|
25119b224d | ||
|
|
76f79e1656 | ||
|
|
b5230cef79 | ||
|
|
f871746efb | ||
|
|
d17ca69a28 | ||
|
|
e445437129 | ||
|
|
090c5476c6 | ||
|
|
646c7b33dd | ||
|
|
0902f16f84 | ||
|
|
ec4eaed1df | ||
|
|
175f610524 | ||
|
|
530fe9d35b | ||
|
|
48ed396a19 | ||
|
|
b0356c217d | ||
|
|
55eed1ecb5 | ||
|
|
50ae739a4d | ||
|
|
d9cbcc2991 | ||
|
|
ad12701fe2 | ||
|
|
2b426a47c6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
output/
|
||||
.codex-qa/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.14",
|
||||
"version": "2.14.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
// 👉 Apex chart
|
||||
.apexcharts-canvas {
|
||||
// 图表仅保留纵向页面滚动,禁止双指缩放等触摸手势。
|
||||
touch-action: pan-y;
|
||||
|
||||
// For RTL alignment
|
||||
.apexcharts-yaxis-texts-g {
|
||||
text-align: start;
|
||||
|
||||
@@ -66,6 +66,43 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
|
||||
// 格式化为Sxx
|
||||
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
|
||||
|
||||
/**
|
||||
* 格式化为 SxxExx 季集标识,多个连续集会合并为范围。
|
||||
*/
|
||||
export function formatSeasonEpisode(
|
||||
season: number | string | null | undefined,
|
||||
episodeNumbers: number[],
|
||||
): string {
|
||||
const seasonText = season === null || season === undefined || season === '' ? '' : formatSeason(String(season))
|
||||
const normalizedNumbers = [...new Set(episodeNumbers)]
|
||||
.map(number => Number(number))
|
||||
.filter(number => Number.isFinite(number) && number > 0)
|
||||
.sort((first, second) => first - second)
|
||||
|
||||
if (!normalizedNumbers.length) return seasonText
|
||||
|
||||
const formatEpisode = (number: number) => `E${String(number).padStart(2, '0')}`
|
||||
const ranges: string[] = []
|
||||
let start = normalizedNumbers[0]
|
||||
let end = normalizedNumbers[0]
|
||||
|
||||
for (let index = 1; index < normalizedNumbers.length; index++) {
|
||||
const currentNumber = normalizedNumbers[index]
|
||||
|
||||
if (currentNumber === end + 1) {
|
||||
end = currentNumber
|
||||
} else {
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
start = currentNumber
|
||||
end = currentNumber
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push(start === end ? formatEpisode(start) : `${formatEpisode(start)}-${formatEpisode(end)}`)
|
||||
|
||||
return `${seasonText}${ranges.join('、')}`
|
||||
}
|
||||
|
||||
// 格式化为xx[TGMK]B
|
||||
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
|
||||
// 负数标记
|
||||
|
||||
8
src/@layouts/types.d.ts
vendored
8
src/@layouts/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import type { Component, Ref, VNode } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
import { ContentWidth, FooterType, NavbarType } from './enums'
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -113,14 +113,18 @@ export interface NavLinkProps {
|
||||
rel?: ATagRelAttrValues
|
||||
}
|
||||
|
||||
export type NavMenuIconColor = 'primary' | 'info' | 'success' | 'warning' | 'secondary'
|
||||
|
||||
export interface NavLink extends NavLinkProps, Partial<AclProperties> {
|
||||
title: string
|
||||
full_title?: string
|
||||
icon?: unknown
|
||||
iconColor?: NavMenuIconColor
|
||||
badgeContent?: string
|
||||
badgeClass?: string
|
||||
disable?: boolean
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenuTabItem {
|
||||
@@ -128,6 +132,8 @@ export interface NavMenuTabItem {
|
||||
icon?: string
|
||||
tab: string
|
||||
description?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
export interface NavMenu extends NavLink {
|
||||
|
||||
348
src/App.vue
348
src/App.vue
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { useTheme } from 'vuetify'
|
||||
import { ensureRenderComplete, removeEl } from './@core/utils/dom'
|
||||
import api from '@/api'
|
||||
import api, { type ConnectionAwareRequestConfig } from '@/api'
|
||||
import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError, type AxiosRequestConfig } from 'axios'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores'
|
||||
import { initializeRequestOptimizer } from '@/utils/requestOptimizer'
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
})
|
||||
|
||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||
skipConnectionTracking?: boolean
|
||||
}
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -30,42 +35,68 @@ api.interceptors.request.use(config => {
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
const locale = getCurrentLocale()
|
||||
config.headers['X-MoviePilot-Locale'] = locale
|
||||
config.headers['Accept-Language'] = locale
|
||||
return config
|
||||
})
|
||||
|
||||
// 离线状态管理
|
||||
const globalOfflineStatus = useGlobalOfflineStatus()
|
||||
|
||||
/** 将 Axios 连接错误归类为全局服务探测可识别的原因。 */
|
||||
function resolveConnectionFailureReason(error: AxiosError): 'network-error' | 'timeout' | null {
|
||||
if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') return 'timeout'
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK' || error.name === 'NetworkError') {
|
||||
return 'network-error'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
interface LocalizedApiPayload {
|
||||
detail?: unknown
|
||||
detail_i18n?: unknown
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
}
|
||||
|
||||
/** 前端展示默认使用后端提供的多语言消息,同时不改变后端接口兼容字段。 */
|
||||
function normalizeLocalizedMessage(payload: any): any {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload
|
||||
|
||||
const localizedPayload = payload as LocalizedApiPayload
|
||||
if (typeof localizedPayload.message_i18n === 'string' && localizedPayload.message_i18n) {
|
||||
localizedPayload.message = localizedPayload.message_i18n
|
||||
}
|
||||
if (typeof localizedPayload.detail_i18n === 'string' && localizedPayload.detail_i18n) {
|
||||
localizedPayload.detail = localizedPayload.detail_i18n
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// 添加响应拦截器
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
// 成功响应时,清除应用离线状态并重置连续错误计数
|
||||
globalOfflineStatus.setAppOffline(false)
|
||||
globalOfflineStatus.resetConsecutiveErrors()
|
||||
return response.data
|
||||
// 任意 API 成功响应都可以证明 MoviePilot 服务当前可达。
|
||||
globalOfflineStatus.markServerOnline()
|
||||
return normalizeLocalizedMessage(response.data)
|
||||
},
|
||||
error => {
|
||||
(error: AxiosError) => {
|
||||
if (!error.response) {
|
||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||
const isNetworkError =
|
||||
error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'ERR_NETWORK' ||
|
||||
error.code === 'ECONNABORTED' ||
|
||||
error.name === 'NetworkError'
|
||||
const requestConfig = error.config as ConnectionAwareRequestConfig | undefined
|
||||
const failureReason = resolveConnectionFailureReason(error)
|
||||
|
||||
if (isNetworkError) {
|
||||
let reason = 'Network connection failed'
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
reason = 'Request timeout'
|
||||
}
|
||||
// 记录网络错误,只有连续三次才会设置为离线模式
|
||||
globalOfflineStatus.recordNetworkError(reason)
|
||||
// 普通请求失败只触发权威探测;探测请求自身失败由心跳管理器处理,避免递归。
|
||||
if (!requestConfig?.skipConnectionTracking && failureReason) {
|
||||
globalOfflineStatus.reportNetworkError(failureReason)
|
||||
}
|
||||
|
||||
if (error.code === 'NETWORK_ERROR' || error.code === 'ERR_NETWORK') {
|
||||
// 网络连接问题
|
||||
return Promise.reject(new Error('Network connection failed, please check your network status'))
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
} else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
|
||||
// 请求超时
|
||||
return Promise.reject(new Error('Request timeout, please try again later'))
|
||||
} else if (error.name === 'AbortError') {
|
||||
@@ -75,12 +106,15 @@ api.interceptors.response.use(
|
||||
// 其他网络错误
|
||||
return Promise.reject(new Error(error.message || 'Network error'))
|
||||
} else if (error.response.status === 403) {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
// 认证 Store
|
||||
const authStore = useAuthStore()
|
||||
// 清除登录状态信息
|
||||
authStore.logout()
|
||||
// token验证失败,跳转到登录页面
|
||||
router.push('/login')
|
||||
} else {
|
||||
normalizeLocalizedMessage(error.response.data)
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
|
||||
@@ -1000,6 +1000,12 @@ export interface MediaStatistic {
|
||||
episode_count: number | null
|
||||
// 用户数量
|
||||
user_count: number
|
||||
// 本月新增电影数量
|
||||
movie_count_month: number
|
||||
// 本月新增电视剧数量
|
||||
tv_count_month: number
|
||||
// 本月新增剧集数量
|
||||
episode_count_month: number
|
||||
}
|
||||
|
||||
// 后台进程
|
||||
@@ -1020,6 +1026,18 @@ export interface Process {
|
||||
memory: number
|
||||
}
|
||||
|
||||
// 仪表板系统摘要
|
||||
export interface DashboardSystemInfo {
|
||||
// 主机名称
|
||||
hostname: string
|
||||
// 操作系统名称
|
||||
operating_system: string
|
||||
// MoviePilot 运行时间,单位秒
|
||||
runtime: number
|
||||
// MoviePilot 后端版本
|
||||
version: string
|
||||
}
|
||||
|
||||
// 下载器信息
|
||||
export interface DownloaderInfo {
|
||||
// 下载速度
|
||||
@@ -1034,18 +1052,86 @@ export interface DownloaderInfo {
|
||||
free_space: number
|
||||
}
|
||||
|
||||
// 仪表板系统内存信息
|
||||
export interface DashboardMemoryInfo {
|
||||
// 总内存字节数
|
||||
total: number
|
||||
// 已使用内存字节数,不包含缓存
|
||||
used: number
|
||||
// 缓存与缓冲区占用字节数
|
||||
cached: number
|
||||
// 可用内存字节数
|
||||
available: number
|
||||
// 已使用内存占总内存百分比,不包含缓存
|
||||
usage: number
|
||||
}
|
||||
|
||||
// 定时服务进度信息
|
||||
export interface ScheduleProgress {
|
||||
// ID
|
||||
id?: string
|
||||
// 名称
|
||||
name?: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider?: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 是否正在执行
|
||||
enable?: boolean
|
||||
// 当前完成百分比
|
||||
value?: number
|
||||
// 当前进度文本
|
||||
text?: string
|
||||
// 多语言进度文本
|
||||
text_i18n?: string
|
||||
// 执行状态
|
||||
status?: string
|
||||
// 最近一次执行是否成功
|
||||
success?: boolean
|
||||
// 最近一次开始时间
|
||||
started_at?: string
|
||||
// 最近一次结束时间
|
||||
finished_at?: string
|
||||
// 最近一次错误信息
|
||||
error?: string
|
||||
// 多语言错误信息
|
||||
error_i18n?: string
|
||||
// 扩展数据
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// 定时服务信息
|
||||
export interface ScheduleInfo {
|
||||
// ID
|
||||
id: string
|
||||
// 名称
|
||||
name: string
|
||||
// 多语言名称
|
||||
name_i18n?: string
|
||||
// 提供者
|
||||
provider: string
|
||||
// 多语言提供者
|
||||
provider_i18n?: string
|
||||
// 状态
|
||||
status: string
|
||||
// 多语言状态
|
||||
status_i18n?: string
|
||||
// 下次运行时间
|
||||
next_run: string
|
||||
// 多语言下次运行时间
|
||||
next_run_i18n?: string
|
||||
// 当前完成百分比
|
||||
progress?: number
|
||||
// 进度文本
|
||||
progress_text?: string
|
||||
// 多语言进度文本
|
||||
progress_text_i18n?: string
|
||||
// 是否正在更新进度
|
||||
progress_enable?: boolean
|
||||
// 进度详情
|
||||
progress_detail?: ScheduleProgress
|
||||
}
|
||||
|
||||
// 消息通知
|
||||
@@ -1151,6 +1237,8 @@ export interface MediaServerLibrary {
|
||||
path?: string
|
||||
// 类型
|
||||
type?: string
|
||||
// 库内媒体数量
|
||||
item_count?: number
|
||||
// 图片
|
||||
image?: string
|
||||
// 图片列表
|
||||
@@ -1649,6 +1737,7 @@ export interface SubscribeShareStatistics {
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
message_i18n?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
28
src/components/agent/pet/AgentPetStage.vue
Normal file
28
src/components/agent/pet/AgentPetStage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import CssRobotRenderer from './renderers/CssRobotRenderer.vue'
|
||||
import type { AgentPetActionName, AgentPetIntent, AgentPetRendererKind } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
renderer?: AgentPetRendererKind
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
renderer: 'css-robot',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CssRobotRenderer
|
||||
v-if="props.renderer === 'css-robot'"
|
||||
:action="props.action"
|
||||
:intent="props.intent"
|
||||
:thinking="props.thinking"
|
||||
/>
|
||||
</template>
|
||||
156
src/components/agent/pet/agentPetActions.ts
Normal file
156
src/components/agent/pet/agentPetActions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { AgentPetActionDefinition, AgentPetActionName } from './types'
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTION_MIN_DELAY = 8000
|
||||
export const AGENT_PET_RANDOM_ACTION_MAX_DELAY = 18000
|
||||
|
||||
export const AGENT_PET_RANDOM_ACTIONS = [
|
||||
'wave',
|
||||
'sit',
|
||||
'eye-roll',
|
||||
'faint',
|
||||
'disassemble',
|
||||
'happy-jump',
|
||||
'sleep',
|
||||
'stretch',
|
||||
'peek',
|
||||
'scan',
|
||||
'charge',
|
||||
'spin-cheer',
|
||||
'shy',
|
||||
'confused',
|
||||
] as const
|
||||
|
||||
export const AGENT_PET_ACTIONS: Record<AgentPetActionName, AgentPetActionDefinition> = {
|
||||
wave: {
|
||||
name: 'wave',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-wave',
|
||||
duration: 2450,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sit: {
|
||||
name: 'sit',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-sit',
|
||||
duration: 4200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'eye-roll': {
|
||||
name: 'eye-roll',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-eye-roll',
|
||||
duration: 1900,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
faint: {
|
||||
name: 'faint',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-faint',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
disassemble: {
|
||||
name: 'disassemble',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-disassemble',
|
||||
duration: 6200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'happy-jump': {
|
||||
name: 'happy-jump',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-happy-jump',
|
||||
duration: 5200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
sleep: {
|
||||
name: 'sleep',
|
||||
intent: 'sleeping',
|
||||
clip: 'agent-fab-action-sleep',
|
||||
duration: 5800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
stretch: {
|
||||
name: 'stretch',
|
||||
intent: 'idle',
|
||||
clip: 'agent-fab-action-stretch',
|
||||
duration: 4400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
peek: {
|
||||
name: 'peek',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-peek',
|
||||
duration: 2800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
scan: {
|
||||
name: 'scan',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-scan',
|
||||
duration: 3200,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
charge: {
|
||||
name: 'charge',
|
||||
intent: 'thinking',
|
||||
clip: 'agent-fab-action-charge',
|
||||
duration: 4800,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
'spin-cheer': {
|
||||
name: 'spin-cheer',
|
||||
intent: 'success',
|
||||
clip: 'agent-fab-action-spin-cheer',
|
||||
duration: 3600,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
shy: {
|
||||
name: 'shy',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-shy',
|
||||
duration: 3000,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
confused: {
|
||||
name: 'confused',
|
||||
intent: 'reaction',
|
||||
clip: 'agent-fab-action-confused',
|
||||
duration: 3400,
|
||||
priority: 1,
|
||||
interruptible: true,
|
||||
},
|
||||
}
|
||||
|
||||
/** 获取指定宠物动作的播放时长。 */
|
||||
export function getAgentPetActionDuration(action: AgentPetActionName) {
|
||||
return AGENT_PET_ACTIONS[action].duration
|
||||
}
|
||||
|
||||
/** 生成下一次空闲趣味动作的随机等待时间。 */
|
||||
export function getAgentPetRandomActionDelay() {
|
||||
return (
|
||||
AGENT_PET_RANDOM_ACTION_MIN_DELAY +
|
||||
Math.round(Math.random() * (AGENT_PET_RANDOM_ACTION_MAX_DELAY - AGENT_PET_RANDOM_ACTION_MIN_DELAY))
|
||||
)
|
||||
}
|
||||
|
||||
/** 从动作池中选择一个不同于上一次的趣味动作。 */
|
||||
export function pickAgentPetRandomAction(lastAction: AgentPetActionName | null): AgentPetActionName {
|
||||
const candidates = AGENT_PET_RANDOM_ACTIONS.filter(action => action !== lastAction)
|
||||
|
||||
return candidates[Math.floor(Math.random() * candidates.length)] || AGENT_PET_RANDOM_ACTIONS[0]
|
||||
}
|
||||
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
44
src/components/agent/pet/renderers/CssRobotRenderer.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { AgentPetActionName, AgentPetIntent } from '../types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action?: AgentPetActionName | null
|
||||
intent?: AgentPetIntent
|
||||
thinking?: boolean
|
||||
}>(),
|
||||
{
|
||||
action: null,
|
||||
intent: 'idle',
|
||||
thinking: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="agent-assistant-fab__bot"
|
||||
:data-agent-pet-action="props.action || undefined"
|
||||
:data-agent-pet-intent="props.intent"
|
||||
:data-agent-pet-thinking="props.thinking ? 'true' : undefined"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="agent-assistant-fab__antenna" />
|
||||
<span class="agent-assistant-fab__head">
|
||||
<span class="agent-assistant-fab__face">
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--left" />
|
||||
<span class="agent-assistant-fab__eye agent-assistant-fab__eye--right" />
|
||||
<span class="agent-assistant-fab__smile" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="agent-assistant-fab__body">
|
||||
<span class="agent-assistant-fab__core" />
|
||||
</span>
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--left" />
|
||||
<span class="agent-assistant-fab__arm agent-assistant-fab__arm--right" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--left" />
|
||||
<span class="agent-assistant-fab__leg agent-assistant-fab__leg--right" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style src="./cssRobot.scss" lang="scss"></style>
|
||||
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
100
src/components/agent/pet/renderers/actions/_charge.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-charge-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-charge-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-charge-face 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-charge-core 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-action-charge .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-charge-arm 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18%,
|
||||
42%,
|
||||
66% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translateY(-0.12rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
54%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(-20deg)
|
||||
scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
20%,
|
||||
84% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.9rem rgba(166, 118, 255, 56%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1.32);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-charge-arm {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.12rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
101
src/components/agent/pet/renderers/actions/_confused.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-confused-antenna 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-confused-head 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-confused-eye 0.85s ease-in-out 4;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-confused-arm-left 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-confused .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-confused-arm-right 3.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(58deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-28deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(42deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-10deg);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(10deg);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(0.22rem, -0.16rem);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(-0.22rem, 0.08rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(-0.12rem, -0.12rem) rotate(88deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-confused-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
74% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-42deg);
|
||||
}
|
||||
}
|
||||
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
195
src/components/agent/pet/renderers/actions/_disassemble.scss
Normal file
@@ -0,0 +1,195 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-disassemble-bot 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-disassemble-antenna 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-disassemble-head 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-disassemble-body 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-disassemble-arm-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-disassemble-arm-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-disassemble-leg-left 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-disassemble .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-disassemble-leg-right 6.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-bot {
|
||||
/* 34%-70% 让散落状态停留片刻,再用 84% 的轻微过冲表现回装吸附感。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
12% {
|
||||
transform: translateY(-0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(-5deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
72% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) rotate(0deg);
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(-0.04rem) scale(var(--agent-assistant-bot-scale)) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(0.02rem, -0.14rem) rotate(44deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(0.96rem, 3.42rem) rotate(268deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.24rem)) rotate(-8deg);
|
||||
}
|
||||
|
||||
32%,
|
||||
70% {
|
||||
transform: translate(-1.18rem, 2.72rem) rotate(-46deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.1rem) rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) rotate(4deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.26rem, 1.56rem) rotate(18deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.05rem, -0.08rem) rotate(-5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.16rem, -0.08rem) rotate(78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-1.72rem, 1.84rem) rotate(248deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.08rem, -0.04rem) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(0.16rem, -0.08rem) rotate(-78deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(1.58rem, 1.72rem) rotate(-238deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.08rem, -0.04rem) rotate(4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(-0.12rem, 0.18rem) rotate(34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(-0.98rem, 0.96rem) rotate(112deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(0.07rem, -0.08rem) rotate(-8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-disassemble-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(0.12rem, 0.18rem) rotate(-34deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
70% {
|
||||
transform: translate(0.92rem, 1.04rem) rotate(-118deg);
|
||||
}
|
||||
|
||||
84% {
|
||||
transform: translate(-0.07rem, -0.08rem) rotate(8deg);
|
||||
}
|
||||
}
|
||||
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
50
src/components/agent/pet/renderers/actions/_eye-roll.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-eye-roll-head 1.9s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-eye-roll .agent-assistant-fab__eye {
|
||||
animation:
|
||||
agent-fab-blink 4.8s ease-in-out infinite,
|
||||
agent-fab-action-eye-roll 0.95s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-6deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(5deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.03rem)) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-eye-roll {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
22% {
|
||||
transform: translate(0.22rem, -0.2rem);
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translate(0, -0.3rem);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.22rem, -0.2rem);
|
||||
}
|
||||
}
|
||||
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
138
src/components/agent/pet/renderers/actions/_faint.scss
Normal file
@@ -0,0 +1,138 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-faint-bot 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-faint-antenna 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-faint-head 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-faint-body 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-faint-arm-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-faint-arm-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-faint-leg-left 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-faint .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-faint-leg-right 4.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale)) rotate(12deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.58rem, 1.08rem) scale(var(--agent-assistant-bot-scale)) rotate(98deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.08rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
28% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(9deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.04rem, 0.02rem) scaleY(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.12rem, 0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.12rem, 0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(-0.24rem, -0.02rem) rotate(82deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-faint-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
34%,
|
||||
86% {
|
||||
transform: translate(0.24rem, -0.02rem) rotate(-82deg);
|
||||
}
|
||||
}
|
||||
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
309
src/components/agent/pet/renderers/actions/_happy-jump.scss
Normal file
@@ -0,0 +1,309 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-happy-jump-bot 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-happy-jump-antenna 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-happy-jump-head 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-happy-jump-eye 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-happy-jump-smile 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-happy-jump-body 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-happy-jump-arm-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-happy-jump-leg-left 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-happy-jump .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-happy-jump-leg-right 5.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-bot {
|
||||
/* 三段蓄力、起跳、落地,让开心连跳比单纯上下移动更像真人动作。 */
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 3deg));
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateY(-0.76rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 4deg));
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translateY(0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 2deg));
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translateY(-0.1rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 1deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(34deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-18deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(38deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.16rem)) rotate(7deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.04rem)) rotate(-5deg);
|
||||
}
|
||||
|
||||
92% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1, 1);
|
||||
}
|
||||
|
||||
8%,
|
||||
36%,
|
||||
64%,
|
||||
92% {
|
||||
border-block-end-width: 0.18rem;
|
||||
transform: translate(0, -0.06rem) scale(1.08, 0.72);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.16, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
6%,
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translateX(-50%) translateY(0.02rem) scale(0.92, 0.82);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translateX(-50%) translateY(-0.04rem) scale(1.22, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.18rem)) scaleY(0.84)
|
||||
rotate(-2deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.1rem)) scaleY(1.08)
|
||||
rotate(4deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.08rem)) scaleY(0.94)
|
||||
rotate(-3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, 0.06rem) rotate(54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.24rem, -0.38rem) rotate(152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.1rem, -0.02rem) rotate(96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, 0.06rem) rotate(-54deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.24rem, -0.38rem) rotate(-152deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.1rem, -0.02rem) rotate(-96deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(0.08rem, -0.22rem) rotate(82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(-0.18rem, 0.08rem) rotate(-32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(0.12rem, -0.08rem) rotate(44deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-happy-jump-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
9%,
|
||||
37%,
|
||||
65% {
|
||||
transform: translate(-0.08rem, -0.22rem) rotate(-82deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
48%,
|
||||
76% {
|
||||
transform: translate(0.18rem, 0.08rem) rotate(32deg);
|
||||
}
|
||||
|
||||
29%,
|
||||
57%,
|
||||
85% {
|
||||
transform: translate(-0.12rem, -0.08rem) rotate(-44deg);
|
||||
}
|
||||
}
|
||||
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
106
src/components/agent/pet/renderers/actions/_peek.scss
Normal file
@@ -0,0 +1,106 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-peek-bot 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-peek-antenna 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-peek-head 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-peek-eye 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-peek-arm-left 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-peek .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-peek-arm-right 2.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translateX(-0.48rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateX(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 11deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.12rem, -0.06rem) rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.18rem), calc(var(--agent-assistant-head-y) - 0.04rem))
|
||||
rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.18rem, -0.02rem) scale(1.12, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(-0.24rem, -0.12rem) rotate(116deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-peek-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
26%,
|
||||
78% {
|
||||
transform: translate(0.04rem, 0.06rem) rotate(-34deg);
|
||||
}
|
||||
}
|
||||
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
84
src/components/agent/pet/renderers/actions/_scan.scss
Normal file
@@ -0,0 +1,84 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-scan-head 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__face {
|
||||
animation: agent-fab-action-scan-face 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-scan-eye 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-scan .agent-assistant-fab__core {
|
||||
animation: agent-fab-action-scan-core 3.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(-8deg);
|
||||
}
|
||||
|
||||
52% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(8deg);
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.04rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-face {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
}
|
||||
|
||||
18%,
|
||||
78% {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.72rem rgba(114, 255, 240, 48%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-eye {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(-0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
46% {
|
||||
transform: translate(0.28rem, -0.02rem) scale(1.1, 0.9);
|
||||
}
|
||||
|
||||
74% {
|
||||
transform: translate(0, -0.18rem) scale(1.18, 0.72);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-scan-core {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
25%,
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: scale(1.24);
|
||||
}
|
||||
}
|
||||
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
14
src/components/agent/pet/renderers/actions/_shared.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__antenna,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__head,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__face,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__body,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__core,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__arm,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__leg,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__eye,
|
||||
.agent-assistant-fab[class*='is-action-'] .agent-assistant-fab__smile {
|
||||
transition: none;
|
||||
}
|
||||
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
88
src/components/agent/pet/renderers/actions/_shy.scss
Normal file
@@ -0,0 +1,88 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-shy-bot 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-shy-head 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-shy-eye 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-left 3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-shy .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-shy-arm-right 3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translateX(0.16rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 7deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) + 0.08rem), calc(var(--agent-assistant-head-y) + 0.14rem))
|
||||
rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-eye {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
opacity: 0.78;
|
||||
transform: translate(0, 0.08rem) scale(0.86, 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(0.02rem, -0.16rem) rotate(118deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-shy-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
24%,
|
||||
78% {
|
||||
transform: translate(-0.02rem, -0.16rem) rotate(-118deg);
|
||||
}
|
||||
}
|
||||
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
116
src/components/agent/pet/renderers/actions/_sit.scss
Normal file
@@ -0,0 +1,116 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sit-bot 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sit-head 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sit-body 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sit-arm-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sit-arm-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--left {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-left 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sit .agent-assistant-fab__leg--right {
|
||||
z-index: 5;
|
||||
animation: agent-fab-action-sit-leg-right 4.2s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) + 0.1rem)) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.22rem)) scaleY(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.08rem, 0.22rem) rotate(76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.08rem, 0.22rem) rotate(-76deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(0.18rem, -0.18rem) rotate(94deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sit-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
84% {
|
||||
transform: translate(-0.18rem, -0.18rem) rotate(-94deg);
|
||||
}
|
||||
}
|
||||
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
144
src/components/agent/pet/renderers/actions/_sleep.scss
Normal file
@@ -0,0 +1,144 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-sleep-bot 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-sleep-antenna 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-sleep-head 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-sleep-eye 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-sleep-body 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-action-sleep-arm-left 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-sleep-arm-right 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-sleep .agent-assistant-fab__leg {
|
||||
animation: agent-fab-action-sleep-leg 5.8s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translateY(0.42rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 8deg));
|
||||
}
|
||||
|
||||
46%,
|
||||
64% {
|
||||
transform: translateY(0.36rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 10deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.62;
|
||||
transform: translate(0.1rem, 0.16rem) rotate(78deg) scale(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(calc(var(--agent-assistant-head-x) - 0.12rem), calc(var(--agent-assistant-head-y) + 0.22rem))
|
||||
rotate(-12deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
opacity: 1;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
18%,
|
||||
86% {
|
||||
border-block-end-width: 0.1rem;
|
||||
opacity: 0.62;
|
||||
transform: translate(0, 0.1rem) scale(1.18, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.26rem)) scaleY(0.78);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(0.12rem, 0.2rem) rotate(92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
transform: translate(-0.12rem, 0.2rem) rotate(-92deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-sleep-leg {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
82% {
|
||||
opacity: 0.35;
|
||||
transform: translateY(-0.16rem) scale(0.76);
|
||||
}
|
||||
}
|
||||
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
130
src/components/agent/pet/renderers/actions/_spin-cheer.scss
Normal file
@@ -0,0 +1,130 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-spin-cheer-bot 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-spin-cheer-antenna 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-spin-cheer-head 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__eye {
|
||||
animation: agent-fab-action-spin-cheer-eye 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__smile {
|
||||
animation: agent-fab-action-spin-cheer-smile 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-left 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-spin-cheer .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-spin-cheer-arm-right 3.6s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) - 7deg));
|
||||
}
|
||||
|
||||
48% {
|
||||
transform: translateY(-0.58rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 184deg));
|
||||
}
|
||||
|
||||
78% {
|
||||
transform: translateY(-0.14rem) scale(var(--agent-assistant-bot-scale))
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 356deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.18rem)) rotate(-28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
22%,
|
||||
78% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.12rem)) rotate(8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-eye {
|
||||
0%,
|
||||
100% {
|
||||
border-block-end-width: 0.15rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y)) scale(1);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
border-block-end-width: 0.19rem;
|
||||
transform: translate(0, -0.12rem) scale(1.18, 0.58);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-smile {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
}
|
||||
|
||||
16%,
|
||||
84% {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-0.03rem) scale(1.24, 1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(-0.28rem, -0.44rem) rotate(148deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-spin-cheer-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
20%,
|
||||
80% {
|
||||
transform: translate(0.28rem, -0.44rem) rotate(-148deg);
|
||||
}
|
||||
}
|
||||
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
146
src/components/agent/pet/renderers/actions/_stretch.scss
Normal file
@@ -0,0 +1,146 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-stretch-bot 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__antenna {
|
||||
animation: agent-fab-action-stretch-antenna 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-stretch-head 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__body {
|
||||
animation: agent-fab-action-stretch-body 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__arm--right {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-stretch-arm-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-action-stretch-leg-left 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-stretch .agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-action-stretch-leg-right 4.4s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translateY(0.18rem) scale(var(--agent-assistant-bot-scale)) scaleY(0.92)
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translateY(-0.34rem) scale(var(--agent-assistant-bot-scale)) scaleY(1.06)
|
||||
rotate(calc(var(--agent-assistant-robot-tilt) + 2deg));
|
||||
}
|
||||
|
||||
86% {
|
||||
transform: translateY(0.08rem) scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-antenna {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.2rem)) rotate(-24deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.28rem)) rotate(5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-body {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
18% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.12rem)) scaleY(0.88);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) - 0.08rem)) scaleY(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.24rem, -0.7rem) rotate(168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.24rem, -0.7rem) rotate(-168deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(-0.18rem, 0.02rem) rotate(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-stretch-leg-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
44%,
|
||||
70% {
|
||||
transform: translate(0.18rem, 0.02rem) rotate(18deg);
|
||||
}
|
||||
}
|
||||
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
87
src/components/agent/pet/renderers/actions/_wave.scss
Normal file
@@ -0,0 +1,87 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__bot {
|
||||
animation: agent-fab-action-wave-bot 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__head {
|
||||
animation: agent-fab-action-wave-head 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--left {
|
||||
z-index: 6;
|
||||
animation: agent-fab-action-wave-arm-left 2.3s ease-in-out both;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-action-wave .agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-action-wave-arm-right 2.3s ease-in-out both;
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-bot {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(calc(var(--agent-assistant-robot-tilt) - 4deg));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-head {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
66% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.1rem)) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-left {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
16% {
|
||||
transform: translate(-0.08rem, -0.06rem) rotate(64deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
44% {
|
||||
transform: translate(-0.18rem, -0.2rem) rotate(92deg);
|
||||
}
|
||||
|
||||
58% {
|
||||
transform: translate(-0.26rem, -0.28rem) rotate(132deg);
|
||||
}
|
||||
|
||||
72% {
|
||||
transform: translate(-0.1rem, -0.08rem) rotate(64deg);
|
||||
}
|
||||
|
||||
88% {
|
||||
transform: translate(-0.02rem, -0.02rem) rotate(28deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-action-wave-arm-right {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
30%,
|
||||
70% {
|
||||
transform: rotate(-28deg) translateY(0.06rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@use './shared';
|
||||
@use './wave';
|
||||
@use './sit';
|
||||
@use './eye-roll';
|
||||
@use './faint';
|
||||
@use './disassemble';
|
||||
@use './happy-jump';
|
||||
@use './sleep';
|
||||
@use './stretch';
|
||||
@use './peek';
|
||||
@use './scan';
|
||||
@use './charge';
|
||||
@use './spin-cheer';
|
||||
@use './shy';
|
||||
@use './confused';
|
||||
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
2
src/components/agent/pet/renderers/cssRobot.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@use './cssRobotBase';
|
||||
@use './actions/cssRobotActions';
|
||||
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
484
src/components/agent/pet/renderers/cssRobotBase.scss
Normal file
@@ -0,0 +1,484 @@
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
/* stylelint-disable no-duplicate-selectors */
|
||||
|
||||
.agent-assistant-fab__bot,
|
||||
.agent-assistant-fab__bot span {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 4.7rem;
|
||||
filter: drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow));
|
||||
inline-size: 3.85rem;
|
||||
inset-block-end: 0.1rem;
|
||||
inset-inline-end: 1.42rem;
|
||||
transform: scale(var(--agent-assistant-bot-scale)) rotate(var(--agent-assistant-robot-tilt));
|
||||
transform-origin: 50% 72%;
|
||||
transition:
|
||||
inset-inline-end 0.24s ease,
|
||||
filter 0.18s ease,
|
||||
transform 0.14s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
animation: agent-fab-antenna-idle 3.9s ease-in-out infinite;
|
||||
background: var(--agent-assistant-robot-outline);
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.18rem;
|
||||
inset-block-start: 0.72rem;
|
||||
inset-inline-start: 2.62rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
transform-origin: bottom center;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__antenna::after {
|
||||
position: absolute;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 999px;
|
||||
background: var(--agent-assistant-robot-shell-start);
|
||||
block-size: 0.38rem;
|
||||
content: '';
|
||||
inline-size: 0.38rem;
|
||||
inset-block-start: -0.34rem;
|
||||
inset-inline-start: -0.13rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__head {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 11px;
|
||||
animation: agent-fab-head-idle 4.6s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-start) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 100%
|
||||
);
|
||||
block-size: 2.05rem;
|
||||
box-shadow:
|
||||
inset 0 -0.2rem 0 rgba(54, 0, 126, 26%),
|
||||
inset 0.15rem 0.14rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 2.82rem;
|
||||
inset-block-start: 1.42rem;
|
||||
inset-inline-start: 0.88rem;
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y));
|
||||
transform-origin: 50% 85%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__face {
|
||||
position: absolute;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--agent-assistant-robot-outline-soft);
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--agent-assistant-robot-face-start) 0%,
|
||||
var(--agent-assistant-robot-face-end) 100%
|
||||
);
|
||||
block-size: 1.28rem;
|
||||
box-shadow: inset 0 0.1rem 0 rgba(255, 255, 255, 8%);
|
||||
inline-size: 2.1rem;
|
||||
inset-block-start: 0.33rem;
|
||||
inset-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
animation: agent-fab-blink 4.8s ease-in-out infinite;
|
||||
block-size: 0.42rem;
|
||||
border-block-end: 0.15rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 0.36rem;
|
||||
transform: translate(var(--agent-assistant-eye-x), var(--agent-assistant-eye-y));
|
||||
|
||||
/* 触屏设备没有连续 hover 轨迹,给眼神位移补过渡避免点按时瞬移。 */
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--left {
|
||||
inset-inline-start: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__eye--right {
|
||||
inset-inline-end: 0.43rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__smile {
|
||||
position: absolute;
|
||||
display: block;
|
||||
border-radius: 0 0 999px 999px;
|
||||
block-size: 0.32rem;
|
||||
border-block-end: 0.13rem solid var(--agent-assistant-robot-eye);
|
||||
inline-size: 0.7rem;
|
||||
inset-block-start: 0.75rem;
|
||||
inset-inline-start: 50%;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.72);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__body {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
border-radius: 0.65rem 0.65rem 0.55rem 0.55rem;
|
||||
animation: agent-fab-body-idle 4.2s ease-in-out infinite;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-shell-end) 82%
|
||||
);
|
||||
block-size: 1.34rem;
|
||||
box-shadow:
|
||||
inset 0 -0.18rem 0 rgba(54, 0, 126, 24%),
|
||||
inset 0.16rem 0.12rem 0 rgba(255, 255, 255, 24%);
|
||||
inline-size: 1.88rem;
|
||||
inset-block-start: 3.24rem;
|
||||
inset-inline-start: 1.32rem;
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y));
|
||||
transform-origin: 50% 18%;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core {
|
||||
position: absolute;
|
||||
display: block;
|
||||
background: transparent;
|
||||
block-size: 100%;
|
||||
box-shadow: none;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__core::before {
|
||||
position: absolute;
|
||||
background: var(--agent-assistant-robot-play);
|
||||
block-size: 0.46rem;
|
||||
clip-path: polygon(18% 8%, 18% 92%, 90% 50%);
|
||||
content: '';
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-start: 50%;
|
||||
transform: translate(-42%, -50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm,
|
||||
.agent-assistant-fab__leg {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: block;
|
||||
border: 2px solid var(--agent-assistant-robot-outline);
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--agent-assistant-robot-shell-mid) 0%,
|
||||
var(--agent-assistant-robot-outline-soft) 100%
|
||||
);
|
||||
box-shadow: inset 0 -0.12rem 0 rgba(54, 0, 126, 24%);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.22s ease;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm {
|
||||
border-radius: 999px;
|
||||
block-size: 1rem;
|
||||
inline-size: 0.46rem;
|
||||
inset-block-start: 3.3rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--left {
|
||||
animation: agent-fab-arm-left-idle 3.8s ease-in-out infinite;
|
||||
inset-inline-start: 0.9rem;
|
||||
transform: rotate(17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__arm--right {
|
||||
animation: agent-fab-arm-right-idle 4.1s ease-in-out infinite;
|
||||
inset-inline-start: 3.08rem;
|
||||
transform: rotate(-17deg);
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg {
|
||||
border-radius: 0.35rem;
|
||||
block-size: 0.66rem;
|
||||
inline-size: 0.48rem;
|
||||
inset-block-start: 4.36rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--left {
|
||||
animation: agent-fab-leg-left-idle 4.8s ease-in-out infinite;
|
||||
inset-inline-start: 1.48rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__leg--right {
|
||||
animation: agent-fab-leg-right-idle 4.8s ease-in-out 0.35s infinite;
|
||||
inset-inline-start: 2.46rem;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.agent-assistant-fab.is-bubble-visible .agent-assistant-fab__bubble:hover {
|
||||
box-shadow: var(--app-surface-hover-shadow);
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble:hover .agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:hover .agent-assistant-fab__bot {
|
||||
filter: drop-shadow(0 0.7rem 0.7rem var(--agent-assistant-robot-shadow-strong));
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.agent-assistant-fab__bubble-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger:focus-visible .agent-assistant-fab__bot {
|
||||
filter:
|
||||
drop-shadow(0 0.55rem 0.55rem var(--agent-assistant-robot-shadow))
|
||||
drop-shadow(0 0 0.34rem rgba(var(--v-theme-primary), 0.55));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-pressed .agent-assistant-fab__bot {
|
||||
transform: translateY(0.22rem) scale(var(--agent-assistant-bot-pressed-scale))
|
||||
rotate(var(--agent-assistant-robot-tilt));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__face {
|
||||
box-shadow:
|
||||
inset 0 0.1rem 0 rgba(255, 255, 255, 8%),
|
||||
0 0 0.65rem rgba(114, 255, 240, 50%);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-thinking .agent-assistant-fab__core {
|
||||
animation: agent-fab-core-pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.42rem;
|
||||
transform: translateY(-0.2rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__eye {
|
||||
transform: translate(calc(var(--agent-assistant-eye-x) * 0.24 - 0.22rem), calc(var(--agent-assistant-eye-y) * 0.24));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__body,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__leg {
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left,
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--right {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
transform: translateX(0.8rem) scale(0.72);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__arm--left {
|
||||
z-index: 5;
|
||||
block-size: 0.95rem;
|
||||
inline-size: 0.42rem;
|
||||
inset-block-start: 3.62rem;
|
||||
inset-inline-start: 1.86rem;
|
||||
opacity: 1;
|
||||
transform: rotate(78deg) scale(0.9);
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__antenna {
|
||||
animation: none;
|
||||
opacity: 0.75;
|
||||
transform: translate(0.34rem, 0.02rem) rotate(2deg) scale(0.82);
|
||||
}
|
||||
|
||||
@keyframes agent-fab-head-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), calc(var(--agent-assistant-head-y) - 0.06rem)) rotate(-1.8deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-body-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-body-x), var(--agent-assistant-body-y)) scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-body-x), calc(var(--agent-assistant-body-y) + 0.04rem)) scaleY(0.97);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-antenna-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(22deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(var(--agent-assistant-head-x), var(--agent-assistant-head-y)) rotate(15deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(12deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-arm-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-17deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-11deg) translateY(0.05rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-left-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-leg-right-idle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(-4deg) translateY(0.03rem);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-core-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.78;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes agent-fab-blink {
|
||||
0%,
|
||||
4%,
|
||||
8%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
scale: 1 1;
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 0.45;
|
||||
scale: 1 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.agent-assistant-fab {
|
||||
block-size: 6.65rem;
|
||||
inline-size: min(12.4rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked {
|
||||
inline-size: 3.45rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubbles {
|
||||
inline-size: min(16.5rem, calc(100vw - 5.6rem));
|
||||
max-block-size: min(30rem, calc(100vh - 9.2rem));
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-stack {
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble {
|
||||
padding-block: 0.56rem;
|
||||
padding-inline: 0.72rem 1.62rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble-title {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bubble > span {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__trigger {
|
||||
block-size: 4.8rem;
|
||||
inline-size: 5rem;
|
||||
}
|
||||
|
||||
.agent-assistant-fab__bot {
|
||||
inset-inline-end: 1.02rem;
|
||||
transform-origin: 70% 78%;
|
||||
}
|
||||
|
||||
.agent-assistant-fab {
|
||||
--agent-assistant-bot-scale: 0.82;
|
||||
--agent-assistant-bot-pressed-scale: 0.78;
|
||||
}
|
||||
|
||||
.agent-assistant-fab.is-docked .agent-assistant-fab__bot {
|
||||
inset-inline-end: -0.72rem;
|
||||
transform: translateY(-0.16rem) scale(var(--agent-assistant-bot-scale)) rotate(-19deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-assistant-fab,
|
||||
.agent-assistant-fab * {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
41
src/components/agent/pet/types.ts
Normal file
41
src/components/agent/pet/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type AgentPetActionName =
|
||||
| 'wave'
|
||||
| 'sit'
|
||||
| 'eye-roll'
|
||||
| 'faint'
|
||||
| 'disassemble'
|
||||
| 'happy-jump'
|
||||
| 'sleep'
|
||||
| 'stretch'
|
||||
| 'peek'
|
||||
| 'scan'
|
||||
| 'charge'
|
||||
| 'spin-cheer'
|
||||
| 'shy'
|
||||
| 'confused'
|
||||
|
||||
export type AgentPetIntent =
|
||||
| 'idle'
|
||||
| 'thinking'
|
||||
| 'speaking'
|
||||
| 'notify'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error'
|
||||
| 'dragging'
|
||||
| 'docked'
|
||||
| 'sleeping'
|
||||
| 'reaction'
|
||||
|
||||
export type AgentPetRendererKind = 'css-robot'
|
||||
|
||||
export interface AgentPetActionDefinition {
|
||||
name: AgentPetActionName
|
||||
intent: AgentPetIntent
|
||||
clip: string
|
||||
duration: number
|
||||
priority: number
|
||||
interruptible: boolean
|
||||
cooldown?: number
|
||||
next?: AgentPetActionName
|
||||
}
|
||||
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
127
src/components/agent/pet/useAgentPetMachine.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
|
||||
import {
|
||||
getAgentPetActionDuration,
|
||||
getAgentPetRandomActionDelay,
|
||||
pickAgentPetRandomAction,
|
||||
} from './agentPetActions'
|
||||
import type { AgentPetActionName } from './types'
|
||||
|
||||
interface AgentPetMachineOptions {
|
||||
active: MaybeRefOrGetter<boolean>
|
||||
thinking: MaybeRefOrGetter<boolean>
|
||||
docked: Ref<boolean>
|
||||
dragging: Ref<boolean>
|
||||
pressed: Ref<boolean>
|
||||
shouldAutoDock: () => boolean
|
||||
scheduleAutoDock: () => void
|
||||
}
|
||||
|
||||
/** 管理桌面宠物的空闲动作队列,避免入口组件直接维护动画计时器。 */
|
||||
export function useAgentPetMachine(options: AgentPetMachineOptions) {
|
||||
const currentAction = ref<AgentPetActionName | null>(null)
|
||||
|
||||
let lastAction: AgentPetActionName | null = null
|
||||
let randomActionTimer: number | null = null
|
||||
let actionEndTimer: number | null = null
|
||||
|
||||
/** 判断当前交互状态是否适合播放空闲趣味动作。 */
|
||||
function canRunRandomAction() {
|
||||
return (
|
||||
toValue(options.active) &&
|
||||
!options.docked.value &&
|
||||
!options.dragging.value &&
|
||||
!options.pressed.value &&
|
||||
!toValue(options.thinking)
|
||||
)
|
||||
}
|
||||
|
||||
/** 清理等待中的随机动作计时器。 */
|
||||
function clearRandomActionTimer() {
|
||||
if (randomActionTimer === null) return
|
||||
|
||||
window.clearTimeout(randomActionTimer)
|
||||
randomActionTimer = null
|
||||
}
|
||||
|
||||
/** 清理正在播放动作的结束计时器。 */
|
||||
function clearActionEndTimer() {
|
||||
if (actionEndTimer === null) return
|
||||
|
||||
window.clearTimeout(actionEndTimer)
|
||||
actionEndTimer = null
|
||||
}
|
||||
|
||||
/** 停止当前动作并清理全部宠物动作计时器。 */
|
||||
function clearAction() {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
}
|
||||
|
||||
/** 完成当前动作后恢复空闲态,并按贴边规则决定是否自动收起。 */
|
||||
function finishRandomAction() {
|
||||
clearActionEndTimer()
|
||||
currentAction.value = null
|
||||
|
||||
if (!options.docked.value && options.shouldAutoDock()) {
|
||||
options.scheduleAutoDock()
|
||||
return
|
||||
}
|
||||
|
||||
scheduleRandomAction()
|
||||
}
|
||||
|
||||
/** 立即播放指定动作,供通知、交互或外部 renderer 编排主动触发。 */
|
||||
function playAction(action: AgentPetActionName) {
|
||||
clearRandomActionTimer()
|
||||
clearActionEndTimer()
|
||||
currentAction.value = action
|
||||
actionEndTimer = window.setTimeout(finishRandomAction, getAgentPetActionDuration(action))
|
||||
}
|
||||
|
||||
/** 播放一个随机趣味动作,由 renderer 根据动作名呈现具体动画。 */
|
||||
function runRandomAction() {
|
||||
if (!canRunRandomAction()) return
|
||||
|
||||
const action = pickAgentPetRandomAction(lastAction)
|
||||
|
||||
lastAction = action
|
||||
playAction(action)
|
||||
}
|
||||
|
||||
/** 安排下一次随机动作,只在宠物可见且空闲时生效。 */
|
||||
function scheduleRandomAction() {
|
||||
clearRandomActionTimer()
|
||||
if (!canRunRandomAction() || currentAction.value || actionEndTimer !== null) return
|
||||
|
||||
randomActionTimer = window.setTimeout(() => {
|
||||
randomActionTimer = null
|
||||
runRandomAction()
|
||||
}, getAgentPetRandomActionDelay())
|
||||
}
|
||||
|
||||
/** 根据入口交互状态同步随机动作队列。 */
|
||||
function syncSchedule() {
|
||||
if (canRunRandomAction()) {
|
||||
if (!currentAction.value && randomActionTimer === null && actionEndTimer === null) scheduleRandomAction()
|
||||
return
|
||||
}
|
||||
|
||||
clearAction()
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => toValue(options.active), () => toValue(options.thinking), options.docked, options.dragging, options.pressed],
|
||||
syncSchedule,
|
||||
)
|
||||
|
||||
onScopeDispose(clearAction)
|
||||
|
||||
return {
|
||||
clearAction,
|
||||
currentAction,
|
||||
playAction,
|
||||
scheduleRandomAction,
|
||||
syncSchedule,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@ const props = defineProps({
|
||||
const imageLoaded = ref(false)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 图片加载完成响应
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
@@ -46,22 +52,30 @@ const getImgUrl = computed(() => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="backdrop-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': imageLoaded,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="getImgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="getImgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="backdrop-card-image"
|
||||
:class="{ 'backdrop-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="backdrop-card-placeholder">
|
||||
<VSkeletonLoader class="backdrop-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
@@ -86,7 +100,43 @@ const getImgUrl = computed(() => {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.backdrop-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-placeholder,
|
||||
.backdrop-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.backdrop-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -73,16 +73,16 @@ async function deleteDownload() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="cardState"
|
||||
v-bind="hover.props"
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
>
|
||||
<template #image>
|
||||
<VImg
|
||||
:src="props.info?.media.image"
|
||||
@@ -130,7 +130,8 @@ async function deleteDownload() {
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
@@ -138,6 +139,10 @@ async function deleteDownload() {
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -25,18 +25,51 @@ const imageLoaded = ref(false)
|
||||
// 图片是否加载错误
|
||||
const imageError = ref(false)
|
||||
|
||||
// 图片加载完成响应
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '3 / 2',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 媒体库内条目数量,兼容不同后端字段。
|
||||
const libraryItemCount = computed(() => props.media?.item_count)
|
||||
|
||||
// 是否展示右上角数量折角。
|
||||
const showCountCorner = computed(() => typeof libraryItemCount.value === 'number' && libraryItemCount.value >= 0)
|
||||
|
||||
// 右上角数量文案。
|
||||
const countLabel = computed(() => {
|
||||
if (!showCountCorner.value) return ''
|
||||
return formatLibraryCount(libraryItemCount.value || 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 格式化右上角媒体库数量。
|
||||
* @param count 媒体库内媒体数量
|
||||
*/
|
||||
function formatLibraryCount(count: number) {
|
||||
if (count >= 10000) return `${(count / 10000).toFixed(1).replace(/\.0$/, '')}万`
|
||||
return `${count}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 图片加载错误
|
||||
/**
|
||||
* 标记封面加载失败并切换默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageError.value = true
|
||||
imgUrl.value = getDefaultImage()
|
||||
}
|
||||
|
||||
// 默认图片
|
||||
/**
|
||||
* 获取媒体服务器默认封面图。
|
||||
*/
|
||||
function getDefaultImage() {
|
||||
if (props.media?.server_type === 'plex') return plex
|
||||
else if (props.media?.server_type === 'emby') return emby
|
||||
@@ -47,14 +80,20 @@ function getDefaultImage() {
|
||||
else return plex
|
||||
}
|
||||
|
||||
// 跳转播放
|
||||
/**
|
||||
* 跳转到媒体服务器媒体库页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图片代理路径
|
||||
/**
|
||||
* 生成图片代理路径。
|
||||
* @param url 原始图片地址
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
if (!url || imageError.value) return getDefaultImage()
|
||||
let imgurl = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
@@ -64,7 +103,11 @@ function getImgUrl(url: string, use_cookies?: boolean) {
|
||||
return imgurl
|
||||
}
|
||||
|
||||
// 根据多张图片生成媒体库封面
|
||||
/**
|
||||
* 根据多张图片生成媒体库封面。
|
||||
* @param imageList 媒体库封面候选图列表
|
||||
* @param use_cookies 是否携带 Cookie 代理图片
|
||||
*/
|
||||
async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 图片
|
||||
const IMAGES = [...imageList]
|
||||
@@ -96,7 +139,11 @@ async function drawImages(imageList: string[], use_cookies?: boolean) {
|
||||
// 设置背景色为透明
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制图片
|
||||
/**
|
||||
* 绘制单张海报及其倒影。
|
||||
* @param imgSrc 海报图片地址
|
||||
* @param index 海报位置
|
||||
*/
|
||||
async function drawImageWithReflection(imgSrc: string, index: number) {
|
||||
if (!canvas) return
|
||||
|
||||
@@ -156,35 +203,162 @@ onMounted(async () => {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="library-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click="goPlay"
|
||||
>
|
||||
<template #image>
|
||||
<canvas ref="canvasRef" width="640" height="360" class="w-full h-full hidden" />
|
||||
<VImg :src="imgUrl" aspect-ratio="2/3" cover @load="imageLoadHandler" @error="imageErrorHandler">
|
||||
<VImg
|
||||
:src="imgUrl"
|
||||
aspect-ratio="2/3"
|
||||
class="library-card-image"
|
||||
:class="{ 'library-card-image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
<div class="library-card-placeholder">
|
||||
<VSkeletonLoader class="library-card-skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<VCardText
|
||||
class="w-full flex flex-col flex-wrap justify-end align-center text-white absolute bottom-0 cursor-pointer pa-2"
|
||||
>
|
||||
<h1 class="mb-1 text-white text-shadow font-bold line-clamp-2 overflow-hidden text-ellipsis ...">
|
||||
{{ props.media?.name }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
<div class="library-card-shade" aria-hidden="true" />
|
||||
<div v-if="showCountCorner" class="library-card-count-corner">
|
||||
<span>{{ countLabel }}</span>
|
||||
</div>
|
||||
<div class="library-card-label">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.library-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 23, 0%) 0%, rgba(2, 6, 23, 2%) 56%, rgba(2, 6, 23, 28%) 100%),
|
||||
linear-gradient(90deg, rgba(2, 6, 23, 8%) 0%, rgba(2, 6, 23, 0%) 42%, rgba(2, 6, 23, 10%) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-label {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(6, 10, 17, 58%);
|
||||
block-size: 1.75rem;
|
||||
border-block-start: 1px solid rgba(255, 255, 255, 12%);
|
||||
border-end-end-radius: var(--app-surface-radius);
|
||||
border-end-start-radius: var(--app-surface-radius);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1;
|
||||
padding-inline: 0.75rem;
|
||||
text-align: center;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 48%);
|
||||
}
|
||||
|
||||
.library-card-label span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-card-count-corner {
|
||||
position: absolute;
|
||||
block-size: 3.35rem;
|
||||
color: #fff;
|
||||
inline-size: 3.35rem;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.library-card-count-corner::before {
|
||||
position: absolute;
|
||||
background: rgba(8, 13, 22, 72%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner::after {
|
||||
position: absolute;
|
||||
background: rgba(var(--v-theme-primary), 62%);
|
||||
block-size: 100%;
|
||||
clip-path: polygon(100% 0, 100% 100%, 0 0);
|
||||
content: "";
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-count-corner span {
|
||||
position: absolute;
|
||||
inset-block-start: 0.63rem;
|
||||
inset-inline-end: 0.18rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 44%);
|
||||
transform: rotate(45deg);
|
||||
transform-origin: center;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.library-card-placeholder,
|
||||
.library-card-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.library-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.library-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeTouchMediaCardId = ref<number | null>(null)
|
||||
let mediaCardIdSeed = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getDisplayImageUrl, getLogoUrl } from '@/utils/imageUtils'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import type { MediaInfo, Subscribe, MediaSeason, Site } from '@/api/types'
|
||||
import { formatRating } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, Site, Subscribe } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getSubscribeMode,
|
||||
useMediaSubscribe,
|
||||
type SeasonSubscribeModes,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getCachedMediaExistsStatus,
|
||||
getCachedMediaSubscribeStatus,
|
||||
setCachedMediaExistsStatus,
|
||||
setCachedMediaSubscribeStatus,
|
||||
} from '@/utils/mediaStatusCache'
|
||||
|
||||
const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue'))
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('../dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('../dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -49,9 +56,6 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
|
||||
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 图片加载状态
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
@@ -64,8 +68,15 @@ const isSubscribed = ref(false)
|
||||
// 本地存在状态
|
||||
const isExists = ref(false)
|
||||
|
||||
// 选中的订阅季
|
||||
const seasonsSelected = ref<MediaSeason[]>([])
|
||||
// 当前媒体已订阅的季号
|
||||
const subscribedSeasons = ref<number[]>([])
|
||||
|
||||
// 当前媒体已订阅季的订阅模式
|
||||
const subscribedSeasonModes = ref<SeasonSubscribeModes>({})
|
||||
|
||||
const subscribedSeasonsLoaded = ref(false)
|
||||
|
||||
const subscribedSeasonsLoading = ref(false)
|
||||
|
||||
// 来源角标字典
|
||||
const sourceIconDict: { [key: string]: any } = {
|
||||
@@ -89,32 +100,12 @@ const selectedSites = ref<number[]>([])
|
||||
// 搜索菜单显示状态
|
||||
const searchMenuShow = ref(false)
|
||||
|
||||
// 选择的剧集组
|
||||
const episodeGroup = ref('')
|
||||
const mediaCardId = ++mediaCardIdSeed
|
||||
|
||||
// 打开订阅季选择弹窗,避免每个媒体卡片都持有弹窗实例。
|
||||
function openSubscribeSeasonDialog() {
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{ media: props.media },
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅编辑弹窗,保存、关闭或删除时释放共享弹窗实例。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: onRemoveSubscribe,
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
// 粗指针设备使用点击展开详情,避免 iOS 返回后沿用 VHover 的触摸态。
|
||||
const isTouchLikePointer = ref(
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(hover: none), (pointer: coarse)').matches),
|
||||
)
|
||||
|
||||
// 打开站点选择弹窗,并把选择结果交回当前媒体卡片继续搜索。
|
||||
function openSearchSiteDialog() {
|
||||
@@ -155,10 +146,7 @@ async function querySelectedSites() {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.tmdb_id) return `tmdb:${props.media?.tmdb_id}`
|
||||
else if (props.media?.douban_id) return `douban:${props.media?.douban_id}`
|
||||
else if (props.media?.bangumi_id) return `bangumi:${props.media?.bangumi_id}`
|
||||
else return `${props.media?.mediaid_prefix}:${props.media?.media_id}`
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) {
|
||||
@@ -177,6 +165,15 @@ function getExistsStatusKey() {
|
||||
].join('::')
|
||||
}
|
||||
|
||||
function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
if (props.media?.tmdb_id && subscribe.tmdbid) return props.media.tmdb_id === subscribe.tmdbid
|
||||
if (props.media?.douban_id && subscribe.doubanid) return props.media.douban_id === subscribe.doubanid
|
||||
if (props.media?.bangumi_id && subscribe.bangumiid) return props.media.bangumi_id === subscribe.bangumiid
|
||||
|
||||
const mediaId = props.media?.media_id ? `${props.media.mediaid_prefix}:${props.media.media_id}` : ''
|
||||
return Boolean(mediaId && subscribe.mediaid === mediaId)
|
||||
}
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -184,101 +181,6 @@ function getChipColor(type: string) {
|
||||
else return 'border-purple-600 bg-purple-600'
|
||||
}
|
||||
|
||||
// 添加订阅处理
|
||||
async function handleAddSubscribe() {
|
||||
if (props.media?.type === '电视剧') {
|
||||
// 弹出季选择列表,支持多选
|
||||
seasonsSelected.value = []
|
||||
openSubscribeSeasonDialog()
|
||||
} else {
|
||||
// 电影
|
||||
addSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API添加订阅,电视剧的话需要指定季
|
||||
async function addSubscribe(season: number | null = null, best_version: number = 0) {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
// 是否洗版
|
||||
if (!best_version && props.media?.type == '电影') best_version = isExists.value ? 1 : 0
|
||||
// 请求API
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: props.media?.title,
|
||||
type: props.media?.type,
|
||||
year: props.media?.year,
|
||||
tmdbid: props.media?.tmdb_id,
|
||||
doubanid: props.media?.douban_id,
|
||||
bangumiid: props.media?.bangumi_id,
|
||||
mediaid: props.media?.media_id ? `${props.media?.mediaid_prefix}:${props.media?.media_id}` : '',
|
||||
season: props.media?.type === '电影' ? null : season,
|
||||
best_version,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
// 订阅状态
|
||||
if (result.success) {
|
||||
// 订阅成功
|
||||
isSubscribed.value = true
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(season), true)
|
||||
}
|
||||
|
||||
// 提示
|
||||
showSubscribeAddToast(result.success, props.media?.title ?? '', season, result.message, best_version)
|
||||
|
||||
// 弹出订阅编辑弹窗
|
||||
if (result.success && seasonsSelected.value.length <= 1) {
|
||||
const show_edit_dialog = await queryDefaultSubscribeConfig()
|
||||
if (show_edit_dialog) {
|
||||
openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 弹出添加订阅提示
|
||||
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
let subname = t('subscribe.normalSub')
|
||||
if (best_version > 0) subname = t('subscribe.versionSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else if (!result) $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message: message })}`)
|
||||
}
|
||||
|
||||
// 调用API取消订阅
|
||||
async function removeSubscribe() {
|
||||
// 开始处理
|
||||
startNProgress()
|
||||
try {
|
||||
const mediaid = getMediaId()
|
||||
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season: props.media?.season,
|
||||
},
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
isSubscribed.value = false
|
||||
setCachedMediaSubscribeStatus(getSubscribeStatusKey(props.media?.season ?? null), false)
|
||||
$toast.success(`${props.media?.title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${props.media?.title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已订阅
|
||||
async function handleCheckSubscribe() {
|
||||
try {
|
||||
@@ -291,6 +193,36 @@ async function handleCheckSubscribe() {
|
||||
}
|
||||
}
|
||||
|
||||
async function querySubscribedSeasons() {
|
||||
if (
|
||||
props.media?.type !== '电视剧' ||
|
||||
!isSubscribed.value ||
|
||||
subscribedSeasonsLoaded.value ||
|
||||
subscribedSeasonsLoading.value
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedSeasonsLoading.value = true
|
||||
try {
|
||||
const subscribes: Subscribe[] = await api.get('subscribe/')
|
||||
const mediaSubscribes = subscribes.filter(
|
||||
item => item.type === '电视剧' && item.season !== undefined && isSameSubscribeMedia(item),
|
||||
)
|
||||
|
||||
subscribedSeasons.value = mediaSubscribes.map(item => item.season as number).sort((a, b) => a - b)
|
||||
subscribedSeasonModes.value = mediaSubscribes.reduce<SeasonSubscribeModes>((modes, item) => {
|
||||
if (item.season !== undefined) modes[item.season] = getSubscribeMode(item)
|
||||
return modes
|
||||
}, {})
|
||||
subscribedSeasonsLoaded.value = true
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
subscribedSeasonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体是否已入库
|
||||
async function handleCheckExists() {
|
||||
try {
|
||||
@@ -317,63 +249,20 @@ async function handleCheckExists() {
|
||||
|
||||
// 调用API检查是否已订阅,电视剧需要指定季
|
||||
async function checkSubscribe(season: number | null) {
|
||||
try {
|
||||
// AbortController 现在由全局请求优化器自动管理
|
||||
const mediaid = getMediaId()
|
||||
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
|
||||
params: {
|
||||
season,
|
||||
title: props.media?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询订阅弹窗规则
|
||||
async function queryDefaultSubscribeConfig() {
|
||||
if (!canSubscribe.value) return false
|
||||
try {
|
||||
let subscribe_config_url = ''
|
||||
if (props.media?.type === '电影') subscribe_config_url = 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
else subscribe_config_url = 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribe_config_url)
|
||||
if (result.data?.value) return result.data.value.show_edit_dialog
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
return false
|
||||
return subscribeActions.checkSubscribe(season)
|
||||
}
|
||||
|
||||
// 爱心订阅按钮响应
|
||||
function handleSubscribe() {
|
||||
if (isSubscribed.value) removeSubscribe()
|
||||
else handleAddSubscribe()
|
||||
}
|
||||
|
||||
// 订阅多季
|
||||
function subscribeSeasons(seasons: MediaSeason[], seasonNoExists: { [key: number]: number }, groudId: string) {
|
||||
episodeGroup.value = groudId
|
||||
seasonsSelected.value = seasons || []
|
||||
seasonsSelected.value.forEach(season => {
|
||||
let best_version = 0
|
||||
if (season && props.media?.tmdb_id)
|
||||
// 全部存在时洗版
|
||||
best_version = !seasonNoExists[season.season_number || 0] ? 1 : 0
|
||||
addSubscribe(season.season_number ?? null, best_version)
|
||||
})
|
||||
async function handleSubscribe() {
|
||||
await querySubscribedSeasons()
|
||||
subscribeActions.handleSubscribe()
|
||||
}
|
||||
|
||||
// 打开详情页
|
||||
function goMediaDetail(isHovering = false) {
|
||||
if (isHovering) {
|
||||
resetMediaCardDetailState()
|
||||
|
||||
if (props.media?.collection_id) {
|
||||
// 跳转到合集列表
|
||||
router.push({
|
||||
@@ -397,6 +286,42 @@ function goMediaDetail(isHovering = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前卡片是否进入可操作详情态,桌面使用 hover,触摸端使用显式点击态。
|
||||
function isMediaCardActive(isHovering: boolean | null | undefined) {
|
||||
return activeTouchMediaCardId.value === mediaCardId || (!isTouchLikePointer.value && Boolean(isHovering))
|
||||
}
|
||||
|
||||
// 当前卡片详情层是否显示,保留图片错误和站点选择时的强制显示。
|
||||
function isMediaCardDetailVisible(isHovering: boolean | null | undefined) {
|
||||
return isMediaCardActive(isHovering) || imageLoadError.value || searchMenuShow.value
|
||||
}
|
||||
|
||||
// 清理移动端详情层展开态,避免路由返回后继续显示上一次的详情层。
|
||||
function resetMediaCardDetailState() {
|
||||
if (activeTouchMediaCardId.value === mediaCardId) {
|
||||
activeTouchMediaCardId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理媒体卡片点击:触摸端第一次点击展开,展开后再次点击进入详情。
|
||||
function handleMediaCardClick(isHovering: boolean | null | undefined) {
|
||||
if (isTouchLikePointer.value && !isMediaCardDetailVisible(isHovering)) {
|
||||
activeTouchMediaCardId.value = mediaCardId
|
||||
querySubscribedSeasons()
|
||||
return
|
||||
}
|
||||
|
||||
goMediaDetail(isMediaCardActive(isHovering) || isMediaCardDetailVisible(isHovering))
|
||||
}
|
||||
|
||||
function handleDocumentPointerDown(event: PointerEvent) {
|
||||
if (!isTouchLikePointer.value || activeTouchMediaCardId.value !== mediaCardId) return
|
||||
if (!(event.target instanceof Node)) return
|
||||
if (mediaCardRef.value?.contains(event.target)) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
}
|
||||
|
||||
// 点击搜索
|
||||
async function clickSearch() {
|
||||
if (allSites.value?.length == 0) {
|
||||
@@ -469,22 +394,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
function onRemoveSubscribe() {
|
||||
isSubscribed.value = false
|
||||
}
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return mediaTypeDict[type]
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
const subscribeActions = useMediaSubscribe({
|
||||
media: () => props.media,
|
||||
canSubscribe: () => canSubscribe.value,
|
||||
isSubscribed,
|
||||
isExists: () => isExists.value,
|
||||
subscribedSeasons,
|
||||
subscribedSeasonModes,
|
||||
primarySeason: () => props.media?.season ?? null,
|
||||
getSubscribeStatusKey,
|
||||
})
|
||||
|
||||
watch(isSubscribed, subscribed => {
|
||||
subscribedSeasonsLoaded.value = false
|
||||
if (!subscribed) {
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
if (isTouchLikePointer.value) {
|
||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -75,15 +75,17 @@ function goPersonDetail() {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="person-card-hover-area">
|
||||
<VCard
|
||||
:height="personProps.height"
|
||||
:width="personProps.width"
|
||||
class="app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
@click.stop="goPersonDetail"
|
||||
>
|
||||
<div class="person-card relative cursor-pointer ring-gray-700">
|
||||
<div style="padding-block-end: 150%">
|
||||
<div class="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
@@ -107,12 +109,17 @@ function goPersonDetail() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.person-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.person-card {
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
|
||||
365
src/components/cards/PlayingBackdropCard.vue
Normal file
365
src/components/cards/PlayingBackdropCard.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '16 / 10',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 规范化后的播放进度,避免后端异常值撑破进度条。
|
||||
const progressValue = computed(() => {
|
||||
const percent = Number(props.media?.percent ?? 0)
|
||||
if (Number.isNaN(percent)) return 0
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
// 是否存在可续播进度。
|
||||
const hasProgress = computed(() => progressValue.value > 0)
|
||||
|
||||
// 右上角进度标签。
|
||||
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
|
||||
|
||||
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
|
||||
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
|
||||
|
||||
// 根据播放进度生成简短提示。
|
||||
const progressHint = computed(() => {
|
||||
if (!hasProgress.value) return '未开始'
|
||||
if (progressValue.value >= 90) return '快看完'
|
||||
if (progressValue.value <= 10) return '刚开始'
|
||||
return '继续上次进度'
|
||||
})
|
||||
|
||||
// 计算图片代理地址。
|
||||
const imageUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const useCookies = props.media?.use_cookies
|
||||
if (useCookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载失败并切换到默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到媒体服务器播放页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="playing-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="playing-card app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
|
||||
'playing-card--loaded': imageLoaded,
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goPlay"
|
||||
@keyup.enter="goPlay"
|
||||
@keyup.space="goPlay"
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="playing-card__placeholder">
|
||||
<VSkeletonLoader class="playing-card__skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<div class="playing-card__scrim" />
|
||||
<div class="playing-card__bottom-scrim" />
|
||||
|
||||
<div class="playing-card__percent">
|
||||
{{ progressLabel }}
|
||||
</div>
|
||||
|
||||
<div class="playing-card__play" aria-hidden="true">
|
||||
<VIcon icon="mdi-play" size="26" />
|
||||
</div>
|
||||
|
||||
<div class="playing-card__content">
|
||||
<div class="playing-card__title">
|
||||
{{ props.media?.title }}
|
||||
</div>
|
||||
<div class="playing-card__subtitle">
|
||||
{{ subtitleText }}
|
||||
</div>
|
||||
<div class="playing-card__meta">
|
||||
<span>{{ progressHint }}</span>
|
||||
<span v-if="props.media?.server_type" class="playing-card__server">
|
||||
{{ props.media.server_type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playing-card__progress" aria-hidden="true">
|
||||
<div class="playing-card__progress-track">
|
||||
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.playing-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.16);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.playing-card__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__placeholder,
|
||||
.playing-card__skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__image :deep(.v-img__img) {
|
||||
filter: saturate(0.96) brightness(0.92);
|
||||
opacity: 0;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
|
||||
.playing-card__image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.playing-card__scrim,
|
||||
.playing-card__bottom-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.playing-card__scrim {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__percent,
|
||||
.playing-card__play,
|
||||
.playing-card__content,
|
||||
.playing-card__progress {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playing-card__percent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 18%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(255, 255, 255, 16%);
|
||||
block-size: 26px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
inline-size: 52px;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-end: 12px;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 92%);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
|
||||
color: rgb(11, 15, 20);
|
||||
inline-size: 40px;
|
||||
inset-block-end: 46px;
|
||||
inset-inline-end: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__play {
|
||||
background: #fff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.playing-card__content {
|
||||
inset-block-end: 18px;
|
||||
inset-inline: 16px 66px;
|
||||
min-inline-size: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
overflow: hidden;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__subtitle {
|
||||
overflow: hidden;
|
||||
margin-block-start: 4px;
|
||||
color: rgba(255, 255, 255, 86%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__meta {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: 7px;
|
||||
color: rgba(255, 255, 255, 70%);
|
||||
font-size: 0.72rem;
|
||||
gap: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__server {
|
||||
overflow: hidden;
|
||||
max-inline-size: 6rem;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playing-card__progress {
|
||||
inset-block-end: 6px;
|
||||
inset-inline: 16px;
|
||||
}
|
||||
|
||||
.playing-card__progress-track {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 24%);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.playing-card__progress-bar {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
|
||||
block-size: 100%;
|
||||
min-inline-size: 8px;
|
||||
transition: inline-size 0.25s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.playing-card__content {
|
||||
inset-inline-end: 58px;
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playing-card__image :deep(.v-img__img),
|
||||
.playing-card__play,
|
||||
.playing-card__progress-bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img),
|
||||
.playing-card--hovering .playing-card__play {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -230,16 +230,17 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-app-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -325,13 +326,18 @@ onUnmounted(() => {
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-app-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-app-card__tags-section {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -567,19 +567,19 @@ watch(
|
||||
<!-- 插件卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-if="isVisible"
|
||||
v-bind="hover.props"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="isVisible" v-bind="hover.props" class="plugin-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div
|
||||
class="flex-grow"
|
||||
:style="`background: linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.5) 100%), linear-gradient(${backgroundColor} 0%, ${backgroundColor} 100%)`"
|
||||
@@ -669,7 +669,8 @@ watch(
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
|
||||
@@ -677,6 +678,10 @@ watch(
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -211,20 +211,21 @@ const dropdownItems = ref([
|
||||
<!-- 文件夹卡片 -->
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="plugin-folder-card-hover-area h-full">
|
||||
<VCard
|
||||
:ripple="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
min-height="8.5rem"
|
||||
@click="handleCardClick"
|
||||
class="plugin-folder-card app-hover-lift-card h-full"
|
||||
:class="{
|
||||
'plugin-folder-card--mobile': display.mobile,
|
||||
'plugin-folder-card--hover': hover.isHovering && !props.sortable,
|
||||
'plugin-folder-card--sortable': props.sortable,
|
||||
}"
|
||||
>
|
||||
<template v-if="backgroundImage" #image>
|
||||
<VImg :src="backgroundImage" cover position="top"> </VImg>
|
||||
</template>
|
||||
@@ -288,25 +289,29 @@ const dropdownItems = ref([
|
||||
</VMenu>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.plugin-folder-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-folder-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&--sortable {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
transform: translateY(-4px);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
&__bg {
|
||||
|
||||
@@ -17,6 +17,12 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '2 / 3',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 角标颜色
|
||||
function getChipColor(type: string) {
|
||||
if (type === '电影') return 'border-blue-500 bg-blue-600'
|
||||
@@ -47,20 +53,21 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
class="outline-none ring-gray-500"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="poster-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="app-hover-lift-card outline-none ring-gray-500"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
class="poster-card-image object-cover aspect-w-2 aspect-h-3"
|
||||
:class="{ 'poster-card-image--loaded': isImageLoaded }"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@@ -77,7 +84,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
variant="elevated"
|
||||
size="small"
|
||||
:class="getChipColor(props.media?.type || '')"
|
||||
class="absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
class="poster-card-chip absolute left-2 top-2 bg-opacity-80 text-white font-bold"
|
||||
>
|
||||
{{ props.media?.type }}
|
||||
</VChip>
|
||||
@@ -93,7 +100,41 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
{{ props.media?.title }}
|
||||
</h1>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.poster-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.poster-card-image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.poster-card-image :deep(.v-responsive__sizer) {
|
||||
padding-bottom: 150%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.poster-card-image :deep(.v-img__img) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -239,25 +239,27 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VCard
|
||||
class="site-card relative h-full flex flex-col overflow-hidden group transition-all duration-300"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer hover:-translate-y-1': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="site-card-hover-area h-full">
|
||||
<VCard
|
||||
class="site-card app-hover-lift-card relative h-full flex flex-col overflow-hidden group"
|
||||
:class="[
|
||||
cardProps.site?.is_active ? '' : 'opacity-70',
|
||||
{
|
||||
'border-error': statColor === 'error',
|
||||
'border-warning': statColor === 'warning',
|
||||
'border-success': statColor === 'success',
|
||||
'cursor-pointer site-card--hoverable': !cardProps.sortable,
|
||||
'cursor-move': cardProps.sortable,
|
||||
'site-card--sortable': cardProps.sortable,
|
||||
},
|
||||
]"
|
||||
:ripple="false"
|
||||
variant="flat"
|
||||
elevation="0"
|
||||
:hover="!cardProps.sortable"
|
||||
@click="handleCardClick"
|
||||
>
|
||||
<!-- 装饰性状态指示器 -->
|
||||
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
|
||||
|
||||
@@ -419,11 +421,20 @@ onMounted(() => {
|
||||
</VMenu>
|
||||
</VBtn>
|
||||
</VSheet>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.site-card-hover-area:hover .site-card--hoverable {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.site-status-indicator {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -455,7 +466,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
/* 站点卡片悬停时状态指示器变化 */
|
||||
.site-card:not(.site-card--sortable):hover .site-status-indicator {
|
||||
.site-card-hover-area:hover .site-card:not(.site-card--sortable) .site-status-indicator {
|
||||
block-size: 2px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -644,7 +655,7 @@ onMounted(() => {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.site-card:hover .site-card-actions {
|
||||
.site-card-hover-area:hover .site-card-actions {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
|
||||
@@ -92,6 +92,17 @@ const rightBottomStateDisplay = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
// 移动端紧凑卡片的状态展示,颜色统一映射到 Vuetify 全局主题 token。
|
||||
const compactStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
return { color: 'secondary', icon: 'mdi-pause-circle-outline', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
if (subscribeState.value === 'P') {
|
||||
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
@@ -290,22 +301,6 @@ const dropdownItems = computed(() => [
|
||||
click: searchSubscribe,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.details'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.files'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: subscribeState.value === 'S' ? t('common.enable') : t('common.pause'),
|
||||
value: 5,
|
||||
@@ -334,6 +329,22 @@ const dropdownItems = computed(() => [
|
||||
},
|
||||
show: props.media?.type === '电视剧',
|
||||
},
|
||||
{
|
||||
title: t('subscribe.mediaDetail'),
|
||||
value: 3,
|
||||
props: {
|
||||
prependIcon: 'mdi-information-outline',
|
||||
click: viewMediaDetail,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('subscribe.fileStatistics'),
|
||||
value: 4,
|
||||
props: {
|
||||
prependIcon: 'mdi-file-document-outline',
|
||||
click: viewSubscribeFiles,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.unsubscribe'),
|
||||
value: 8,
|
||||
@@ -404,63 +415,162 @@ function handleCardClick() {
|
||||
<div>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="subscribe-card-shell w-full h-full relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full overflow-hidden"
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="subscribe-card-shell app-hover-lift-card w-full h-full relative"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'subscribe-card-shell--selected': props.batchMode && props.selected,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
<VCard
|
||||
:key="props.media?.id"
|
||||
class="subscribe-card flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@click="handleCardClick"
|
||||
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable && display.smAndUp.value" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem v-if="item.show !== false" :base-color="item.props.color" @click="item.props.click">
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image v-if="display.smAndUp.value">
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="2"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
<template #image>
|
||||
<VImg :src="backdropUrl || posterUrl" aspect-ratio="3/2" cover @load="imageLoadHandler" position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-3 aspect-h-2" />
|
||||
</VImg>
|
||||
|
||||
<div
|
||||
v-if="props.media?.username || lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta"
|
||||
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
|
||||
>
|
||||
<div
|
||||
v-if="props.media?.username"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
|
||||
:title="props.media?.username"
|
||||
>
|
||||
<VIcon icon="mdi-account" size="14" />
|
||||
<span>{{ props.media?.username }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__updated"
|
||||
>
|
||||
<VIcon icon="mdi-download" size="14" />
|
||||
<span>{{ lastUpdateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-body">
|
||||
<div class="subscribe-card-mobile-title">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-footer">
|
||||
<div class="subscribe-card-mobile-meta">
|
||||
<div
|
||||
class="subscribe-card-mobile-state"
|
||||
:style="{ color: `rgb(var(--v-theme-${compactStateDisplay.color}))` }"
|
||||
:title="compactStateDisplay.label"
|
||||
:aria-label="compactStateDisplay.label"
|
||||
>
|
||||
<VIcon :icon="compactStateDisplay.icon" size="18" />
|
||||
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
|
||||
{{ subscribeProgressText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn
|
||||
v-if="!props.sortable"
|
||||
class="subscribe-card-mobile-menu"
|
||||
size="small"
|
||||
@click.stop
|
||||
>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
<VListItem
|
||||
v-if="item.show !== false"
|
||||
:base-color="item.props.color"
|
||||
@click="item.props.click"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
|
||||
<div v-if="props.media?.total_episode" class="subscribe-card-mobile-progress">
|
||||
<VProgressLinear
|
||||
:model-value="getPercentage()"
|
||||
:bg-color="compactStateDisplay.color"
|
||||
:color="compactStateDisplay.color"
|
||||
bg-opacity="0.18"
|
||||
height="4"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
@@ -567,14 +677,185 @@ function handleCardClick() {
|
||||
color="success"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅卡片外壳:选中态虚线框复用同一圆角,避免 outline 在圆角卡片外形成直角。
|
||||
*/
|
||||
.subscribe-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.subscribe-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 1;
|
||||
flex-shrink: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-media .v-img {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
isolation: isolate;
|
||||
line-height: 1.2;
|
||||
padding-block: 0.0625rem;
|
||||
padding-inline: 0.125rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
content: '';
|
||||
filter: blur(3px);
|
||||
inset: -0.25rem -0.55rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 1rem);
|
||||
inset-block-start: 0.5rem;
|
||||
inset-inline-start: 0.5rem;
|
||||
transition: inset-block-start 0.2s ease;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 4.25rem);
|
||||
inset-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user span {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__updated {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
inset-block-end: 0.5rem;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-meta {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 2rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-state span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress-text {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-menu {
|
||||
block-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
min-inline-size: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress {
|
||||
display: flex;
|
||||
block-size: 4px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress .v-progress-linear {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.subscribe-card-shell--selected::after {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
border: 2px solid rgb(var(--v-theme-primary));
|
||||
border-radius: inherit;
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
@@ -598,7 +879,7 @@ function handleCardClick() {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 40%); // sky-400
|
||||
box-shadow: inset 0 0 48px rgba(var(--v-theme-info), 0.28);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
@@ -630,4 +911,30 @@ function handleCardClick() {
|
||||
background: rgba(255, 255, 255, 22%);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 15%);
|
||||
}
|
||||
|
||||
@media (width <= 599px) {
|
||||
.subscribe-card {
|
||||
min-block-size: 0 !important;
|
||||
}
|
||||
|
||||
.subscribe-card-paused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subscribe-card-paused .subscribe-card-mobile-media {
|
||||
filter: saturate(0.65);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.best-version-badge {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
|
||||
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,16 +93,17 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="subscribe-share-card-hover-area w-full h-full">
|
||||
<div
|
||||
class="app-hover-lift-card w-full h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
min-height="150"
|
||||
@click="showForkSubscribe"
|
||||
>
|
||||
@@ -155,13 +156,18 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.subscribe-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden subtitle-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden subtitle-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -203,11 +204,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-card {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-card:hover {
|
||||
.subtitle-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-card-hover-area:hover .subtitle-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,10 +99,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="subtitle-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="subtitle?.enclosure"
|
||||
class="pa-3 mb-2 rounded subtitle-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded subtitle-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -206,11 +207,16 @@ watch(
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subtitle-item {
|
||||
border: 1px solid transparent;
|
||||
.subtitle-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subtitle-item:hover {
|
||||
.subtitle-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.subtitle-item-hover-area:hover .subtitle-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,12 +146,13 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-card-hover-area h-full">
|
||||
<VCard
|
||||
:width="props.width || '100%'"
|
||||
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||
@click="handleAddDownload(props.torrent)"
|
||||
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
|
||||
class="app-hover-lift-card h-full cursor-pointer d-flex flex-column overflow-hidden torrent-card"
|
||||
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
|
||||
hover
|
||||
>
|
||||
@@ -316,13 +317,18 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: 1px solid transparent;
|
||||
.torrent-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-card:hover {
|
||||
/* 卡片悬停效果 */
|
||||
.torrent-card {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-card-hover-area:hover .torrent-card {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
/* 优惠标签样式 */
|
||||
|
||||
@@ -115,10 +115,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Hover 命中区域保持静止,避免列表项上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="torrent-item-hover-area w-100">
|
||||
<VListItem
|
||||
:value="props.torrent?.torrent_info?.enclosure"
|
||||
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||
class="app-hover-lift-card pa-3 mb-2 rounded torrent-item overflow-hidden"
|
||||
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
|
||||
@click="handleAddDownload"
|
||||
>
|
||||
@@ -262,12 +263,17 @@ watch(
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
.torrent-item {
|
||||
border: 1px solid transparent;
|
||||
.torrent-item-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.torrent-item:hover {
|
||||
.torrent-item {
|
||||
border: var(--app-card-light-border);
|
||||
}
|
||||
|
||||
.torrent-item-hover-area:hover .torrent-item {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.chip-season {
|
||||
|
||||
@@ -127,14 +127,16 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
:class="[
|
||||
'transition-transform duration-300 hover:-translate-y-1',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div class="user-card-hover-area h-full">
|
||||
<VCard
|
||||
:class="[
|
||||
'app-hover-lift-card',
|
||||
!props.user.is_active ? 'opacity-85 bg-surface-lighten-1' : '',
|
||||
]"
|
||||
class="user-card flex flex-column h-full"
|
||||
@click="editUser"
|
||||
>
|
||||
<div class="user-card__body flex-grow flex-grow-1">
|
||||
<!-- 用户头像和基本信息 -->
|
||||
<VCardItem :class="[user.is_superuser ? 'admin-header' : '']">
|
||||
@@ -302,10 +304,19 @@ onMounted(() => {
|
||||
</div>
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.user-card-hover-area:hover .user-card {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
|
||||
.user-card {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
@@ -95,17 +95,18 @@ function doDelete() {
|
||||
<div class="h-full">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'workflow-share-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-share-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.workflow?.id"
|
||||
class="workflow-share-card app-hover-lift-card flex flex-col h-full cursor-pointer overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<VCardText class="flex items-center pa-3 pb-1 grow">
|
||||
<div class="flex flex-col justify-center w-full">
|
||||
@@ -134,20 +135,16 @@ function doDelete() {
|
||||
{{ dateText }}
|
||||
</VCardText>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 阴影需要落在实际卡片上,不能被额外的 overflow 容器裁掉。
|
||||
.workflow-share-card {
|
||||
transition: transform 0.3s ease, box-shadow 0.2s ease;
|
||||
transform: translateZ(0);
|
||||
.workflow-share-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-share-card--hovering {
|
||||
transform: translate3d(0, -0.25rem, 0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -220,14 +220,15 @@ const resolveProgress = (item: Workflow) => {
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<VHover v-slot="hover">
|
||||
<VCard
|
||||
v-bind="hover.props"
|
||||
class="mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
|
||||
>
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
@@ -367,7 +368,14 @@ const resolveProgress = (item: Workflow) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,6 @@ type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
@@ -24,7 +23,6 @@ const props = withDefaults(
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
@@ -63,17 +61,42 @@ const elevatedValue = computed({
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => props.enabled,
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.elevated,
|
||||
value => {
|
||||
localElevated.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.items.map(item => getItemValue(item)).join('\u0000'),
|
||||
() => {
|
||||
syncLocalEnabledItems()
|
||||
},
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
syncLocalEnabledItems()
|
||||
}
|
||||
|
||||
// 同步新增设置项的默认状态,避免刷新列表时覆盖用户正在编辑的本地选择。
|
||||
function syncLocalEnabledItems() {
|
||||
props.items.forEach(item => {
|
||||
const key = getItemValue(item)
|
||||
if (key && !(key in localEnabled.value)) {
|
||||
localEnabled.value[key] = Boolean(props.enabled[key])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
@@ -129,12 +152,13 @@ function submitSettings() {
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
type="button"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
:aria-pressed="Boolean(localEnabled[getItemValue(item)])"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
@@ -147,7 +171,7 @@ function submitSettings() {
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
@@ -186,43 +210,48 @@ function submitSettings() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
text-align: start;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--app-surface-radius);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
background: rgb(var(--v-theme-primary));
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
@@ -230,6 +259,24 @@ function submitSettings() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item:focus-visible {
|
||||
outline: 3px solid rgba(var(--v-theme-primary), 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -240,4 +287,11 @@ function submitSettings() {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -9,12 +9,10 @@ const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
@@ -55,9 +53,16 @@ function submitOrder() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
width="35rem"
|
||||
class="settings-dialog"
|
||||
scrollable
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
@@ -72,14 +77,11 @@ function submitOrder() {
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:animation="180"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<VCard variant="text" class="setting-item enabled">
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
@@ -102,6 +104,11 @@ function submitOrder() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
@@ -110,50 +117,86 @@ function submitOrder() {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
min-block-size: 48px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 10px;
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.04);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
background-color: rgb(var(--v-theme-primary));
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inline-size: 3px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.3);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.32);
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
}
|
||||
|
||||
.setting-item:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
font-weight: 550;
|
||||
line-height: 1.35;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled .setting-label {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.52);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.setting-item:hover .drag-icon {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (width <= 760px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,9 +105,9 @@ function submitTemplate() {
|
||||
<style scoped>
|
||||
.notification-template-editor-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
max-block-size: calc(100dvh - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-editor-header {
|
||||
@@ -147,8 +147,8 @@ function submitTemplate() {
|
||||
|
||||
.template-ace-editor {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
block-size: auto;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.template-editor-actions {
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -51,8 +51,14 @@ function downloadLogger() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="72rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -75,9 +81,29 @@ function downloadLogger() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (width <= 960px) {
|
||||
.logging-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.logging-dialog-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useUserStore, useGlobalSettingsStore } from '@/stores'
|
||||
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { VDialog, VMenu } from 'vuetify/components'
|
||||
import { buildUserPermissionContext, hasPermission, filterMenusByPermission } from '@/utils/permission'
|
||||
|
||||
// 显示器宽度
|
||||
@@ -15,10 +16,16 @@ const display = useDisplay()
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义props,接收modelValue
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
// 定义 props,接收浮层状态及是否显示响应式入口。
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
showActivator?: boolean
|
||||
}>(),
|
||||
{
|
||||
showActivator: false,
|
||||
},
|
||||
)
|
||||
|
||||
// 路由
|
||||
const router = useRouter()
|
||||
@@ -78,16 +85,33 @@ const dialog = computed({
|
||||
set: val => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
// 桌面使用锚定下拉,小屏继续使用原有搜索弹窗。
|
||||
const searchOverlay = computed(() => (display.mdAndUp.value ? VMenu : VDialog))
|
||||
const searchOverlayProps = computed(() =>
|
||||
display.mdAndUp.value
|
||||
? {
|
||||
closeOnContentClick: false,
|
||||
location: 'bottom start' as const,
|
||||
offset: 8,
|
||||
scrollStrategy: 'reposition' as const,
|
||||
}
|
||||
: {
|
||||
fullscreen: true,
|
||||
maxWidth: '40rem',
|
||||
scrollable: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 搜索词
|
||||
const searchWord = ref<string | null>(null)
|
||||
|
||||
// ref
|
||||
const searchWordInput = ref<HTMLElement | null>(null)
|
||||
// 当前尺寸下可见的搜索输入框。
|
||||
const searchWordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// 近期搜索词条
|
||||
const recentSearches = ref<string[]>([])
|
||||
|
||||
// 检测操作系统是否是Mac
|
||||
/** 检测操作系统是否为 macOS。 */
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
}
|
||||
@@ -95,7 +119,7 @@ function isMac() {
|
||||
// 计算属性:根据操作系统显示不同的按键提示
|
||||
const metaKey = computed(() => (isMac() ? '⌘+K' : 'Ctrl+K'))
|
||||
|
||||
// 保存近期搜索到本地
|
||||
/** 将有效关键词保存到近期搜索记录。 */
|
||||
function saveRecentSearches(keyword: string) {
|
||||
if (!keyword) return
|
||||
if (recentSearches.value.includes(keyword)) return
|
||||
@@ -103,7 +127,7 @@ function saveRecentSearches(keyword: string) {
|
||||
localStorage.setItem('MP_RecentSearches', JSON.stringify(recentSearches.value))
|
||||
}
|
||||
|
||||
// 从本地加载近期搜索
|
||||
/** 从本地存储加载并裁剪近期搜索记录。 */
|
||||
function loadRecentSearches() {
|
||||
const recentSearchesStr = localStorage.getItem('MP_RecentSearches')
|
||||
if (recentSearchesStr) {
|
||||
@@ -115,7 +139,7 @@ function loadRecentSearches() {
|
||||
}
|
||||
}
|
||||
|
||||
// 所有菜单功能
|
||||
/** 获取可参与全局搜索的导航菜单和设置入口。 */
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
@@ -129,6 +153,7 @@ function getMenus(): NavMenu[] {
|
||||
header: item.header,
|
||||
admin: item.admin,
|
||||
permission: item.permission,
|
||||
feature: item.feature,
|
||||
}),
|
||||
)
|
||||
// 设置标签页
|
||||
@@ -170,7 +195,7 @@ const matchedMenuItems = computed(() => {
|
||||
// 所有插件(已安装)
|
||||
const pluginItems = ref<Plugin[]>([])
|
||||
|
||||
// 获取插件列表数据
|
||||
/** 加载已安装插件,供搜索结果匹配。 */
|
||||
async function fetchInstalledPlugins() {
|
||||
try {
|
||||
pluginItems.value = await api.get('plugin/', {
|
||||
@@ -194,7 +219,7 @@ const matchedPluginItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 获取订阅列表数据
|
||||
/** 加载订阅列表,供搜索结果匹配。 */
|
||||
async function fetchSubscribes() {
|
||||
try {
|
||||
SubscribeItems.value = await api.get('subscribe/')
|
||||
@@ -203,7 +228,7 @@ async function fetchSubscribes() {
|
||||
}
|
||||
}
|
||||
|
||||
// 从接口加载用户站点偏好设置
|
||||
/** 从接口加载用户的站点搜索偏好。 */
|
||||
const loadUserSitePreferences = async () => {
|
||||
try {
|
||||
const result = await api.get('system/setting/public/IndexerSites')
|
||||
@@ -216,7 +241,7 @@ const loadUserSitePreferences = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 查询所有站点
|
||||
/** 查询所有启用站点,并初始化默认选择。 */
|
||||
async function queryAllSites() {
|
||||
try {
|
||||
const data: Site[] = await api.get('site/')
|
||||
@@ -231,7 +256,7 @@ async function queryAllSites() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开站点选择对话框
|
||||
/** 打开指定资源类型的站点选择对话框。 */
|
||||
const openSiteDialog = (type: 'torrent' | 'subtitle' = 'torrent') => {
|
||||
siteSearchType.value = type
|
||||
chooseSiteDialog.value = true
|
||||
@@ -247,7 +272,7 @@ const matchedSubscribeItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 搜索多站点
|
||||
/** 使用选中的站点执行当前资源类型搜索。 */
|
||||
function searchSites(sites: number[]) {
|
||||
chooseSiteDialog.value = false
|
||||
selectedSites.value = sites
|
||||
@@ -258,7 +283,7 @@ function searchSites(sites: number[]) {
|
||||
searchTorrent()
|
||||
}
|
||||
|
||||
// 搜索资源
|
||||
/** 使用当前关键词搜索站点资源。 */
|
||||
function searchTorrent() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
// 记录搜索词
|
||||
@@ -273,12 +298,10 @@ function searchTorrent() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
// 关闭搜索对话框
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 搜索字幕资源
|
||||
/** 使用当前关键词搜索字幕资源。 */
|
||||
function searchSubtitle() {
|
||||
if (!searchWord.value || !hasSearchPermission.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -291,11 +314,10 @@ function searchSubtitle() {
|
||||
sites: selectedSites.value.join(','),
|
||||
},
|
||||
})
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转媒体搜索页面
|
||||
/** 跳转到指定类型的媒体搜索结果页。 */
|
||||
function searchMedia(searchType: string) {
|
||||
// 搜索类型 media/person
|
||||
if (!searchWord.value || !hasDiscoveryPermission.value) return
|
||||
@@ -307,10 +329,10 @@ function searchMedia(searchType: string) {
|
||||
type: searchType,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到历史记录页面
|
||||
/** 跳转到包含当前关键词的历史记录页。 */
|
||||
function searchHistory() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -320,10 +342,10 @@ function searchHistory() {
|
||||
search: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转到订阅分享页面
|
||||
/** 跳转到包含当前关键词的订阅分享页。 */
|
||||
function searchSubscribeShares() {
|
||||
if (!searchWord.value) return
|
||||
saveRecentSearches(searchWord.value)
|
||||
@@ -333,10 +355,10 @@ function searchSubscribeShares() {
|
||||
keyword: searchWord.value,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转插件页面
|
||||
/** 打开匹配插件的已安装详情。 */
|
||||
function showPlugin(pluginId: string) {
|
||||
router.push({
|
||||
path: `/plugins/`,
|
||||
@@ -345,16 +367,16 @@ function showPlugin(pluginId: string) {
|
||||
id: pluginId,
|
||||
},
|
||||
})
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转菜单页面
|
||||
/** 跳转到匹配的功能菜单。 */
|
||||
function goPage(to: string) {
|
||||
router.push(to)
|
||||
emit('close')
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
// 跳转订阅页面
|
||||
/** 根据订阅类型跳转到对应订阅详情。 */
|
||||
function goSubscribe(subscribe: Subscribe) {
|
||||
if (subscribe.type === '电影') {
|
||||
router.push({
|
||||
@@ -371,13 +393,29 @@ function goSubscribe(subscribe: Subscribe) {
|
||||
},
|
||||
})
|
||||
}
|
||||
closeSearch()
|
||||
}
|
||||
|
||||
/** 关闭搜索浮层并通知外层入口同步状态。 */
|
||||
function closeSearch() {
|
||||
dialog.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/** 聚焦当前可见输入框,供快捷键入口复用。 */
|
||||
function focusSearchInput() {
|
||||
searchWordInput.value?.focus()
|
||||
}
|
||||
|
||||
watch(dialog, async isOpen => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
focusSearchInput()
|
||||
})
|
||||
|
||||
defineExpose({ focusSearchInput })
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
searchWordInput.value?.focus()
|
||||
}, 500)
|
||||
// 根据权限加载不同的数据
|
||||
if (hasAdminPermission.value) {
|
||||
fetchInstalledPlugins()
|
||||
@@ -395,10 +433,20 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<VDialog v-model="dialog" max-width="40rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="search-dialog">
|
||||
<!-- 搜索输入框区域 -->
|
||||
<div class="search-header">
|
||||
<component :is="searchOverlay" v-model="dialog" v-bind="searchOverlayProps">
|
||||
<template v-if="showActivator" #activator="{ props: activatorProps }">
|
||||
<!-- 小屏入口保持图标形态,点击后打开全屏搜索弹窗。 -->
|
||||
<IconBtn
|
||||
v-if="!display.mdAndUp.value"
|
||||
v-bind="activatorProps"
|
||||
class="search-icon-trigger"
|
||||
:aria-label="t('dialog.searchBar.openSearch')"
|
||||
>
|
||||
<VIcon class="search-icon-trigger__icon" icon="mdi-magnify" />
|
||||
</IconBtn>
|
||||
|
||||
<!-- 中屏及以上常驻搜索输入框,输入时直接在下方展示同一组选项。 -->
|
||||
<div v-else v-bind="activatorProps" class="search-desktop-activator">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-magnify" size="22" class="search-input-icon" />
|
||||
<input
|
||||
@@ -408,7 +456,26 @@ onMounted(() => {
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape="emit('close')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<kbd class="search-shortcut-badge">{{ metaKey }}</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<VCard class="search-dialog" :class="{ 'search-dialog--dropdown': display.mdAndUp.value }">
|
||||
<!-- 弹窗模式保留原有搜索输入区。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-header">
|
||||
<div class="search-input-wrapper">
|
||||
<VIcon icon="mdi-text" size="22" class="search-input-icon" />
|
||||
<input
|
||||
ref="searchWordInput"
|
||||
v-model="searchWord"
|
||||
type="text"
|
||||
class="search-native-input"
|
||||
:placeholder="t('dialog.searchBar.searchPlaceholder')"
|
||||
@keydown.enter="searchMedia('media')"
|
||||
@keydown.escape.stop="closeSearch"
|
||||
/>
|
||||
<VBtn icon size="small" variant="text" class="search-submit-btn" @click="searchMedia('media')">
|
||||
<VIcon icon="mdi-magnify" size="20" />
|
||||
@@ -697,26 +764,14 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部区域 -->
|
||||
<!-- 桌面端:快捷键提示 -->
|
||||
<div v-if="display.mdAndUp.value" class="search-footer">
|
||||
<div class="shortcut-group">
|
||||
<kbd>Esc</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.escClose') }}</span>
|
||||
</div>
|
||||
<div class="shortcut-group">
|
||||
<kbd>{{ metaKey }}</kbd>
|
||||
<span class="shortcut-label">{{ t('dialog.searchBar.openSearch') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端:关闭图标 -->
|
||||
<div v-else class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="emit('close')">
|
||||
<!-- 弹窗形态保留底部关闭按钮,桌面下拉不显示页脚。 -->
|
||||
<div v-if="!display.mdAndUp.value" class="search-footer-mobile">
|
||||
<VBtn icon variant="tonal" @click="closeSearch">
|
||||
<VIcon icon="mdi-close" size="20" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</component>
|
||||
|
||||
<!-- 站点选择对话框 -->
|
||||
<SearchSiteDialog
|
||||
@@ -731,12 +786,46 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.search-dialog {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
inline-size: min(32rem, calc(100vw - 2rem));
|
||||
max-block-size: min(72vh, 42rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator {
|
||||
flex: 0 1 22rem;
|
||||
inline-size: clamp(15rem, 32vw, 22rem);
|
||||
max-inline-size: calc(100vw - 10rem);
|
||||
}
|
||||
|
||||
.search-desktop-activator .search-input-wrapper {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.12);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
block-size: 42px;
|
||||
padding-inline: 14px 8px;
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.v-theme--transparent .search-desktop-activator .search-input-wrapper {
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
||||
}
|
||||
|
||||
.search-icon-trigger {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-icon-trigger__icon {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
/* 搜索头部区域 */
|
||||
.search-header {
|
||||
padding-block: 16px 12px;
|
||||
@@ -748,18 +837,18 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1.5px solid rgba(var(--v-theme-primary), 0.4);
|
||||
border-radius: 28px;
|
||||
border-radius: var(--app-vuetify-rounded-pill);
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.04);
|
||||
block-size: 48px;
|
||||
padding-inline: 14px 6px;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
border-radius 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.search-input-wrapper:focus-within {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.search-input-icon {
|
||||
@@ -797,13 +886,24 @@ onMounted(() => {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.12) !important;
|
||||
}
|
||||
|
||||
.search-shortcut-badge {
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown .search-content {
|
||||
max-block-size: min(58vh, 34rem);
|
||||
}
|
||||
|
||||
.search-list {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -848,28 +948,13 @@ onMounted(() => {
|
||||
|
||||
.recent-searches-section {
|
||||
inline-size: 100%;
|
||||
padding-block-start: 1rem;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 底部快捷键提示 */
|
||||
.search-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
gap: 16px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.shortcut-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.15);
|
||||
border-radius: 5px;
|
||||
@@ -883,11 +968,6 @@ kbd {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
.shortcut-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.45);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端底部关闭图标 */
|
||||
.search-footer-mobile {
|
||||
display: flex;
|
||||
@@ -912,10 +992,5 @@ kbd {
|
||||
.search-native-input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-footer {
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { Site } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
const props = defineProps({
|
||||
sites: {
|
||||
type: Array as PropType<Site[]>,
|
||||
@@ -84,7 +88,7 @@ const filteredSites = computed(() => {
|
||||
</script>
|
||||
<template>
|
||||
<!-- Site Selection Dialog -->
|
||||
<VDialog max-width="40rem" fullscreen-mobile>
|
||||
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
|
||||
<VCard class="site-dialog">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
|
||||
@@ -42,8 +42,14 @@ function allLoggingDownloadUrl() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialog
|
||||
v-if="visible"
|
||||
v-model="visible"
|
||||
:scrollable="display.mdAndUp.value"
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="logging-dialog-card">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
@@ -66,9 +72,29 @@ function allLoggingDownloadUrl() {
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<VCardText class="logging-dialog-content pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (width <= 960px) {
|
||||
.logging-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.logging-dialog-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
const theme = useTheme()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
@@ -41,6 +42,17 @@ const visible = computed({
|
||||
})
|
||||
|
||||
const isFullscreen = computed(() => !display.mdAndUp.value)
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
const isSchedulerDialog = computed(() => props.cardClass.split(/\s+/).includes('scheduler-shortcut-dialog-card'))
|
||||
|
||||
// 透明主题下仅定时服务全屏弹窗取消外层 VCard 的背景和模糊,避免整屏磨砂遮住界面。
|
||||
const cardClasses = computed(() => [
|
||||
props.cardClass,
|
||||
{
|
||||
'scheduler-shortcut-dialog-card--transparent':
|
||||
isFullscreen.value && isTransparentTheme.value && isSchedulerDialog.value,
|
||||
},
|
||||
])
|
||||
|
||||
// 仅系统健康检查弹窗需要在全屏时取消固定高度,避免其它快捷弹窗被误伤。
|
||||
const bodyClasses = computed(() => [
|
||||
@@ -50,17 +62,17 @@ const bodyClasses = computed(() => [
|
||||
isFullscreen.value && props.bodyClass.split(/\s+/).includes('system-health-dialog-body'),
|
||||
},
|
||||
])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="isFullscreen">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCard :class="cardClasses">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon :icon="props.icon" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
@@ -90,4 +102,49 @@ const bodyClasses = computed(() => [
|
||||
.system-health-dialog-body--fullscreen {
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 959.98px) {
|
||||
.words-shortcut-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.words-shortcut-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.scheduler-shortcut-dialog-card--transparent {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.cache-shortcut-dialog-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
html[data-theme='transparent'] .cache-shortcut-dialog-card,
|
||||
.v-theme--transparent .cache-shortcut-dialog-card {
|
||||
backdrop-filter: blur(var(--transparent-blur, 10px));
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
|
||||
.cache-shortcut-dialog-body {
|
||||
display: flex;
|
||||
overflow: hidden !important;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -13,6 +13,9 @@ const { t, locale } = useI18n()
|
||||
// 响应式断点
|
||||
const display = useDisplay()
|
||||
|
||||
// 当前主题
|
||||
const theme = useTheme()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
site: Object as PropType<Site>,
|
||||
@@ -92,9 +95,13 @@ const resultSummaryText = computed(() => {
|
||||
// 是否小屏幕
|
||||
const isMobileLayout = computed(() => display.smAndDown.value)
|
||||
|
||||
// 是否透明主题
|
||||
const isTransparentTheme = computed(() => theme.name.value === 'transparent')
|
||||
|
||||
// 移动端分页数据
|
||||
const mobileResourceList = computed(() => resourceDataList.value)
|
||||
|
||||
// 获取资源项唯一标识
|
||||
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||
}
|
||||
@@ -188,10 +195,12 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 切换移动端搜索栏
|
||||
function toggleMobileSearch() {
|
||||
mobileSearchExpanded.value = !mobileSearchExpanded.value
|
||||
}
|
||||
|
||||
// 关闭移动端搜索栏
|
||||
function closeMobileSearch() {
|
||||
mobileSearchExpanded.value = false
|
||||
}
|
||||
@@ -480,7 +489,11 @@ onMounted(() => {
|
||||
:get-item-key="getResourceItemKey"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<VCard class="site-resource-card" variant="flat">
|
||||
<VCard
|
||||
class="site-resource-card"
|
||||
:class="{ 'site-resource-card--transparent': isTransparentTheme }"
|
||||
variant="flat"
|
||||
>
|
||||
<VCardText class="pa-3">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
<div class="site-resource-card__title text-body-1 font-weight-medium text-high-emphasis">
|
||||
@@ -746,7 +759,7 @@ onMounted(() => {
|
||||
background: var(--site-resource-card-bg);
|
||||
}
|
||||
|
||||
:global(html[data-theme="transparent"]) .site-resource-card {
|
||||
.site-resource-card--transparent {
|
||||
--site-resource-card-bg: rgba(var(--v-theme-surface), var(--transparent-opacity));
|
||||
|
||||
backdrop-filter: blur(var(--transparent-blur));
|
||||
|
||||
@@ -64,8 +64,10 @@ const historyChartOptions = computed(() => {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
@@ -156,8 +158,10 @@ const seedingChartOptions = computed(() => {
|
||||
background: currentTheme.value.surface, // 新增背景色同步
|
||||
foreColor: currentTheme.value.onSurface, // 新增文字颜色同步
|
||||
zoom: {
|
||||
autoScaleYaxis: true,
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
},
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { qualityOptions, resolutionOptions, effectOptions } from '@/api/constants'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
// i18n
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
@@ -96,6 +97,13 @@ const seasonItems = ref(
|
||||
})),
|
||||
)
|
||||
|
||||
function getSubscribeDisplayName() {
|
||||
const name = subscribeForm.value.name || ''
|
||||
const season = subscribeForm.value.season
|
||||
if (season === null || season === undefined) return name
|
||||
return `${name} ${formatSeason(season.toString())}`
|
||||
}
|
||||
|
||||
// 剧集组选项属性
|
||||
function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
return {
|
||||
@@ -158,11 +166,11 @@ async function updateSubscribeInfo() {
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', subscribeForm.value)
|
||||
// 提示
|
||||
if (result.success) {
|
||||
$toast.success(`${subscribeForm.value.name} 更新成功!`)
|
||||
$toast.success(`${getSubscribeDisplayName()} 更新成功!`)
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
} else {
|
||||
$toast.error(`${subscribeForm.value.name} 更新失败:${result.message}!`)
|
||||
$toast.error(`${getSubscribeDisplayName()} 更新失败:${result.message}!`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
@@ -258,7 +266,7 @@ async function removeSubscribe() {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/${props.subid}`)
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(`订阅 ${subscribeForm.value.name} 已取消!`)
|
||||
$toast.success(`订阅 ${getSubscribeDisplayName()} 已取消!`)
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
}
|
||||
@@ -317,10 +325,7 @@ onMounted(() => {
|
||||
{{ props.default ? t('dialog.subscribeEdit.titleDefault') : t('dialog.subscribeEdit.titleEdit') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="!props.default">
|
||||
{{ subscribeForm.name }}
|
||||
<span v-if="subscribeForm.season">
|
||||
{{ t('dialog.subscribeEdit.seasonFormat', { number: subscribeForm.season }) }}
|
||||
</span>
|
||||
{{ getSubscribeDisplayName() }}
|
||||
</VCardSubtitle>
|
||||
<VCardSubtitle v-else>
|
||||
{{ props.type }}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
64
src/components/dialog/SubscribeModeDialog.vue
Normal file
64
src/components/dialog/SubscribeModeDialog.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: string
|
||||
modes?: SubscribeMode[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'choose', mode: SubscribeMode): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const modeItems = computed<SubscribeMode[]>(() =>
|
||||
props.modes?.length
|
||||
? props.modes
|
||||
: props.type === '电视剧'
|
||||
? ['normal', 'best_version', 'best_version_full']
|
||||
: ['normal', 'best_version'],
|
||||
)
|
||||
|
||||
const optionMeta: Record<SubscribeMode, { icon: string; title: string }> = {
|
||||
normal: {
|
||||
icon: 'mdi-plus-circle-outline',
|
||||
title: t('dialog.subscribeMode.normal'),
|
||||
},
|
||||
best_version: {
|
||||
icon: 'mdi-refresh',
|
||||
title:
|
||||
props.type === '电视剧' ? t('dialog.subscribeMode.bestVersionEpisode') : t('dialog.subscribeMode.bestVersion'),
|
||||
},
|
||||
best_version_full: {
|
||||
icon: 'mdi-shimmer',
|
||||
title: t('dialog.subscribeMode.bestVersionFull'),
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="modelValue" max-width="28rem" @update:model-value="emit('update:modelValue', $event)">
|
||||
<VCard>
|
||||
<VCardTitle class="text-lg font-weight-bold px-5 pt-5">
|
||||
{{ t('dialog.subscribeMode.title') }}
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VList class="py-2">
|
||||
<VListItem
|
||||
v-for="mode in modeItems"
|
||||
:key="mode"
|
||||
:prepend-icon="optionMeta[mode].icon"
|
||||
:title="optionMeta[mode].title"
|
||||
@click="emit('choose', mode)"
|
||||
/>
|
||||
</VList>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -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
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useTransparencySettings } from '@/composables/useTransparencySettings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,15 +42,17 @@ const {
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onGlassQualityChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyGlassQuality,
|
||||
transparencyOpacity,
|
||||
} = useTransparencySettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
@@ -118,6 +124,34 @@ const {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('theme.transparencyGlassQuality') }}</span>
|
||||
<VBtnToggle
|
||||
v-model="transparencyGlassQuality"
|
||||
mandatory
|
||||
divided
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
class="w-full"
|
||||
@update:model-value="onGlassQualityChange"
|
||||
>
|
||||
<VBtn value="lightweight" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityLightweight') }}
|
||||
</VBtn>
|
||||
<VBtn value="realtime" class="flex-1">
|
||||
{{ t('theme.transparencyGlassQualityRealtime') }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="text-caption text-medium-emphasis mt-2 mb-0">
|
||||
{{
|
||||
transparencyGlassQuality === 'realtime'
|
||||
? t('theme.transparencyGlassQualityRealtimeHint')
|
||||
: t('theme.transparencyGlassQualityLightweightHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
|
||||
<VBtnGroup density="compact" variant="outlined" class="w-full">
|
||||
|
||||
@@ -7,6 +7,14 @@ import { useDisplay } from 'vuetify'
|
||||
import avatar1 from '@images/avatars/avatar-1.png'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
USER_PERMISSION_FEATURES,
|
||||
buildDefaultFeaturePermissions,
|
||||
normalizeUserPermissions,
|
||||
type UserPermissionCategoryKey,
|
||||
type UserPermissionFeatureKey,
|
||||
type UserPermissions,
|
||||
} from '@/utils/permission'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -65,14 +73,6 @@ interface ExtendedUser extends User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 权限类型定义
|
||||
interface UserPermissions {
|
||||
discovery: boolean // 发现权限
|
||||
search: boolean // 搜索权限
|
||||
subscribe: boolean // 订阅权限
|
||||
manage: boolean // 管理权限
|
||||
}
|
||||
|
||||
// 用户编辑表单数据
|
||||
const userForm = ref<ExtendedUser>({
|
||||
id: 0,
|
||||
@@ -88,6 +88,7 @@ const userForm = ref<ExtendedUser>({
|
||||
search: true,
|
||||
subscribe: true,
|
||||
manage: false,
|
||||
features: buildDefaultFeaturePermissions(),
|
||||
},
|
||||
settings: {
|
||||
wechat_userid: null,
|
||||
@@ -127,34 +128,115 @@ const permissionOptions = [
|
||||
description: t('dialog.userAddEdit.permissions.manageDesc'),
|
||||
icon: 'mdi-cog-outline',
|
||||
},
|
||||
]
|
||||
] as const
|
||||
|
||||
const activePermissionCategory = ref<UserPermissionCategoryKey>('discovery')
|
||||
|
||||
// 权限状态计算属性
|
||||
const userPermissions = computed({
|
||||
get: () => {
|
||||
const permissions = userForm.value.permissions as UserPermissions
|
||||
return {
|
||||
discovery: permissions?.discovery ?? true,
|
||||
search: permissions?.search ?? true,
|
||||
subscribe: permissions?.subscribe ?? true,
|
||||
manage: permissions?.manage ?? false,
|
||||
}
|
||||
return normalizeUserPermissions(userForm.value.permissions as Partial<UserPermissions>)
|
||||
},
|
||||
set: (value: UserPermissions) => {
|
||||
userForm.value.permissions = value
|
||||
},
|
||||
})
|
||||
|
||||
// 切换权限状态
|
||||
function togglePermission(key: keyof UserPermissions) {
|
||||
const permissionFeatureOptions = computed(() =>
|
||||
USER_PERMISSION_FEATURES.map(feature => ({
|
||||
...feature,
|
||||
title: t(feature.titleKey),
|
||||
description: t(feature.descriptionKey),
|
||||
})),
|
||||
)
|
||||
|
||||
const activePermissionOption = computed(() =>
|
||||
permissionOptions.find(option => option.key === activePermissionCategory.value) ?? permissionOptions[0],
|
||||
)
|
||||
|
||||
const activePermissionFeatures = computed(() =>
|
||||
permissionFeatureOptions.value.filter(feature => feature.permission === activePermissionCategory.value),
|
||||
)
|
||||
|
||||
const enabledCategoryCount = computed(
|
||||
() => permissionOptions.filter(option => userPermissions.value[option.key]).length,
|
||||
)
|
||||
|
||||
const enabledFeatureCount = computed(
|
||||
() =>
|
||||
permissionFeatureOptions.value.filter(
|
||||
feature => userPermissions.value[feature.permission] && isFeatureEnabled(feature.key),
|
||||
).length,
|
||||
)
|
||||
|
||||
/** 切换当前查看的权限分类。 */
|
||||
function selectPermissionCategory(key: UserPermissionCategoryKey) {
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
/** 切换权限分类启用状态,并聚焦到该分类的功能列表。 */
|
||||
function togglePermission(key: UserPermissionCategoryKey) {
|
||||
const currentPermissions = userPermissions.value
|
||||
userPermissions.value = {
|
||||
...currentPermissions,
|
||||
[key]: !currentPermissions[key],
|
||||
}
|
||||
activePermissionCategory.value = key
|
||||
}
|
||||
|
||||
// 更新头像
|
||||
/** 判断功能项是否已启用,缺省功能按启用处理以兼容旧权限数据。 */
|
||||
function isFeatureEnabled(key: UserPermissionFeatureKey) {
|
||||
return userPermissions.value.features?.[key] !== false
|
||||
}
|
||||
|
||||
/** 切换单个功能项的启用状态。 */
|
||||
function togglePermissionFeature(key: UserPermissionFeatureKey) {
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: {
|
||||
...(userPermissions.value.features ?? {}),
|
||||
[key]: !isFeatureEnabled(key),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 统计指定分类下已经勾选的功能数量。 */
|
||||
function getEnabledFeatureCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(
|
||||
feature => feature.permission === permission && isFeatureEnabled(feature.key),
|
||||
).length
|
||||
}
|
||||
|
||||
/** 返回指定分类下的功能总数。 */
|
||||
function getFeatureTotalCount(permission: UserPermissionCategoryKey) {
|
||||
return permissionFeatureOptions.value.filter(feature => feature.permission === permission).length
|
||||
}
|
||||
|
||||
/** 判断指定分类是否处于功能部分勾选状态。 */
|
||||
function isPermissionPartiallySelected(permission: UserPermissionCategoryKey) {
|
||||
const enabledCount = getEnabledFeatureCount(permission)
|
||||
const totalCount = getFeatureTotalCount(permission)
|
||||
return enabledCount > 0 && enabledCount < totalCount
|
||||
}
|
||||
|
||||
/** 批量设置当前分类下的所有功能项。 */
|
||||
function setCategoryFeatures(permission: UserPermissionCategoryKey, enabled: boolean) {
|
||||
const nextFeatures = { ...(userPermissions.value.features ?? {}) }
|
||||
for (const feature of permissionFeatureOptions.value.filter(item => item.permission === permission)) {
|
||||
nextFeatures[feature.key] = enabled
|
||||
}
|
||||
userPermissions.value = {
|
||||
...userPermissions.value,
|
||||
features: nextFeatures,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将当前分类下的功能项恢复为默认全选状态。 */
|
||||
function resetCategoryFeatures(permission: UserPermissionCategoryKey) {
|
||||
setCategoryFeatures(permission, true)
|
||||
}
|
||||
|
||||
/** 校验并读取用户上传的新头像。 */
|
||||
function changeAvatar(file: Event) {
|
||||
const fileReader = new FileReader()
|
||||
const { files } = file.target as HTMLInputElement
|
||||
@@ -182,19 +264,19 @@ function changeAvatar(file: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// 重置默认头像
|
||||
/** 将头像恢复为系统默认头像。 */
|
||||
function resetDefaultAvatar() {
|
||||
currentAvatar.value = avatar1
|
||||
$toast.success(t('dialog.userAddEdit.resetAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 还原当前头像
|
||||
/** 还原为用户当前已保存的头像。 */
|
||||
function restoreCurrentAvatar() {
|
||||
currentAvatar.value = userForm.value.avatar
|
||||
$toast.success(t('dialog.userAddEdit.restoreAvatarSuccess'))
|
||||
}
|
||||
|
||||
// 查询用户信息
|
||||
/** 从接口查询当前编辑用户的信息并回填表单。 */
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
userForm.value = await api.get(`user/${props.username}`)
|
||||
@@ -210,7 +292,7 @@ async function fetchUserInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API 新增用户
|
||||
/** 调用接口创建新用户。 */
|
||||
async function addUser() {
|
||||
if (isAdding.value) {
|
||||
$toast.error(t('dialog.userAddEdit.creatingUser', { name: userForm.value.name }))
|
||||
@@ -256,7 +338,7 @@ async function addUser() {
|
||||
isAdding.value = false
|
||||
}
|
||||
|
||||
// 调用API更新用户信息
|
||||
/** 调用接口更新当前用户信息。 */
|
||||
async function updateUser() {
|
||||
if (isUpdating.value) {
|
||||
$toast.error(t('dialog.userAddEdit.updatingUser', { name: userForm.value.name }))
|
||||
@@ -367,7 +449,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog scrollable max-width="40rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VDialog scrollable max-width="64rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem :class="props.oper === 'add' ? 'py-3' : 'py-2'">
|
||||
<template #prepend>
|
||||
@@ -572,43 +654,144 @@ onMounted(() => {
|
||||
<span>{{ t('dialog.userAddEdit.permissions.title') }}</span>
|
||||
</VDivider>
|
||||
<!-- 权限设置 -->
|
||||
<div v-if="canControl">
|
||||
<VRow>
|
||||
<VCol v-for="option in permissionOptions" :key="option.key" cols="6">
|
||||
<VCard
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface'"
|
||||
:variant="userPermissions[option.key as keyof UserPermissions] ? 'tonal' : 'outlined'"
|
||||
class="cursor-pointer transition-all h-full"
|
||||
@click="togglePermission(option.key as keyof UserPermissions)"
|
||||
hover
|
||||
<div v-if="canControl" class="user-permission-editor">
|
||||
<div class="permission-section-header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryTitle') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t('dialog.userAddEdit.permissions.categoryHint') }}
|
||||
</div>
|
||||
</div>
|
||||
<VChip size="small" color="primary" variant="tonal">
|
||||
{{
|
||||
t('dialog.userAddEdit.permissions.summary', {
|
||||
categories: enabledCategoryCount,
|
||||
features: enabledFeatureCount,
|
||||
})
|
||||
}}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="permission-category-grid">
|
||||
<div
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.key"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="permission-category-option"
|
||||
:class="{
|
||||
'is-active': activePermissionCategory === option.key,
|
||||
'is-enabled': userPermissions[option.key],
|
||||
'is-partial': isPermissionPartiallySelected(option.key),
|
||||
}"
|
||||
@click="selectPermissionCategory(option.key)"
|
||||
@keydown.enter="selectPermissionCategory(option.key)"
|
||||
@keydown.space.prevent="selectPermissionCategory(option.key)"
|
||||
>
|
||||
<span class="permission-category-option__icon">
|
||||
<VIcon :icon="option.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-category-option__body">
|
||||
<span class="permission-category-option__title-row">
|
||||
<span class="permission-category-option__title">{{ option.title }}</span>
|
||||
<span class="permission-category-option__count">
|
||||
{{ getEnabledFeatureCount(option.key) }}/{{ getFeatureTotalCount(option.key) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="permission-category-option__desc">{{ option.description }}</span>
|
||||
</span>
|
||||
<VBtn
|
||||
:icon="userPermissions[option.key] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="userPermissions[option.key] ? 'primary' : 'secondary'"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="permission-category-option__toggle"
|
||||
@click.stop="togglePermission(option.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-panel">
|
||||
<div class="permission-feature-panel__header">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ activePermissionOption.title }}{{ t('dialog.userAddEdit.permissions.featureTitleSuffix') }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{
|
||||
userPermissions[activePermissionCategory]
|
||||
? t('dialog.userAddEdit.permissions.featureHint')
|
||||
: t('dialog.userAddEdit.permissions.disabledCategoryHint')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="permission-feature-panel__actions">
|
||||
<VBtn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, true)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="setCategoryFeatures(activePermissionCategory, false)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.clear') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
@click="resetCategoryFeatures(activePermissionCategory)"
|
||||
>
|
||||
{{ t('dialog.userAddEdit.permissions.default') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="permission-feature-list">
|
||||
<div
|
||||
v-for="feature in activePermissionFeatures"
|
||||
:key="feature.key"
|
||||
role="checkbox"
|
||||
:aria-checked="isFeatureEnabled(feature.key)"
|
||||
:aria-disabled="!userPermissions[activePermissionCategory]"
|
||||
:tabindex="userPermissions[activePermissionCategory] ? 0 : -1"
|
||||
class="permission-feature-item"
|
||||
:class="{
|
||||
'is-enabled': isFeatureEnabled(feature.key),
|
||||
'is-disabled': !userPermissions[activePermissionCategory],
|
||||
}"
|
||||
@click="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.enter="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
@keydown.space.prevent="userPermissions[activePermissionCategory] && togglePermissionFeature(feature.key)"
|
||||
>
|
||||
<VCardText class="d-flex align-center pa-4">
|
||||
<VAvatar
|
||||
:color="userPermissions[option.key as keyof UserPermissions] ? 'primary' : 'surface-variant'"
|
||||
size="40"
|
||||
class="me-3"
|
||||
>
|
||||
<VIcon :icon="option.icon" />
|
||||
</VAvatar>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-subtitle-1 font-weight-medium d-flex align-center">
|
||||
{{ option.title }}
|
||||
<VIcon
|
||||
v-if="userPermissions[option.key as keyof UserPermissions]"
|
||||
icon="mdi-check-circle"
|
||||
color="primary"
|
||||
size="small"
|
||||
class="ms-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VCheckboxBtn
|
||||
:model-value="isFeatureEnabled(feature.key)"
|
||||
:disabled="!userPermissions[activePermissionCategory]"
|
||||
color="primary"
|
||||
density="compact"
|
||||
class="permission-feature-item__check"
|
||||
@click.stop
|
||||
@update:model-value="togglePermissionFeature(feature.key)"
|
||||
/>
|
||||
<span class="permission-feature-item__icon">
|
||||
<VIcon :icon="feature.icon" size="20" />
|
||||
</span>
|
||||
<span class="permission-feature-item__body">
|
||||
<span class="permission-feature-item__title">{{ feature.title }}</span>
|
||||
<span class="permission-feature-item__desc">{{ feature.description }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
@@ -642,3 +825,273 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-permission-editor {
|
||||
--permission-editor-radius: min(var(--app-surface-radius, 8px), 8px);
|
||||
--permission-editor-border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
--permission-editor-muted-border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
--permission-editor-panel-bg: rgb(var(--v-theme-surface));
|
||||
--permission-editor-hover-bg: rgba(var(--v-theme-primary), 0.04);
|
||||
--permission-editor-active-bg: rgba(var(--v-theme-primary), 0.1);
|
||||
--permission-editor-selected-bg: rgba(var(--v-theme-primary), 0.08);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.permission-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option,
|
||||
.permission-feature-item {
|
||||
border: var(--permission-editor-muted-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: var(--permission-editor-panel-bg);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease, opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: flex-start;
|
||||
min-block-size: 5.75rem;
|
||||
padding: 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-category-option:hover,
|
||||
.permission-feature-item:hover:not(.is-disabled) {
|
||||
border-color: rgba(var(--v-theme-primary), 0.36);
|
||||
background: var(--permission-editor-hover-bg);
|
||||
}
|
||||
|
||||
.permission-category-option:focus-visible,
|
||||
.permission-feature-item:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.72);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.permission-category-option.is-active {
|
||||
border-color: rgba(var(--v-theme-primary), 0.72);
|
||||
background: var(--permission-editor-active-bg);
|
||||
}
|
||||
|
||||
.permission-category-option.is-enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.34);
|
||||
}
|
||||
|
||||
.permission-category-option.is-partial .permission-category-option__count {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
background: rgba(var(--v-theme-warning), 0.12);
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-feature-item__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.permission-category-option__icon {
|
||||
inline-size: 2.25rem;
|
||||
block-size: 2.25rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__body,
|
||||
.permission-feature-item__body {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.permission-category-option__title,
|
||||
.permission-feature-item__title {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.permission-category-option__count {
|
||||
flex: 0 0 auto;
|
||||
border-radius: var(--app-control-radius, 6px);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc,
|
||||
.permission-feature-item__scope {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__desc,
|
||||
.permission-feature-item__desc {
|
||||
display: block;
|
||||
overflow: visible;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.permission-feature-panel {
|
||||
overflow: hidden;
|
||||
border: var(--permission-editor-border);
|
||||
border-radius: var(--permission-editor-radius);
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
|
||||
.permission-feature-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-block-end: var(--permission-editor-muted-border);
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.625rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
min-block-size: 4rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__icon {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.permission-feature-item.is-disabled .permission-feature-item__title,
|
||||
.permission-feature-item.is-disabled .permission-feature-item__desc {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
.permission-feature-item.is-enabled {
|
||||
background: var(--permission-editor-selected-bg);
|
||||
}
|
||||
|
||||
.permission-feature-item__check {
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-feature-item__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.permission-feature-item__scope {
|
||||
max-inline-size: 8rem;
|
||||
margin-inline-start: 1rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
.permission-category-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.permission-feature-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-section-header,
|
||||
.permission-feature-panel__header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.permission-feature-panel__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.user-permission-editor {
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.permission-category-option {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-block-size: 3.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.permission-category-option__icon,
|
||||
.permission-category-option__desc {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.permission-category-option__toggle {
|
||||
margin-inline-start: 0.5rem;
|
||||
}
|
||||
|
||||
.permission-feature-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.permission-feature-item__icon {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -728,7 +728,7 @@ async function batchScrape() {
|
||||
function handleProgressMessage(event: MessageEvent) {
|
||||
const progress = JSON.parse(event.data)
|
||||
if (progress) {
|
||||
progressText.value = progress.text
|
||||
progressText.value = progress.text_i18n || progress.text
|
||||
progressValue.value = progress.value
|
||||
progressDialogController?.updateProps({ text: progressText.value, value: progressValue.value })
|
||||
}
|
||||
|
||||
@@ -722,7 +722,7 @@ onMounted(() => {
|
||||
.filter-buttons-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.filter-btn-mobile {
|
||||
@@ -732,10 +732,23 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 8px;
|
||||
block-size: auto;
|
||||
min-block-size: 48px;
|
||||
block-size: 56px;
|
||||
min-block-size: 56px;
|
||||
min-inline-size: 0;
|
||||
padding-block: 4px;
|
||||
padding-inline: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.filter-btn-mobile :deep(.v-btn__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
@@ -744,20 +757,20 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.15;
|
||||
max-inline-size: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.filter-buttons-grid {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
overflow: hidden;
|
||||
max-inline-size: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
|
||||
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
|
||||
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
|
||||
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
@@ -33,6 +34,9 @@ const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader>
|
||||
library: () => import('@/views/dashboard/MediaServerLibrary.vue'),
|
||||
playing: () => import('@/views/dashboard/MediaServerPlaying.vue'),
|
||||
latest: () => import('@/views/dashboard/MediaServerLatest.vue'),
|
||||
recentImports: () => import('@/views/dashboard/DashboardRecentImports.vue'),
|
||||
quickActions: () => import('@/views/dashboard/DashboardQuickActions.vue'),
|
||||
systemInfo: () => import('@/views/dashboard/DashboardSystemInfo.vue'),
|
||||
}
|
||||
|
||||
const builtInDashboardComponentPromises = new Map<string, Promise<any>>()
|
||||
@@ -65,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
|
||||
const AnalyticsStorage = createAsyncDashboardComponent('storage')
|
||||
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
|
||||
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
|
||||
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
|
||||
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
|
||||
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
|
||||
@@ -74,6 +79,9 @@ const AnalyticsNetwork = createAsyncDashboardComponent('network')
|
||||
const MediaServerLibrary = createAsyncDashboardComponent('library')
|
||||
const MediaServerPlaying = createAsyncDashboardComponent('playing')
|
||||
const MediaServerLatest = createAsyncDashboardComponent('latest')
|
||||
const DashboardRecentImports = createAsyncDashboardComponent('recentImports')
|
||||
const DashboardQuickActions = createAsyncDashboardComponent('quickActions')
|
||||
const DashboardSystemInfo = createAsyncDashboardComponent('systemInfo')
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
@@ -196,6 +204,7 @@ onUnmounted(() => {
|
||||
<!-- 系统内置的仪表板 -->
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
@@ -205,6 +214,9 @@ onUnmounted(() => {
|
||||
<MediaServerLibrary v-else-if="config?.id === 'library'" />
|
||||
<MediaServerPlaying v-else-if="config?.id === 'playing'" />
|
||||
<MediaServerLatest v-else-if="config?.id === 'latest'" />
|
||||
<DashboardRecentImports v-else-if="config?.id === 'recentImports'" />
|
||||
<DashboardQuickActions v-else-if="config?.id === 'quickActions'" />
|
||||
<DashboardSystemInfo v-else-if="config?.id === 'systemInfo'" :allow-refresh="props.allowRefresh" />
|
||||
<!-- 插件仪表板 -->
|
||||
<template v-else-if="!isNullOrEmptyObject(props.config)">
|
||||
<!-- Vue 渲染模式 -->
|
||||
|
||||
@@ -8,15 +8,22 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
interface TmdbItem {
|
||||
// 媒体标题
|
||||
title: string
|
||||
// 媒体简介,包含类型标签
|
||||
overview: string
|
||||
// TMDB ID
|
||||
tmdbid: number
|
||||
// 豆瓣 ID
|
||||
doubanid: string
|
||||
// 海报地址
|
||||
poster: string
|
||||
// 媒体类型
|
||||
type?: string
|
||||
}
|
||||
|
||||
// update:modelValue 事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
const emit = defineEmits(['update:modelValue', 'select', 'close'])
|
||||
|
||||
const items = ref<TmdbItem[]>([])
|
||||
|
||||
@@ -29,9 +36,10 @@ const loading = ref(false)
|
||||
// ref
|
||||
const inputKeyword = ref<HTMLElement | null>(null)
|
||||
|
||||
// 选中条目
|
||||
// 选中条目并通知父组件同步额外媒体信息。
|
||||
function selectMedia(item: TmdbItem) {
|
||||
emit('update:modelValue', item.tmdbid || item.doubanid)
|
||||
emit('select', item)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -66,6 +74,7 @@ async function searchMedias() {
|
||||
tmdbid: item.tmdb_id || 0,
|
||||
doubanid: item.douban_id || '',
|
||||
poster: getW500Image(item.poster_path),
|
||||
type: item.type,
|
||||
title: `${item.title}(${item.year})`,
|
||||
overview: `<span class="text-primary">${item.type}</span> ${item.overview}`,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取容器宽度不可用时的兜底视口宽度。
|
||||
*/
|
||||
function getFallbackViewportWidth() {
|
||||
if (typeof window === 'undefined') {
|
||||
return itemStep.value * Math.max(props.overscanItems, 1)
|
||||
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
|
||||
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析虚拟列表项的稳定 key。
|
||||
*/
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动状态指示计时器。
|
||||
*/
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前滚动位置更新虚拟渲染范围。
|
||||
*/
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
@@ -113,6 +125,9 @@ function updateVisibleRange() {
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步左右导航按钮的可用状态。
|
||||
*/
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -130,11 +145,17 @@ function updateDisabledState() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步虚拟列表布局与导航状态。
|
||||
*/
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前可视范围向左或向右滚动一屏。
|
||||
*/
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内容滚动并刷新滚动指示状态。
|
||||
*/
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
@@ -257,28 +281,20 @@ watch(
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-left"
|
||||
color="white"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-right"
|
||||
color="white"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -402,17 +418,18 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 14%);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
background: rgba(8, 18, 28, 52%) !important;
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
|
||||
color: rgb(255, 255, 255);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inline-size: 40px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
@@ -421,21 +438,22 @@ watch(
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
:deep(.v-icon) {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
|
||||
font-size: 28px;
|
||||
opacity: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(255, 255, 255, 28%);
|
||||
background: rgba(8, 18, 28, 68%) !important;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
|
||||
color: rgb(255, 255, 255);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
:deep(.v-icon) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { getCurrentInstance, onMounted, onUnmounted, ref, type Ref } from 'vue'
|
||||
import { sseManagerSingleton, type SSEManagerOptions } from '@/utils/sseManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
type UseSSEOptions = Partial<SSEManagerOptions> & {
|
||||
connectDelay?: number
|
||||
}
|
||||
|
||||
/** 为 SSE 请求补充当前前端语言,弥补 EventSource 不能设置自定义请求头的问题。 */
|
||||
function appendLocaleParam(url: string) {
|
||||
const locale = getCurrentLocale()
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, window.location.origin)
|
||||
parsedUrl.searchParams.set('locale', locale)
|
||||
return parsedUrl.toString()
|
||||
} catch {
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}locale=${encodeURIComponent(locale)}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台任务组合函数
|
||||
* 统一管理SSE连接和定时器,减少后台常驻活动。
|
||||
@@ -180,12 +195,15 @@ export function useBackground() {
|
||||
listenerId: string,
|
||||
isActive: Ref<boolean>,
|
||||
) => {
|
||||
const getManager = () =>
|
||||
sseManagerSingleton.getIndependentManager(url, listenerId, {
|
||||
let managerUrl = ''
|
||||
const getManager = () => {
|
||||
managerUrl = appendLocaleParam(url)
|
||||
return sseManagerSingleton.getIndependentManager(managerUrl, listenerId, {
|
||||
backgroundCloseDelay: 1000, // 进度SSE更快关闭
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectAttempts: 5,
|
||||
})
|
||||
}
|
||||
|
||||
let manager: ReturnType<typeof getManager> | null = null
|
||||
let isListening = false
|
||||
@@ -207,8 +225,9 @@ export function useBackground() {
|
||||
manager.removeMessageListener(listenerId)
|
||||
|
||||
if (destroyManager) {
|
||||
sseManagerSingleton.closeIndependentManager(url, listenerId)
|
||||
sseManagerSingleton.closeIndependentManager(managerUrl || appendLocaleParam(url), listenerId)
|
||||
manager = null
|
||||
managerUrl = ''
|
||||
}
|
||||
|
||||
isListening = false
|
||||
|
||||
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
167
src/composables/useDashboardMediaGridCapacity.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
toValue,
|
||||
watch,
|
||||
type ComputedRef,
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
|
||||
interface DashboardMediaGridCapacityOptions {
|
||||
contentSelector?: string
|
||||
gap?: number
|
||||
horizontalPadding?: number
|
||||
maxCount?: number
|
||||
minItemWidth: number
|
||||
rows?: MaybeRefOrGetter<number>
|
||||
}
|
||||
|
||||
interface DashboardMediaGridCapacityState {
|
||||
columnCount: ComputedRef<number>
|
||||
containerRef: Ref<HTMLElement | null>
|
||||
itemCount: ComputedRef<number>
|
||||
refreshCapacity: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_GAP = 16
|
||||
const DEFAULT_DASHBOARD_MEDIA_GRID_ROWS = 2
|
||||
|
||||
/**
|
||||
* 根据仪表盘媒体卡片容器宽度和响应式行数计算请求数量。
|
||||
*
|
||||
* @param options 网格尺寸参数,需要与实际 ProgressiveCardGrid 参数保持一致
|
||||
* @returns 容器引用、列数、请求数量和手动刷新方法
|
||||
*/
|
||||
export function useDashboardMediaGridCapacity(options: DashboardMediaGridCapacityOptions): DashboardMediaGridCapacityState {
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const measuredWidth = ref(0)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let frameId: number | null = null
|
||||
|
||||
const safeGap = computed(() => Math.max(0, options.gap ?? DEFAULT_DASHBOARD_MEDIA_GRID_GAP))
|
||||
const safeHorizontalPadding = computed(() => Math.max(0, options.horizontalPadding ?? 0))
|
||||
const safeMaxCount = computed(() => Math.max(0, Math.floor(options.maxCount ?? Number.POSITIVE_INFINITY)))
|
||||
const safeMinItemWidth = computed(() => Math.max(1, options.minItemWidth))
|
||||
const safeRows = computed(() => {
|
||||
const rows = options.rows === undefined ? DEFAULT_DASHBOARD_MEDIA_GRID_ROWS : toValue(options.rows)
|
||||
|
||||
return Math.max(1, Math.floor(rows))
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (measuredWidth.value <= 0) return 0
|
||||
|
||||
return Math.max(1, Math.floor((measuredWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
|
||||
})
|
||||
|
||||
const itemCount = computed(() => {
|
||||
if (columnCount.value <= 0) return 0
|
||||
|
||||
const count = columnCount.value * safeRows.value
|
||||
|
||||
return safeMaxCount.value > 0 ? Math.min(count, safeMaxCount.value) : count
|
||||
})
|
||||
|
||||
/**
|
||||
* 读取元素扣除内边距后的内容宽度。
|
||||
*
|
||||
* @param element 需要测量的元素
|
||||
*/
|
||||
function measureContentBoxWidth(element: HTMLElement) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const paddingLeft = Number.parseFloat(style.paddingLeft) || 0
|
||||
const paddingRight = Number.parseFloat(style.paddingRight) || 0
|
||||
|
||||
return Math.max(0, element.clientWidth - paddingLeft - paddingRight)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取容器可用于网格布局的实际宽度。
|
||||
*/
|
||||
function measureContainerWidth() {
|
||||
const element = containerRef.value
|
||||
|
||||
if (!element) return 0
|
||||
|
||||
const contentElement = options.contentSelector
|
||||
? element.querySelector<HTMLElement>(options.contentSelector)
|
||||
: null
|
||||
if (contentElement) {
|
||||
return measureContentBoxWidth(contentElement)
|
||||
}
|
||||
|
||||
const elementWidth = element.getBoundingClientRect().width || element.clientWidth
|
||||
|
||||
return Math.max(0, elementWidth - safeHorizontalPadding.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步当前容器宽度到响应式状态。
|
||||
*/
|
||||
function refreshCapacity() {
|
||||
measuredWidth.value = measureContainerWidth()
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并同一帧内的尺寸变更,避免拖拽调整仪表盘时重复计算。
|
||||
*/
|
||||
function queueRefreshCapacity() {
|
||||
if (typeof window === 'undefined' || frameId !== null) return
|
||||
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null
|
||||
refreshCapacity()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新绑定当前容器的 ResizeObserver。
|
||||
*/
|
||||
function observeContainer() {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined' && containerRef.value) {
|
||||
resizeObserver = new ResizeObserver(queueRefreshCapacity)
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
|
||||
queueRefreshCapacity()
|
||||
}
|
||||
|
||||
watch(containerRef, observeContainer, { flush: 'post' })
|
||||
|
||||
onMounted(() => {
|
||||
observeContainer()
|
||||
window.addEventListener('resize', queueRefreshCapacity, { passive: true })
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void nextTick(refreshCapacity)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', queueRefreshCapacity)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
columnCount,
|
||||
containerRef,
|
||||
itemCount,
|
||||
refreshCapacity,
|
||||
}
|
||||
}
|
||||
41
src/composables/useDashboardMotion.ts
Normal file
41
src/composables/useDashboardMotion.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { TransitionPresets, usePreferredReducedMotion, useTransition, type UseTransitionOptions } from '@vueuse/core'
|
||||
import { computed, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const fileSizeUnits = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
|
||||
|
||||
export function useDashboardMotionDisabled() {
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
|
||||
return computed(() => preferredMotion.value === 'reduce')
|
||||
}
|
||||
|
||||
export function useAnimatedDashboardNumber(source: MaybeRefOrGetter<number>, options: UseTransitionOptions = {}) {
|
||||
const disabled = useDashboardMotionDisabled()
|
||||
|
||||
return useTransition(source, {
|
||||
duration: 420,
|
||||
transition: TransitionPresets.easeOutQuart,
|
||||
disabled,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function formatDashboardCount(value: number) {
|
||||
return Math.round(Math.max(Number(value) || 0, 0)).toLocaleString()
|
||||
}
|
||||
|
||||
export function formatDashboardFileSize(bytes: number, decimals = 2, targetBytes = bytes) {
|
||||
let size = Math.abs(Number(targetBytes) || Number(bytes) || 0)
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < fileSizeUnits.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
const divisor = 1024 ** unitIndex
|
||||
const value = (Math.abs(Number(bytes) || 0) / divisor).toFixed(decimals)
|
||||
const prefix = bytes < 0 ? '-' : ''
|
||||
|
||||
return `${prefix}${value} ${fileSizeUnits[unitIndex]}`
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from 'vue'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 声明全局变量类型
|
||||
declare global {
|
||||
@@ -31,6 +31,8 @@ export interface DynamicButtonMenuItem {
|
||||
icon?: string
|
||||
color?: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
disabled?: boolean
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@@ -60,11 +62,12 @@ export function useDynamicButton(options: {
|
||||
onClick?: () => void
|
||||
menuItems?: MaybeRefValue<DynamicButtonMenuItem[] | undefined>
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: MaybeRefValue<boolean>
|
||||
autoRegister?: boolean // 是否自动注册,默认为true
|
||||
}) {
|
||||
// 提取配置
|
||||
const { icon, onClick, menuItems, permission, show, autoRegister = true } = options
|
||||
const { icon, onClick, menuItems, permission, feature, show, autoRegister = true } = options
|
||||
|
||||
// 动态按钮相关
|
||||
const registerDynamicButton = inject<((button: any) => void) | null>('registerDynamicButton', null)
|
||||
@@ -78,6 +81,7 @@ export function useDynamicButton(options: {
|
||||
const resolvedShow = computed(() => resolveMaybeRef(show, true))
|
||||
const resolvedMenuItems = computed(() => resolveMaybeRef(menuItems))
|
||||
|
||||
/** 根据当前响应式配置生成可注册的动态按钮对象。 */
|
||||
function buildDynamicButton() {
|
||||
const buttonMenuItems = resolvedMenuItems.value
|
||||
|
||||
@@ -85,12 +89,13 @@ export function useDynamicButton(options: {
|
||||
icon: resolvedIcon.value,
|
||||
action: onClick || (() => {}),
|
||||
permission,
|
||||
feature,
|
||||
show: resolvedShow.value,
|
||||
menuItems: buttonMenuItems && buttonMenuItems.length > 0 ? buttonMenuItems : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态按钮
|
||||
/** 在当前页面激活时注册动态按钮。 */
|
||||
function setupDynamicButton() {
|
||||
if (!componentActive.value) return
|
||||
|
||||
@@ -132,7 +137,7 @@ export function useDynamicButton(options: {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册动态按钮
|
||||
/** 清理当前页面注册过的动态按钮。 */
|
||||
function cleanupDynamicButton() {
|
||||
if (unregisterDynamicButton && dynamicButtonRegistered.value) {
|
||||
unregisterDynamicButton()
|
||||
@@ -146,7 +151,7 @@ export function useDynamicButton(options: {
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法:手动打开对话框
|
||||
/** 手动触发动态按钮主操作。 */
|
||||
function openDialog() {
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { useTabStateRestore } from '@/composables/useStateRestore'
|
||||
import type { UserPermissionKey } from '@/utils/permission'
|
||||
import type { UserPermissionFeatureKey, UserPermissionKey } from '@/utils/permission'
|
||||
|
||||
// 动态标签页相关类型
|
||||
interface DynamicHeaderTabButton {
|
||||
@@ -11,6 +11,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
@@ -20,6 +21,8 @@ interface DynamicHeaderTabItem {
|
||||
title: string
|
||||
icon?: string
|
||||
tab: string
|
||||
permission?: UserPermissionKey
|
||||
feature?: UserPermissionFeatureKey
|
||||
}
|
||||
|
||||
interface DynamicHeaderTabConfig {
|
||||
@@ -30,6 +33,7 @@ interface DynamicHeaderTabConfig {
|
||||
onUpdateModelValue?: (value: string) => void
|
||||
}
|
||||
|
||||
/** 提供页面动态头部标签的注册、状态恢复和注销能力。 */
|
||||
export function useDynamicHeaderTab() {
|
||||
const route = useRoute()
|
||||
|
||||
@@ -37,7 +41,7 @@ export function useDynamicHeaderTab() {
|
||||
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
|
||||
|
||||
// 注册动态标签页
|
||||
/** 注册当前页面的动态头部标签配置。 */
|
||||
const registerHeaderTab = (config: {
|
||||
items: DynamicHeaderTabItem[] | ComputedRef<DynamicHeaderTabItem[]> | Ref<DynamicHeaderTabItem[]>
|
||||
modelValue: Ref<string>
|
||||
@@ -184,7 +188,7 @@ export function useDynamicHeaderTab() {
|
||||
})
|
||||
}
|
||||
|
||||
// 取消注册
|
||||
/** 取消当前页面注册的动态头部标签。 */
|
||||
const unregisterHeaderTab = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
|
||||
503
src/composables/useMediaSubscribe.ts
Normal file
503
src/composables/useMediaSubscribe.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
import { defineAsyncComponent, ref, type Ref } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { formatSeason } from '@/@core/utils/formatters'
|
||||
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { setCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache'
|
||||
|
||||
export type SubscribeMode = 'normal' | 'best_version' | 'best_version_full'
|
||||
|
||||
interface SubscribePayload {
|
||||
best_version?: number
|
||||
best_version_full?: number
|
||||
}
|
||||
|
||||
interface SubscribeConfig {
|
||||
show_edit_dialog?: boolean
|
||||
best_version?: unknown
|
||||
best_version_full?: unknown
|
||||
}
|
||||
|
||||
interface AddSubscribeOptions {
|
||||
openEditDialog?: boolean
|
||||
}
|
||||
|
||||
interface RemoveSubscribeOptions {
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
interface UseMediaSubscribeOptions {
|
||||
media: () => MediaInfo | undefined
|
||||
canSubscribe: () => boolean
|
||||
isSubscribed?: Ref<boolean>
|
||||
isExists?: () => boolean
|
||||
seasonsSubscribed?: Ref<{ [key: number]: boolean }>
|
||||
subscribedSeasons?: Ref<number[]>
|
||||
subscribedSeasonModes?: Ref<SeasonSubscribeModes>
|
||||
primarySeason?: () => number | null
|
||||
getSubscribeStatusKey?: (season: number | null) => string
|
||||
onEditRemove?: () => void
|
||||
}
|
||||
|
||||
const SubscribeEditDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeEditDialog.vue'))
|
||||
const SubscribeModeDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeModeDialog.vue'))
|
||||
const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/dialog/SubscribeSeasonDialog.vue'))
|
||||
|
||||
export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
||||
|
||||
// 生成跨媒体源稳定的订阅媒体标识。
|
||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
if (media?.tmdb_id) return `tmdb:${media.tmdb_id}`
|
||||
if (media?.douban_id) return `douban:${media.douban_id}`
|
||||
if (media?.bangumi_id) return `bangumi:${media.bangumi_id}`
|
||||
return `${media?.mediaid_prefix}:${media?.media_id}`
|
||||
}
|
||||
|
||||
// 将订阅模式转换为后端订阅字段。
|
||||
function getSubscribePayload(mode: SubscribeMode): SubscribePayload {
|
||||
return {
|
||||
best_version: mode === 'normal' ? 0 : 1,
|
||||
best_version_full: mode === 'best_version_full' ? 1 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容布尔值和数字、字符串形式的开关值。
|
||||
function isEnabledFlag(value: unknown) {
|
||||
return value === true || value === 1 || value === '1'
|
||||
}
|
||||
|
||||
// 从订阅字段解析统一的订阅模式。
|
||||
export function getSubscribeMode(subscribe: { best_version?: unknown; best_version_full?: unknown }): SubscribeMode {
|
||||
if (!isEnabledFlag(subscribe.best_version)) return 'normal'
|
||||
|
||||
return isEnabledFlag(subscribe.best_version_full) ? 'best_version_full' : 'best_version'
|
||||
}
|
||||
|
||||
// 从默认订阅配置解析订阅模式。
|
||||
function getSubscribeConfigMode(config?: SubscribeConfig): SubscribeMode {
|
||||
return getSubscribeMode({
|
||||
best_version: config?.best_version,
|
||||
best_version_full: config?.best_version_full,
|
||||
})
|
||||
}
|
||||
|
||||
// 获取订阅模式的本地化名称。
|
||||
function getModeName(t: ReturnType<typeof useI18n>['t'], mode: SubscribeMode) {
|
||||
if (mode === 'normal') return t('dialog.subscribeMode.normal')
|
||||
if (mode === 'best_version') return t('dialog.subscribeMode.bestVersionEpisode')
|
||||
return t('dialog.subscribeMode.bestVersionFull')
|
||||
}
|
||||
|
||||
// 封装媒体卡片与详情页共用的订阅交互。
|
||||
export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
const { t } = useI18n()
|
||||
const $toast = useToast()
|
||||
const createConfirm = useConfirm()
|
||||
const episodeGroup = ref('')
|
||||
|
||||
// 获取调用方当前媒体,避免在异步流程中持有旧对象。
|
||||
function currentMedia() {
|
||||
return options.media()
|
||||
}
|
||||
|
||||
// 获取当前媒体的统一订阅标识。
|
||||
function getMediaId() {
|
||||
return getMediaSubscribeId(currentMedia())
|
||||
}
|
||||
|
||||
// 获取主订阅入口默认对应的季号。
|
||||
function getPrimarySeason() {
|
||||
return options.primarySeason?.() ?? currentMedia()?.season ?? null
|
||||
}
|
||||
|
||||
// 同步调用方状态和订阅状态缓存。
|
||||
function updateSubscribeStatus(season: number | null, subscribed: boolean, mode: SubscribeMode = 'normal') {
|
||||
const media = currentMedia()
|
||||
|
||||
if (media?.type === '电影' || season === null) {
|
||||
if (options.isSubscribed) options.isSubscribed.value = subscribed
|
||||
} else {
|
||||
if (options.seasonsSubscribed) options.seasonsSubscribed.value[season] = subscribed
|
||||
else if (options.subscribedSeasons) {
|
||||
const nextSeasons = new Set(options.subscribedSeasons.value)
|
||||
if (subscribed) nextSeasons.add(season)
|
||||
else nextSeasons.delete(season)
|
||||
options.subscribedSeasons.value = [...nextSeasons].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
if (options.isSubscribed) {
|
||||
if (subscribed) {
|
||||
options.isSubscribed.value = true
|
||||
} else if (options.seasonsSubscribed) {
|
||||
options.isSubscribed.value = Object.values(options.seasonsSubscribed.value).some(Boolean)
|
||||
} else if (options.subscribedSeasons) {
|
||||
options.isSubscribed.value = options.subscribedSeasons.value.length > 0
|
||||
} else {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (options.subscribedSeasonModes) {
|
||||
const nextModes = { ...options.subscribedSeasonModes.value }
|
||||
if (subscribed) nextModes[season] = mode
|
||||
else delete nextModes[season]
|
||||
options.subscribedSeasonModes.value = nextModes
|
||||
}
|
||||
}
|
||||
|
||||
if (options.getSubscribeStatusKey) {
|
||||
setCachedMediaSubscribeStatus(options.getSubscribeStatusKey(season), subscribed)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开已创建订阅的编辑弹窗。
|
||||
function openSubscribeEditDialog(subid: number) {
|
||||
openSharedDialog(
|
||||
SubscribeEditDialog,
|
||||
{ subid },
|
||||
{
|
||||
remove: () => {
|
||||
if (options.onEditRemove) {
|
||||
options.onEditRemove()
|
||||
} else if (options.isSubscribed) {
|
||||
options.isSubscribed.value = false
|
||||
}
|
||||
},
|
||||
},
|
||||
{ closeOn: ['close', 'save', 'remove'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开订阅模式选择弹窗并转换选择结果。
|
||||
function openSubscribeModeDialog(
|
||||
modes: SubscribeMode[],
|
||||
choose: (payload: SubscribePayload, mode: SubscribeMode) => void,
|
||||
) {
|
||||
openSharedDialog(
|
||||
SubscribeModeDialog,
|
||||
{ modes, type: currentMedia()?.type },
|
||||
{
|
||||
choose: (mode: SubscribeMode) => choose(getSubscribePayload(mode), mode),
|
||||
},
|
||||
{ closeOn: ['close', 'choose'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 打开季订阅弹窗,并保留发起入口当前使用的剧集组。
|
||||
async function openSubscribeSeasonDialog(selectedSeason?: number | null, initialEpisodeGroup = '') {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const defaultSubscribeConfig = await queryDefaultSubscribeConfig()
|
||||
|
||||
openSharedDialog(
|
||||
SubscribeSeasonDialog,
|
||||
{
|
||||
media,
|
||||
selectedSeason,
|
||||
initialEpisodeGroup,
|
||||
subscribedSeasons: options.subscribedSeasons?.value ?? [],
|
||||
subscribedSeasonModes: options.subscribedSeasonModes?.value ?? {},
|
||||
defaultSubscribeMode: getSubscribeConfigMode(defaultSubscribeConfig),
|
||||
},
|
||||
{
|
||||
subscribe: subscribeSeasons,
|
||||
},
|
||||
{ closeOn: ['close', 'subscribe'] },
|
||||
)
|
||||
}
|
||||
|
||||
// 查询系统默认订阅配置。
|
||||
async function queryDefaultSubscribeConfig(): Promise<SubscribeConfig | undefined> {
|
||||
if (!options.canSubscribe()) return undefined
|
||||
|
||||
try {
|
||||
const media = currentMedia()
|
||||
const subscribeConfigUrl =
|
||||
media?.type === '电影'
|
||||
? 'system/setting/public/DefaultMovieSubscribeConfig'
|
||||
: 'system/setting/public/DefaultTvSubscribeConfig'
|
||||
const result: { [key: string]: any } = await api.get(subscribeConfigUrl)
|
||||
|
||||
return result.data?.value
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 展示订阅新增结果通知。
|
||||
function showSubscribeAddToast(
|
||||
result: boolean,
|
||||
title: string,
|
||||
season: number | null,
|
||||
message: string,
|
||||
bestVersion: number,
|
||||
) {
|
||||
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
const subname = bestVersion > 0 ? t('subscribe.versionSub') : t('subscribe.normalSub')
|
||||
|
||||
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
|
||||
else $toast.error(`${title} ${t('subscribe.addFailed', { name: subname, message })}`)
|
||||
}
|
||||
|
||||
// 创建指定季和模式的订阅。
|
||||
async function addSubscribe(
|
||||
season: number | null = null,
|
||||
payload: SubscribePayload = {},
|
||||
addOptions: AddSubscribeOptions = {},
|
||||
) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.post('subscribe/', {
|
||||
name: media.title,
|
||||
type: media.type,
|
||||
year: media.year,
|
||||
tmdbid: media.tmdb_id,
|
||||
doubanid: media.douban_id,
|
||||
bangumiid: media.bangumi_id,
|
||||
mediaid: media.media_id ? `${media.mediaid_prefix}:${media.media_id}` : '',
|
||||
season: media.type === '电影' ? null : season,
|
||||
...payload,
|
||||
episode_group: episodeGroup.value,
|
||||
})
|
||||
|
||||
if (result.success) updateSubscribeStatus(media.type === '电影' ? null : season, true, getSubscribeMode(payload))
|
||||
|
||||
showSubscribeAddToast(result.success, media.title ?? '', season, result.message, payload.best_version ?? 0)
|
||||
|
||||
if (result.success && (addOptions.openEditDialog ?? true)) {
|
||||
const subscribeConfig = await queryDefaultSubscribeConfig()
|
||||
if (subscribeConfig?.show_edit_dialog) openSubscribeEditDialog(result.data.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除指定季的订阅。
|
||||
async function removeSubscribe(season: number | null = null, removeOptions: RemoveSubscribeOptions = {}) {
|
||||
if (removeOptions.confirm ?? true) {
|
||||
const confirmed = await createConfirm({
|
||||
title: t('common.confirm'),
|
||||
content: t('dialog.subscribeEdit.cancelSubscribeConfirm'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season: media.type === '电影' ? null : season,
|
||||
},
|
||||
})
|
||||
let title = media.title ?? ''
|
||||
if (media.type !== '电影' && season !== null) title = `${title} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(media.type === '电影' ? null : season, false)
|
||||
$toast.success(`${title} ${t('subscribe.cancelSuccess')}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.cancelFailed', { message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 检查当前媒体指定季是否已订阅。
|
||||
async function checkSubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return Boolean(result.id)
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return false
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 查询当前媒体指定季的订阅记录。
|
||||
async function querySubscribe(season: number | null = null) {
|
||||
try {
|
||||
const result: Subscribe = await api.get(`subscribe/media/${getMediaId()}`, {
|
||||
params: {
|
||||
season,
|
||||
title: currentMedia()?.title,
|
||||
},
|
||||
})
|
||||
|
||||
return result.id ? result : null
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 404) return null
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已有单季订阅的模式。
|
||||
async function updateSubscribeMode(season: number, mode: SubscribeMode) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
const subscribe = await querySubscribe(season)
|
||||
if (!subscribe?.id) {
|
||||
$toast.error(`${media.title ?? ''} ${formatSeason(season.toString())} ${t('subscribe.notFound')}`)
|
||||
return
|
||||
}
|
||||
|
||||
const payload = getSubscribePayload(mode)
|
||||
const result: { [key: string]: any } = await api.put('subscribe/', {
|
||||
...subscribe,
|
||||
...payload,
|
||||
})
|
||||
const title = `${media.title ?? ''} ${formatSeason(season.toString())}`
|
||||
|
||||
if (result.success) {
|
||||
updateSubscribeStatus(season, true, mode)
|
||||
$toast.success(`${title} ${t('subscribe.modeUpdateSuccess', { mode: getModeName(t, mode) })}`)
|
||||
} else {
|
||||
$toast.error(`${title} ${t('subscribe.addFailed', { name: getModeName(t, mode), message: result.message })}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
doneNProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// 处理单季订阅入口,未订阅时将当前剧集组带入季选择弹窗。
|
||||
function handleSeasonSubscribe(season: number, initialEpisodeGroup = '') {
|
||||
if (options.seasonsSubscribed?.value[season]) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
openSubscribeSeasonDialog(season, initialEpisodeGroup)
|
||||
}
|
||||
|
||||
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
|
||||
function handlePrimarySubscribe() {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
|
||||
const season = media.type === '电影' ? null : getPrimarySeason()
|
||||
|
||||
if (media.type === '电视剧') {
|
||||
openSubscribeSeasonDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isSubscribed?.value) {
|
||||
removeSubscribe(season)
|
||||
return
|
||||
}
|
||||
|
||||
if (options.isExists?.()) {
|
||||
openSubscribeModeDialog(['normal', 'best_version'], payload => addSubscribe(null, payload))
|
||||
return
|
||||
}
|
||||
|
||||
addSubscribe(null)
|
||||
}
|
||||
|
||||
// 根据是否指定季号分发主订阅或单季订阅操作。
|
||||
function handleSubscribe(season?: number | null, initialEpisodeGroup = '') {
|
||||
if (season !== undefined && season !== null) {
|
||||
handleSeasonSubscribe(season, initialEpisodeGroup)
|
||||
return
|
||||
}
|
||||
|
||||
handlePrimarySubscribe()
|
||||
}
|
||||
|
||||
// 批量对齐弹窗中选择的季、订阅模式和当前订阅状态。
|
||||
function subscribeSeasons(
|
||||
seasons: MediaSeason[] = [],
|
||||
seasonExistsStates: { [key: number]: number } = {},
|
||||
groupId = '',
|
||||
seasonModes: SubscribeMode | SeasonSubscribeModes = 'normal',
|
||||
visibleSeasonNumbers: number[] = [],
|
||||
) {
|
||||
episodeGroup.value = groupId
|
||||
const subscribedSeasonSet = new Set(options.subscribedSeasons?.value ?? [])
|
||||
const selectedSeasonSet = new Set(
|
||||
seasons.map(season => season.season_number).filter((season): season is number => season !== null && season !== undefined),
|
||||
)
|
||||
const visibleSeasonSet = new Set(visibleSeasonNumbers)
|
||||
const seasonsToSubscribe = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
return seasonNumber !== null && !subscribedSeasonSet.has(seasonNumber)
|
||||
})
|
||||
const seasonsToUnsubscribe = [...subscribedSeasonSet].filter(
|
||||
season => visibleSeasonSet.has(season) && !selectedSeasonSet.has(season),
|
||||
)
|
||||
const seasonsToUpdateMode = seasons.filter(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null || !subscribedSeasonSet.has(seasonNumber)) return false
|
||||
|
||||
const nextMode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
return (options.subscribedSeasonModes?.value[seasonNumber] ?? 'normal') !== nextMode
|
||||
})
|
||||
|
||||
seasonsToUnsubscribe.forEach(season => {
|
||||
removeSubscribe(season, { confirm: false })
|
||||
})
|
||||
|
||||
seasonsToUpdateMode.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
updateSubscribeMode(seasonNumber, mode)
|
||||
})
|
||||
|
||||
seasonsToSubscribe.forEach(season => {
|
||||
const seasonNumber = season.season_number ?? null
|
||||
if (seasonNumber === null) return
|
||||
|
||||
const mode = typeof seasonModes === 'string' ? seasonModes : seasonModes[seasonNumber] ?? 'normal'
|
||||
const payload = getSubscribePayload(mode)
|
||||
addSubscribe(
|
||||
seasonNumber,
|
||||
payload,
|
||||
{
|
||||
openEditDialog: seasonsToSubscribe.length === 1 && seasonsToUnsubscribe.length === 0,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
addSubscribe,
|
||||
checkSubscribe,
|
||||
handleSubscribe,
|
||||
openSubscribeSeasonDialog,
|
||||
removeSubscribe,
|
||||
subscribeSeasons,
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,82 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
|
||||
// 全局状态
|
||||
const isAppOffline = ref(false)
|
||||
const appOfflineReason = ref('')
|
||||
const consecutiveNetworkErrors = ref(0)
|
||||
const MAX_CONSECUTIVE_ERRORS = 3
|
||||
export type ConnectionStatus = 'online' | 'checking' | 'offline'
|
||||
export type ConnectionFailureReason = 'browser-offline' | 'network-error' | 'timeout' | 'server-unreachable'
|
||||
|
||||
// 全局离线状态管理
|
||||
const browserOnline = useOnline()
|
||||
const connectionStatus = ref<ConnectionStatus>('online')
|
||||
const connectionReason = ref<ConnectionFailureReason | null>(null)
|
||||
const connectionCheckRequestId = ref(0)
|
||||
const serverSuccessSequence = ref(0)
|
||||
|
||||
/** 管理 MoviePilot 服务的全局连接状态。 */
|
||||
export function useGlobalOfflineStatus() {
|
||||
const isOnline = useOnline()
|
||||
const isOnline = computed(() => connectionStatus.value === 'online')
|
||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||
const isOffline = computed(() => connectionStatus.value === 'offline')
|
||||
const canPerformNetworkAction = computed(() => connectionStatus.value !== 'offline')
|
||||
|
||||
// 综合离线状态(网络离线 或 应用离线)
|
||||
const isOffline = computed(() => !isOnline.value || isAppOffline.value)
|
||||
|
||||
// 是否可以执行网络操作
|
||||
const canPerformNetworkAction = computed(() => isOnline.value && !isAppOffline.value)
|
||||
|
||||
// 设置应用离线状态
|
||||
const setAppOffline = (offline: boolean, reason?: string) => {
|
||||
isAppOffline.value = offline
|
||||
appOfflineReason.value = reason || ''
|
||||
|
||||
// 如果设置为在线状态,重置连续错误计数
|
||||
if (!offline) {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
}
|
||||
/** 记录任意 MoviePilot API 成功响应并恢复在线状态。 */
|
||||
function markServerOnline() {
|
||||
connectionStatus.value = 'online'
|
||||
connectionReason.value = null
|
||||
serverSuccessSequence.value += 1
|
||||
}
|
||||
|
||||
// 记录网络错误
|
||||
const recordNetworkError = (reason?: string) => {
|
||||
consecutiveNetworkErrors.value++
|
||||
|
||||
// 只有连续出现三次网络错误时才设置为离线模式
|
||||
if (consecutiveNetworkErrors.value >= MAX_CONSECUTIVE_ERRORS) {
|
||||
setAppOffline(true, reason || `连续${MAX_CONSECUTIVE_ERRORS}次网络错误`)
|
||||
}
|
||||
/** 将连接状态标记为待确认,但不直接阻断页面操作。 */
|
||||
function markConnectionChecking(reason?: ConnectionFailureReason) {
|
||||
connectionStatus.value = 'checking'
|
||||
if (reason) connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 重置连续错误计数
|
||||
const resetConsecutiveErrors = () => {
|
||||
consecutiveNetworkErrors.value = 0
|
||||
/** 在权威探测失败后标记 MoviePilot 服务不可达。 */
|
||||
function markServerOffline(reason: ConnectionFailureReason = 'server-unreachable') {
|
||||
connectionStatus.value = 'offline'
|
||||
connectionReason.value = reason
|
||||
}
|
||||
|
||||
// 获取离线消息
|
||||
const getOfflineMessage = () => {
|
||||
if (!isOnline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
if (isAppOffline.value) {
|
||||
return appOfflineReason.value
|
||||
}
|
||||
return ''
|
||||
/** 将普通请求的网络错误降级为待确认状态,并请求一次去重后的服务探测。 */
|
||||
function reportNetworkError(reason: ConnectionFailureReason = 'network-error') {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
/** 请求立即检查 MoviePilot 服务连接。 */
|
||||
function requestConnectionCheck(reason?: ConnectionFailureReason) {
|
||||
markConnectionChecking(reason)
|
||||
connectionCheckRequestId.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
browserOnline,
|
||||
connectionStatus,
|
||||
connectionReason,
|
||||
connectionCheckRequestId,
|
||||
serverSuccessSequence,
|
||||
isOnline,
|
||||
isChecking,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
setAppOffline,
|
||||
recordNetworkError,
|
||||
resetConsecutiveErrors,
|
||||
getOfflineMessage,
|
||||
consecutiveNetworkErrors: computed(() => consecutiveNetworkErrors.value),
|
||||
markServerOnline,
|
||||
markConnectionChecking,
|
||||
markServerOffline,
|
||||
reportNetworkError,
|
||||
requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
// 单个组件的离线状态
|
||||
export function useOfflineStatus(initialMessage?: string) {
|
||||
const { isOnline, isOffline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
const message = computed(() => {
|
||||
if (initialMessage) {
|
||||
return initialMessage
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
/** 为单个组件提供 MoviePilot 服务连接状态。 */
|
||||
export function useOfflineStatus() {
|
||||
const status = useGlobalOfflineStatus()
|
||||
|
||||
return {
|
||||
isOnline,
|
||||
isOffline,
|
||||
canPerformNetworkAction,
|
||||
message,
|
||||
browserOnline: status.browserOnline,
|
||||
isOnline: status.isOnline,
|
||||
isChecking: status.isChecking,
|
||||
isOffline: status.isOffline,
|
||||
canPerformNetworkAction: status.canPerformNetworkAction,
|
||||
connectionReason: status.connectionReason,
|
||||
requestConnectionCheck: status.requestConnectionCheck,
|
||||
}
|
||||
}
|
||||
|
||||
114
src/composables/useScheduleProgress.ts
Normal file
114
src/composables/useScheduleProgress.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, ScheduleInfo, ScheduleProgress } from '@/api/types'
|
||||
import { useBackground } from '@/composables/useBackground'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
const SCHEDULE_PROGRESS_REFRESH_INTERVAL = 1000
|
||||
const SCHEDULE_RUNNING_STATUS = '正在运行'
|
||||
const SCHEDULE_WAITING_STATUS = '等待'
|
||||
|
||||
/** 判断定时服务是否仍在运行,兼容列表状态与进度详情两种后端信号。 */
|
||||
export function isScheduleRunning(schedule: ScheduleInfo) {
|
||||
return (
|
||||
schedule.status === SCHEDULE_RUNNING_STATUS ||
|
||||
schedule.progress_enable === true ||
|
||||
schedule.progress_detail?.enable === true ||
|
||||
schedule.progress_detail?.status === 'running'
|
||||
)
|
||||
}
|
||||
|
||||
/** 判断定时服务是否处于等待执行状态。 */
|
||||
export function isScheduleWaiting(schedule: ScheduleInfo) {
|
||||
return schedule.status === SCHEDULE_WAITING_STATUS
|
||||
}
|
||||
|
||||
/** 获取定时服务展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleName(schedule: ScheduleInfo) {
|
||||
return schedule.name_i18n || schedule.name || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务提供者展示名称,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleProvider(schedule: ScheduleInfo) {
|
||||
return schedule.provider_i18n || schedule.provider || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务状态展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleStatusText(schedule: ScheduleInfo) {
|
||||
return schedule.status_i18n || schedule.status || ''
|
||||
}
|
||||
|
||||
/** 获取定时服务下次运行展示文本,优先使用后端按当前语言生成的文本。 */
|
||||
export function getScheduleNextRunText(schedule: ScheduleInfo) {
|
||||
return schedule.next_run_i18n || schedule.next_run || ''
|
||||
}
|
||||
|
||||
/** 为定时服务列表提供仅针对运行中任务的实时进度轮询。 */
|
||||
export function useScheduleProgress(schedules: Ref<ScheduleInfo[]>, refreshId: string) {
|
||||
const { useDataRefresh } = useBackground()
|
||||
const progressById = ref<Record<string, ScheduleProgress>>({})
|
||||
|
||||
/** 请求指定运行中任务的最新进度。 */
|
||||
async function loadScheduleProgress(schedule: ScheduleInfo) {
|
||||
const response = (await api.get(
|
||||
`dashboard/schedule/${encodeURIComponent(schedule.id)}/progress`,
|
||||
)) as ApiResponse<ScheduleProgress>
|
||||
|
||||
return response.success ? response.data : undefined
|
||||
}
|
||||
|
||||
/** 刷新所有运行中任务的进度,并清理已经停止任务的缓存。 */
|
||||
async function refreshRunningProgress() {
|
||||
const runningSchedules = schedules.value.filter(
|
||||
schedule => schedule.id && isScheduleRunning(schedule),
|
||||
)
|
||||
const runningIds = new Set(runningSchedules.map(schedule => schedule.id))
|
||||
const nextProgress = Object.fromEntries(
|
||||
Object.entries(progressById.value).filter(([id]) => runningIds.has(id)),
|
||||
)
|
||||
|
||||
if (!runningSchedules.length) {
|
||||
progressById.value = {}
|
||||
return
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(runningSchedules.map(loadScheduleProgress))
|
||||
const currentRunningIds = new Set(
|
||||
schedules.value
|
||||
.filter(schedule => schedule.id && isScheduleRunning(schedule))
|
||||
.map(schedule => schedule.id),
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const schedule = runningSchedules[index]
|
||||
if (result.status === 'fulfilled' && result.value && currentRunningIds.has(schedule.id)) {
|
||||
nextProgress[schedule.id] = result.value
|
||||
}
|
||||
})
|
||||
|
||||
progressById.value = Object.fromEntries(
|
||||
Object.entries(nextProgress).filter(([id]) => currentRunningIds.has(id)),
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的百分比,并限制在合法范围内。 */
|
||||
function getScheduleProgressValue(schedule: ScheduleInfo) {
|
||||
const value = Number(progressById.value[schedule.id]?.value ?? schedule.progress ?? 0)
|
||||
|
||||
return Math.min(Math.max(value, 0), 100)
|
||||
}
|
||||
|
||||
/** 获取任务当前应展示的进度说明。 */
|
||||
function getScheduleProgressText(schedule: ScheduleInfo) {
|
||||
const progress = progressById.value[schedule.id]
|
||||
|
||||
return progress?.text_i18n || progress?.text || schedule.progress_text_i18n || schedule.progress_text || ''
|
||||
}
|
||||
|
||||
useDataRefresh(refreshId, refreshRunningProgress, SCHEDULE_PROGRESS_REFRESH_INTERVAL, true)
|
||||
|
||||
return {
|
||||
getScheduleProgressText,
|
||||
getScheduleProgressValue,
|
||||
refreshRunningProgress,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export interface WizardData {
|
||||
baseUrlPreset: string
|
||||
maxContextTokens: number
|
||||
userAgent: string
|
||||
temperature: number
|
||||
audioInputProvider: string
|
||||
audioInputApiKey: string
|
||||
audioInputBaseUrl: string
|
||||
@@ -253,6 +254,7 @@ const wizardData = ref<WizardData>({
|
||||
baseUrlPreset: '',
|
||||
maxContextTokens: 64,
|
||||
userAgent: '',
|
||||
temperature: 0.3,
|
||||
audioInputProvider: 'openai',
|
||||
audioInputApiKey: '',
|
||||
audioInputBaseUrl: '',
|
||||
@@ -1436,6 +1438,7 @@ export function useSetupWizard() {
|
||||
// 保存智能助手设置
|
||||
async function saveAgentSettings() {
|
||||
try {
|
||||
const agentTemperature = Number(wizardData.value.agent.temperature ?? 0.3)
|
||||
const agentSettings = {
|
||||
AI_AGENT_ENABLE: wizardData.value.agent.enabled,
|
||||
AI_AGENT_GLOBAL: wizardData.value.agent.enabled ? wizardData.value.agent.global : false,
|
||||
@@ -1452,6 +1455,7 @@ export function useSetupWizard() {
|
||||
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
|
||||
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
|
||||
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
|
||||
LLM_TEMPERATURE: Number.isFinite(agentTemperature) ? agentTemperature : 0.3,
|
||||
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
|
||||
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
|
||||
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
|
||||
@@ -1567,6 +1571,8 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
|
||||
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
|
||||
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
|
||||
const agentTemperature = Number(result.data.LLM_TEMPERATURE ?? 0.3)
|
||||
wizardData.value.agent.temperature = Number.isFinite(agentTemperature) ? agentTemperature : 0.3
|
||||
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
|
||||
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
|
||||
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''
|
||||
|
||||
157
src/composables/useShortcutTools.ts
Normal file
157
src/composables/useShortcutTools.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Component } from 'vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
hasItemPermission,
|
||||
type PermissionProtectedItem,
|
||||
} from '@/utils/permission'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const NameTestView = defineAsyncComponent(() => import('@/views/system/NameTestView.vue'))
|
||||
const NetTestView = defineAsyncComponent(() => import('@/views/system/NetTestView.vue'))
|
||||
const RuleTestView = defineAsyncComponent(() => import('@/views/system/RuleTestView.vue'))
|
||||
const ModuleTestView = defineAsyncComponent(() => import('@/views/system/ModuleTestView.vue'))
|
||||
const WordsView = defineAsyncComponent(() => import('@/views/system/WordsView.vue'))
|
||||
const CacheView = defineAsyncComponent(() => import('@/views/system/CacheView.vue'))
|
||||
const AccountSettingService = defineAsyncComponent(() => import('@/views/system/ServiceView.vue'))
|
||||
const ShortcutLogDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutLogDialog.vue'))
|
||||
const ShortcutToolDialog = defineAsyncComponent(() => import('@/components/dialog/ShortcutToolDialog.vue'))
|
||||
|
||||
// 定时服务在捷径与仪表板中共用的图标,避免两个入口的视觉语义漂移。
|
||||
export const SCHEDULER_SHORTCUT_ICON = 'mdi-list-box'
|
||||
|
||||
export type ShortcutToolItem = PermissionProtectedItem & {
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
component?: Component
|
||||
customDialog?: Component
|
||||
dialog: string
|
||||
dialogSubtitle?: string
|
||||
icon: string
|
||||
maxWidth?: string
|
||||
subtitle: string
|
||||
title: string
|
||||
titleText?: string
|
||||
}
|
||||
|
||||
/** 提供顶部捷径与仪表板共用的工具定义和打开逻辑。 */
|
||||
export function useShortcutTools() {
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
|
||||
const shortcuts: ShortcutToolItem[] = [
|
||||
{
|
||||
title: t('shortcut.recognition.title'),
|
||||
subtitle: t('shortcut.recognition.subtitle'),
|
||||
icon: 'mdi-text-recognition',
|
||||
dialog: 'nameTest',
|
||||
component: NameTestView,
|
||||
maxWidth: '45rem',
|
||||
titleText: t('shortcut.recognition.title'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.rule.title'),
|
||||
subtitle: t('shortcut.rule.subtitle'),
|
||||
icon: 'mdi-filter-cog',
|
||||
dialog: 'ruleTest',
|
||||
component: RuleTestView,
|
||||
titleText: t('shortcut.rule.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.log.title'),
|
||||
subtitle: t('shortcut.log.subtitle'),
|
||||
icon: 'mdi-file-document',
|
||||
dialog: 'logging',
|
||||
customDialog: ShortcutLogDialog,
|
||||
},
|
||||
{
|
||||
title: t('shortcut.network.title'),
|
||||
subtitle: t('shortcut.network.subtitle'),
|
||||
icon: 'mdi-network',
|
||||
dialog: 'netTest',
|
||||
component: NetTestView,
|
||||
titleText: t('shortcut.network.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.words.title'),
|
||||
subtitle: t('shortcut.words.subtitle'),
|
||||
icon: 'mdi-file-word-box',
|
||||
dialog: 'words',
|
||||
bodyClass: 'words-shortcut-dialog-body pa-0',
|
||||
cardClass: 'words-shortcut-dialog-card',
|
||||
component: WordsView,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.words.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.cache.title'),
|
||||
subtitle: t('shortcut.cache.subtitle'),
|
||||
icon: 'mdi-database',
|
||||
dialog: 'cache',
|
||||
bodyClass: 'cache-shortcut-dialog-body',
|
||||
cardClass: 'cache-shortcut-dialog-card',
|
||||
component: CacheView,
|
||||
maxWidth: '90rem',
|
||||
titleText: t('shortcut.cache.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.scheduler.title'),
|
||||
subtitle: t('shortcut.scheduler.subtitle'),
|
||||
icon: SCHEDULER_SHORTCUT_ICON,
|
||||
dialog: 'scheduler',
|
||||
bodyClass: 'scheduler-shortcut-dialog-body pa-0',
|
||||
cardClass: 'scheduler-shortcut-dialog-card',
|
||||
component: AccountSettingService,
|
||||
maxWidth: '60rem',
|
||||
titleText: t('shortcut.scheduler.subtitle'),
|
||||
dialogSubtitle: t('setting.scheduler.subtitle'),
|
||||
},
|
||||
{
|
||||
title: t('shortcut.system.title'),
|
||||
subtitle: t('shortcut.system.subtitle'),
|
||||
icon: 'mdi-cog',
|
||||
dialog: 'systemTest',
|
||||
bodyClass: 'system-health-dialog-body pa-0',
|
||||
cardClass: 'system-health-dialog-card',
|
||||
component: ModuleTestView,
|
||||
titleText: t('shortcut.system.subtitle'),
|
||||
},
|
||||
].map(item => ({ ...item, permission: 'admin' }))
|
||||
|
||||
const visibleShortcuts = computed(() => filterItemsByPermission(shortcuts, userPermissions.value))
|
||||
|
||||
/** 打开工具对应的共享弹窗。 */
|
||||
function openShortcutDialog(item: ShortcutToolItem) {
|
||||
if (!hasItemPermission(item, userPermissions.value)) return
|
||||
|
||||
if (item.customDialog) {
|
||||
openSharedDialog(item.customDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.component) return
|
||||
|
||||
openSharedDialog(
|
||||
ShortcutToolDialog,
|
||||
{
|
||||
bodyClass: item.bodyClass,
|
||||
cardClass: item.cardClass,
|
||||
icon: item.icon,
|
||||
maxWidth: item.maxWidth ?? '35rem',
|
||||
subtitle: item.dialogSubtitle,
|
||||
title: item.titleText ?? item.title,
|
||||
view: item.component,
|
||||
},
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
openShortcutDialog,
|
||||
visibleShortcuts,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user