Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3b99ae51 | ||
|
|
e5339d524b | ||
|
|
e218508c2d | ||
|
|
e7bfc4ec1c | ||
|
|
5c63fe2313 | ||
|
|
8d86bd6e92 | ||
|
|
8a4ca2482c | ||
|
|
3daa031a04 | ||
|
|
15ef332ea2 | ||
|
|
57448ca609 | ||
|
|
0f7c95cfaf | ||
|
|
3886909e9e | ||
|
|
0f8a817413 | ||
|
|
eb47545b76 | ||
|
|
6fc123e0f6 | ||
|
|
5bdef2ba8f | ||
|
|
42ca5b8f94 | ||
|
|
ba48a25982 | ||
|
|
76c524eb9b | ||
|
|
48f8e8fc71 | ||
|
|
2a0f15c1bd | ||
|
|
e94ade364e | ||
|
|
27f6c33782 | ||
|
|
b3e596c761 | ||
|
|
0ee9e466c3 |
@@ -322,7 +322,21 @@ const emit = defineEmits(['action'])
|
||||
|
||||
`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||
|
||||
### 5.6 调用主应用原生订阅
|
||||
### 5.6 玻璃光学表面
|
||||
|
||||
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
||||
|
||||
```html
|
||||
<div data-glass-optical-surface data-glass-optical-mode="dynamic">
|
||||
<!-- 插件自己的视觉内容 -->
|
||||
</div>
|
||||
```
|
||||
|
||||
使用时需同时声明 `data-glass-optical-surface` 和 `data-glass-optical-mode="dynamic"`。模式会从最近的祖先容器继承,因此显式声明的动态子表面不会沿用宿主的静态模式。该合同适用于插件在 `Page`、`Config` 或 `AppPage` 中自行渲染并控制的区域;主应用生成的插件列表、插件市场卡片、`Dashboard` 及其他宿主 DOM 不属于插件的修改边界。
|
||||
|
||||
动态模式只在主应用启用玻璃主题和实时光学能力时生效。其他主题、降低动态效果或光学能力不可用时,插件必须保持内容与交互正常,不应依赖动态光学表达业务状态或必要反馈。
|
||||
|
||||
### 5.7 调用主应用原生订阅
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 都会收到 `nativeSubscribe(mediaInfo)` prop。插件传入媒体信息后,电视剧会打开主应用的选季抽屉,电影会进入现有电影订阅流程。宿主也会用 `moviepilot:nativeSubscribe` 键提供同一个方法,深层子组件可以使用 `inject`,无需逐层传递 prop。
|
||||
|
||||
@@ -357,7 +371,7 @@ async function subscribeMedia(mediaInfo: Record<string, unknown>) {
|
||||
|
||||
`success: true` 表示主应用已接受调用并启动原生交互,不表示用户已经完成订阅。字段无效或当前用户没有订阅权限时返回 `success: false`,插件可以依据 `code` 执行 fallback。
|
||||
|
||||
### 5.7 调用主应用 Toast
|
||||
### 5.8 调用主应用 Toast
|
||||
|
||||
`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用 Toast。远程组件应复用该实例,不要自行渲染 `VSnackbar` 或创建另一套 Toast 容器:
|
||||
|
||||
|
||||
@@ -54,9 +54,6 @@
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"no-useless-catch": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
1648
index.html
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.15.0",
|
||||
"version": "2.15.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"dev:pwa": "vite --host --port 5174",
|
||||
"prebuild": "npm run build:icons",
|
||||
"prebuild": "npm run build:icons && npm run generate:pwa-splash",
|
||||
"build": "vite build",
|
||||
"generate:pwa-splash": "node scripts/generate-pwa-splash.mjs",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
@@ -131,6 +132,7 @@
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-html": "^1.5.0",
|
||||
"prettier": "^3.9.5",
|
||||
"sharp": "^0.33.5",
|
||||
"stylelint": "^16.13.2",
|
||||
"stylelint-config-idiomatic-order": "^10.0.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
|
||||
BIN
public/splash/apple-splash-1125-2436.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1136-640.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/splash/apple-splash-1170-2532.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1206-2622.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1242-2208.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-1242-2688.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1260-2736.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1284-2778.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-1320-2868.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/splash/apple-splash-1334-750.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/splash/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/splash/apple-splash-1536-2048.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-1620-2160.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-1668-2224.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-1668-2388.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-1792-828.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/splash/apple-splash-2048-1536.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/splash/apple-splash-2048-2732.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/splash/apple-splash-2160-1620.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2208-1242.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2224-1668.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/splash/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2388-1668.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2436-1125.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/splash/apple-splash-2532-1170.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/splash/apple-splash-2622-1206.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2688-1242.jpg
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/splash/apple-splash-2732-2048.jpg
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/splash/apple-splash-2736-1260.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/splash/apple-splash-2778-1284.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/splash/apple-splash-2868-1320.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/splash/apple-splash-640-1136.jpg
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/splash/apple-splash-750-1334.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/splash/apple-splash-828-1792.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 16 KiB |
68
scripts/generate-pwa-splash.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import sharp from 'sharp'
|
||||
import appleSplashSpecs from './pwa-splash-specs.json' with { type: 'json' }
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const logoPath = path.join(projectRoot, 'public', 'logo.svg')
|
||||
const outputDirectory = path.join(projectRoot, 'public', 'splash')
|
||||
const background = '#0E1116'
|
||||
|
||||
async function createSplash(width, height, scaleFactor, outputPath, format) {
|
||||
// Match the DOM loader's `min(160px, 36vw)` in CSS pixels, then convert it
|
||||
// to physical pixels for the selected Apple launch image.
|
||||
const logoSize = Math.round(Math.min(160, (width / scaleFactor) * 0.36) * scaleFactor)
|
||||
const logo = await sharp(logoPath).resize(logoSize, logoSize, { fit: 'contain' }).png().toBuffer()
|
||||
const image = sharp({
|
||||
create: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
background,
|
||||
},
|
||||
}).composite([
|
||||
{
|
||||
input: logo,
|
||||
left: Math.round((width - logoSize) / 2),
|
||||
top: Math.round((height - logoSize) / 2),
|
||||
},
|
||||
])
|
||||
|
||||
if (format === 'png') {
|
||||
await image.png({ compressionLevel: 9 }).toFile(outputPath)
|
||||
return
|
||||
}
|
||||
|
||||
await image.flatten({ background }).jpeg({ quality: 88, progressive: true }).toFile(outputPath)
|
||||
}
|
||||
|
||||
await mkdir(outputDirectory, { recursive: true })
|
||||
|
||||
for (const { width: portraitWidth, height: portraitHeight, scaleFactor } of appleSplashSpecs) {
|
||||
const landscapeWidth = portraitHeight
|
||||
const landscapeHeight = portraitWidth
|
||||
|
||||
await createSplash(
|
||||
portraitWidth,
|
||||
portraitHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${portraitWidth}-${portraitHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
await createSplash(
|
||||
landscapeWidth,
|
||||
landscapeHeight,
|
||||
scaleFactor,
|
||||
path.join(outputDirectory, `apple-splash-${landscapeWidth}-${landscapeHeight}.jpg`),
|
||||
'jpg',
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the previous fallback filename for older deployments and bookmarked
|
||||
// entries that may still reference it. Its palette matches the new assets.
|
||||
await createSplash(750, 1334, 2, path.join(outputDirectory, 'apple-splash.png'), 'png')
|
||||
|
||||
console.log(
|
||||
`Generated ${appleSplashSpecs.length * 2 + 1} PWA splash assets in ${path.relative(projectRoot, outputDirectory)}`,
|
||||
)
|
||||
22
scripts/pwa-splash-specs.json
Normal file
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{ "width": 2048, "height": 2732, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2388, "scaleFactor": 2 },
|
||||
{ "width": 1536, "height": 2048, "scaleFactor": 2 },
|
||||
{ "width": 1640, "height": 2360, "scaleFactor": 2 },
|
||||
{ "width": 1668, "height": 2224, "scaleFactor": 2 },
|
||||
{ "width": 1620, "height": 2160, "scaleFactor": 2 },
|
||||
{ "width": 1488, "height": 2266, "scaleFactor": 2 },
|
||||
{ "width": 1320, "height": 2868, "scaleFactor": 3 },
|
||||
{ "width": 1206, "height": 2622, "scaleFactor": 3 },
|
||||
{ "width": 1260, "height": 2736, "scaleFactor": 3 },
|
||||
{ "width": 1290, "height": 2796, "scaleFactor": 3 },
|
||||
{ "width": 1179, "height": 2556, "scaleFactor": 3 },
|
||||
{ "width": 1170, "height": 2532, "scaleFactor": 3 },
|
||||
{ "width": 1284, "height": 2778, "scaleFactor": 3 },
|
||||
{ "width": 1125, "height": 2436, "scaleFactor": 3 },
|
||||
{ "width": 1242, "height": 2688, "scaleFactor": 3 },
|
||||
{ "width": 828, "height": 1792, "scaleFactor": 2 },
|
||||
{ "width": 1242, "height": 2208, "scaleFactor": 3 },
|
||||
{ "width": 750, "height": 1334, "scaleFactor": 2 },
|
||||
{ "width": 640, "height": 1136, "scaleFactor": 2 }
|
||||
]
|
||||
53
src/@core/utils/__tests__/image.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {},
|
||||
}))
|
||||
|
||||
import { preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
describe('preloadCorsImage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('uses a CORS-clean cached response without reloading it', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://image.example/wallpaper.jpg'),
|
||||
expect.objectContaining({ cache: 'force-cache', credentials: 'omit', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('reloads a response when an earlier non-CORS cache entry blocks the first request', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
||||
.mockResolvedValueOnce({
|
||||
blob: vi.fn().mockResolvedValue(new Blob(['image'])),
|
||||
ok: true,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(preloadCorsImage('/wallpaper.jpg')).resolves.toBe(true)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
new URL('/wallpaper.jpg', window.location.href),
|
||||
expect.objectContaining({ cache: 'reload', credentials: 'same-origin', mode: 'cors' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false when the source cannot be read with CORS', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
await expect(preloadCorsImage('https://image.example/wallpaper.jpg')).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -70,22 +70,57 @@ export async function getDominantColor(
|
||||
export async function preloadImage(url: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
let settled = false
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return
|
||||
|
||||
img.onload = () => resolve(true)
|
||||
img.onerror = () => resolve(false)
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
resolve(available)
|
||||
}
|
||||
|
||||
img.onload = () => finish(true)
|
||||
img.onerror = () => finish(false)
|
||||
|
||||
// 设置超时,防止图片长时间加载
|
||||
const timeout = setTimeout(() => {
|
||||
img.src = ''
|
||||
resolve(false)
|
||||
finish(false)
|
||||
}, 5000) // 5秒超时
|
||||
|
||||
img.src = url
|
||||
|
||||
// 如果图片已经缓存,onload可能不会触发
|
||||
if (img.complete) {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
finish(img.naturalWidth > 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 在纹理加载前建立带 CORS 响应头的缓存,避免普通图片缓存污染 WebGL 读取。 */
|
||||
export async function preloadCorsImage(url: string): Promise<boolean> {
|
||||
const request = async (cache: RequestCache) => {
|
||||
const source = new URL(url, window.location.href)
|
||||
const response = await fetch(source, {
|
||||
cache,
|
||||
credentials: source.origin === window.location.origin ? 'same-origin' : 'omit',
|
||||
mode: 'cors',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
|
||||
await response.blob()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (await request('force-cache')) return true
|
||||
} catch {
|
||||
// 缓存中的非 CORS 响应可能使首次读取失败,重新验证后再决定是否回退。
|
||||
}
|
||||
|
||||
try {
|
||||
return await request('reload')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@ export function saveLocalTheme(name: string, theme: any) {
|
||||
localStorage.setItem('theme', name)
|
||||
localStorage.setItem('materio-initial-loader-bg', theme.current.value.colors.background)
|
||||
localStorage.setItem('materio-initial-loader-color', theme.current.value.colors.primary)
|
||||
|
||||
// 自动主题下次恢复时需要一个稳定的首帧明暗结果,避免媒体查询短暂返回浅色。
|
||||
if (name === 'auto') {
|
||||
localStorage.setItem('materio-initial-resolved-theme', theme.current.value.dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
515
src/App.vue
@@ -7,7 +7,7 @@ import { useAuthStore, useGlobalSettingsStore } from '@/stores'
|
||||
import { getBrowserLocale, setI18nLanguage } from './plugins/i18n'
|
||||
import { SupportedLocale } from '@/types/i18n'
|
||||
import { checkAndEmitUnreadMessages } from '@/utils/badge'
|
||||
import { preloadImage } from './@core/utils/image'
|
||||
import { preloadCorsImage, preloadImage } from './@core/utils/image'
|
||||
import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
@@ -27,16 +27,82 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { commitPreloadedBackgroundRotation } from '@/utils/backgroundRotation'
|
||||
import {
|
||||
BACKGROUND_ROTATION_GRACE_MS,
|
||||
createBackgroundCandidateOrderResolver,
|
||||
findFirstAvailableBackground,
|
||||
preloadBackgroundRotationImages,
|
||||
shouldAllowBackgroundRotation,
|
||||
} from '@/utils/backgroundRotation'
|
||||
import {
|
||||
activateLoginBackgroundLayer,
|
||||
createLoginBackgroundLayers,
|
||||
getLoginGlassOpticalSettings,
|
||||
getLoginVisualProfile,
|
||||
prepareLoginBackgroundLayer,
|
||||
settleLoginBackgroundLayers,
|
||||
type LoginBackgroundLayer,
|
||||
} from '@/utils/loginPresentation'
|
||||
import {
|
||||
DEFAULT_GLASS_WALLPAPER_TONE_PROFILE,
|
||||
loadGlassWallpaperToneProfile,
|
||||
type GlassWallpaperToneProfile,
|
||||
} from '@/utils/glassWallpaperTone'
|
||||
|
||||
const LOGIN_WALLPAPER_ROUTE = '/login'
|
||||
const BACKGROUND_CROSSFADE_DURATION_MS = 1500
|
||||
const LAUNCH_MIN_VISIBLE_MS = 320
|
||||
const LAUNCH_MAX_WAIT_MS = 1200
|
||||
const LAUNCH_EXIT_DURATION_MS = 180
|
||||
|
||||
function getLaunchNow() {
|
||||
return globalThis.performance?.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
const launchStartedAt = Number.parseFloat(document.documentElement.dataset.launchStartedAt || '') || getLaunchNow()
|
||||
|
||||
function getRemainingLaunchBudget() {
|
||||
return Math.max(0, LAUNCH_MAX_WAIT_MS - (getLaunchNow() - launchStartedAt))
|
||||
}
|
||||
|
||||
async function waitForLaunchTask(task: Promise<unknown>, timeoutMs: number, label: string) {
|
||||
if (timeoutMs <= 0) return
|
||||
|
||||
await Promise.race([
|
||||
task.catch(error => {
|
||||
console.warn(`[Launch] ${label} failed`, error)
|
||||
}),
|
||||
new Promise<void>(resolve => window.setTimeout(resolve, timeoutMs)),
|
||||
])
|
||||
}
|
||||
|
||||
async function waitForMinimumLaunchVisibility() {
|
||||
const remaining = LAUNCH_MIN_VISIBLE_MS - (getLaunchNow() - launchStartedAt)
|
||||
if (remaining > 0) {
|
||||
await new Promise<void>(resolve => window.setTimeout(resolve, remaining))
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedAutoResolvedTheme() {
|
||||
const cachedTheme = localStorage.getItem('materio-initial-resolved-theme')
|
||||
|
||||
return cachedTheme === 'dark' || cachedTheme === 'light' ? cachedTheme : null
|
||||
}
|
||||
|
||||
function resolveInitialThemeName(themePreference: string) {
|
||||
if (themePreference === 'auto') {
|
||||
return getCachedAutoResolvedTheme() || resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
return resolveThemeName(themePreference)
|
||||
}
|
||||
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
globalTheme.name.value = resolveThemeName(themeValue)
|
||||
let resumeThemeSyncTimer: number | null = null
|
||||
globalTheme.name.value = resolveInitialThemeName(themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
|
||||
// 启动屏和 iOS safe area 在同一层显示,根节点底色需要尽早和当前主题保持一致。
|
||||
@@ -66,43 +132,100 @@ const offlineStatus = useGlobalOfflineStatus()
|
||||
// 全局设置store
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
// 生成背景图片key
|
||||
const loginStateKey = computed(() => (isLogin.value ? 'logged-in' : 'logged-out'))
|
||||
|
||||
// 背景图片
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
const isBackgroundCrossfading = ref(false)
|
||||
const backgroundCrossfadeStartedAt = ref(0)
|
||||
const pendingOpticalBackgroundImage = ref('')
|
||||
const resolveBackgroundCandidateOrder = createBackgroundCandidateOrderResolver()
|
||||
const { allowsDecorativeMotion, isSuspended: isRenderThrottled, state: appActivityState } = useAppActivityLifecycle()
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
// 壁纸轮播同时服从应用活动状态与系统动态效果偏好。
|
||||
const allowsBackgroundRotation = computed(() => allowsDecorativeMotion.value && preferredMotion.value !== 'reduce')
|
||||
const backgroundRotationGraceActive = ref(false)
|
||||
let backgroundRotationGraceTimer: number | null = null
|
||||
// 壁纸时钟允许短时后台续跑;指针、滚动和流场仍服从更严格的应用活动状态。
|
||||
const allowsBackgroundRotation = computed(() =>
|
||||
shouldAllowBackgroundRotation(
|
||||
appActivityState.value,
|
||||
backgroundRotationGraceActive.value,
|
||||
preferredMotion.value === 'reduce',
|
||||
),
|
||||
)
|
||||
const isTransparentTheme = computed(() => globalTheme.name.value === 'transparent')
|
||||
const isGlassTheme = computed(() => globalTheme.name.value === 'glass')
|
||||
const effectiveGlassSettings = useEffectiveGlassSettings()
|
||||
const isInitialRouteReady = ref(false)
|
||||
const isBackdropTheme = computed(() => isTransparentTheme.value || isGlassTheme.value)
|
||||
const isLoginWallpaperRoute = computed(() => !isLogin.value && route.path === LOGIN_WALLPAPER_ROUTE)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => Boolean(isLogin.value) && isTransparentTheme.value)
|
||||
const shouldUseGlassBackgroundTreatment = computed(() => Boolean(isLogin.value) && isGlassTheme.value)
|
||||
const loginVisualProfile = computed(() => getLoginVisualProfile(globalTheme.name.value))
|
||||
const loginGlassSettings = computed(() =>
|
||||
getLoginGlassOpticalSettings({
|
||||
appearance: effectiveGlassSettings.value.glassAppearance,
|
||||
deformationStrength: effectiveGlassSettings.value.glassDeformationStrength,
|
||||
flowStrength: effectiveGlassSettings.value.glassFlowStrength,
|
||||
preset: effectiveGlassSettings.value.glassPreset,
|
||||
reflectionStrength: effectiveGlassSettings.value.glassReflectionStrength,
|
||||
transmissionStrength: effectiveGlassSettings.value.glassTransmissionStrength,
|
||||
translationStrength: effectiveGlassSettings.value.glassTranslationStrength,
|
||||
transparencyStrength: effectiveGlassSettings.value.glassTransparencyStrength,
|
||||
}),
|
||||
)
|
||||
const opticalDeformationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.deformationStrength
|
||||
: effectiveGlassSettings.value.glassDeformationStrength,
|
||||
)
|
||||
const opticalFlowStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value ? loginGlassSettings.value.flowStrength : effectiveGlassSettings.value.glassFlowStrength,
|
||||
)
|
||||
const opticalQuality = computed(() =>
|
||||
isLoginWallpaperRoute.value ? loginGlassSettings.value.quality : effectiveGlassSettings.value.glassQuality,
|
||||
)
|
||||
const opticalReflectionStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.reflectionStrength
|
||||
: effectiveGlassSettings.value.glassReflectionStrength,
|
||||
)
|
||||
const opticalTransparencyStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.transparencyStrength
|
||||
: effectiveGlassSettings.value.glassTransparencyStrength,
|
||||
)
|
||||
const opticalTranslationStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.translationStrength
|
||||
: effectiveGlassSettings.value.glassTranslationStrength,
|
||||
)
|
||||
const opticalTransmissionStrength = computed(() =>
|
||||
isLoginWallpaperRoute.value
|
||||
? loginGlassSettings.value.transmissionStrength
|
||||
: effectiveGlassSettings.value.glassTransmissionStrength,
|
||||
)
|
||||
const shouldUseTransparentBackgroundTreatment = computed(() => isTransparentTheme.value && Boolean(isLogin.value))
|
||||
const shouldUseGlassBackgroundTreatment = computed(
|
||||
() => isGlassTheme.value && (Boolean(isLogin.value) || isLoginWallpaperRoute.value),
|
||||
)
|
||||
const shouldLoadBackgroundImages = computed(
|
||||
() => isLoginWallpaperRoute.value || (Boolean(isLogin.value) && isBackdropTheme.value),
|
||||
)
|
||||
const activeBackgroundImage = computed(() => backgroundImages.value[activeImageIndex.value] ?? '')
|
||||
// 登录页的光学层只绘制程序化焦散,不会读取该跨域壁纸;登录后才通过同源缓存采样纹理。
|
||||
const activeOpticalBackgroundImage = computed(() =>
|
||||
getDisplayImageUrl(activeBackgroundImage.value, Boolean(isLogin.value)),
|
||||
)
|
||||
const appWrapperStyle = computed(() => ({
|
||||
'--login-wallpaper-image': activeBackgroundImage.value ? `url("${activeBackgroundImage.value}")` : 'none',
|
||||
}))
|
||||
const renderedBackgroundLayers = computed(() => backgroundLayers.value)
|
||||
const getOpticalBackgroundImage = (imageUrl: string) => getDisplayImageUrl(imageUrl, Boolean(isLogin.value))
|
||||
const activeOpticalBackgroundImage = computed(() => getOpticalBackgroundImage(activeBackgroundImage.value))
|
||||
const previousOpticalBackgroundImage = computed(() => {
|
||||
const previousIndex = previousImageIndex.value
|
||||
if (previousIndex === null) return ''
|
||||
|
||||
return getOpticalBackgroundImage(backgroundImages.value[previousIndex] ?? '')
|
||||
})
|
||||
const shouldRenderGlassOpticalLayer = computed(
|
||||
() =>
|
||||
isGlassTheme.value &&
|
||||
effectiveGlassSettings.value.glassQuality !== 'css' &&
|
||||
opticalQuality.value !== 'css' &&
|
||||
isInitialRouteReady.value &&
|
||||
!isRenderThrottled.value &&
|
||||
Boolean(activeBackgroundImage.value),
|
||||
)
|
||||
const GlassOpticalLayer = defineAsyncComponent(() => import('@/components/theme/GlassOpticalLayer.vue'))
|
||||
@@ -119,7 +242,11 @@ const shouldRenderGlobalBlurLayer = computed(
|
||||
let backgroundRetryTimer: number | null = null
|
||||
let backgroundRequestController: AbortController | null = null
|
||||
let backgroundCrossfadeTimer: number | null = null
|
||||
let pendingOpticalWallpaperTimer: number | null = null
|
||||
let pendingOpticalWallpaperResolve: ((ready: boolean) => void) | null = null
|
||||
let authenticatedStateTimer: number | null = null
|
||||
let backgroundLoadVersion = 0
|
||||
let backgroundRecoveryAttemptedVersion = -1
|
||||
let backgroundRotationVersion = 0
|
||||
|
||||
// 读取并同步透明主题背景设置到根组件响应式状态。
|
||||
@@ -138,6 +265,31 @@ function handleTransparencySettingsChanged(event: Event) {
|
||||
transparencyGlassQuality.value = glassQuality
|
||||
}
|
||||
|
||||
/** 在壁纸可见前准备稳健曝光;不可读跨域图片回落中性 profile,不阻断 CSS 背景。 */
|
||||
async function ensureBackgroundToneProfile(imageUrl: string) {
|
||||
if (!imageUrl || !isGlassTheme.value) return DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
|
||||
const profile = await loadGlassWallpaperToneProfile(getOpticalBackgroundImage(imageUrl))
|
||||
backgroundToneProfiles.value = {
|
||||
...backgroundToneProfiles.value,
|
||||
[imageUrl]: profile,
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
/** 让稳定双槽位分别携带当前壁纸的曝光,交叉淡化期间不共享新图参数。 */
|
||||
function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
||||
const profile = backgroundToneProfiles.value[layer.url] ?? DEFAULT_GLASS_WALLPAPER_TONE_PROFILE
|
||||
const appearance = effectiveGlassSettings.value.glassAppearance
|
||||
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
||||
|
||||
return {
|
||||
'backgroundImage': layer.url ? `url(${layer.url})` : undefined,
|
||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
||||
}
|
||||
}
|
||||
|
||||
applyTransparentBackgroundSettings()
|
||||
|
||||
void router.isReady().then(() => {
|
||||
@@ -291,10 +443,18 @@ function updateHtmlThemeAttribute(themeName: string) {
|
||||
}
|
||||
|
||||
// 从本地存储重新同步主题偏好、DOM 主题属性和相关外观配置。
|
||||
function syncThemePreferenceFromStorage() {
|
||||
function syncThemePreferenceFromStorage(preferCachedAuto = false) {
|
||||
if (resumeThemeSyncTimer !== null) {
|
||||
window.clearTimeout(resumeThemeSyncTimer)
|
||||
resumeThemeSyncTimer = null
|
||||
}
|
||||
|
||||
themeValue = localStorage.getItem('theme') || 'auto'
|
||||
|
||||
const resolvedTheme = resolveThemeName(themeValue)
|
||||
const resolvedTheme =
|
||||
themeValue === 'auto' && preferCachedAuto
|
||||
? getCachedAutoResolvedTheme() || resolveThemeName(themeValue)
|
||||
: resolveThemeName(themeValue)
|
||||
if (globalTheme.name.value !== resolvedTheme) {
|
||||
globalTheme.name.value = resolvedTheme
|
||||
}
|
||||
@@ -305,13 +465,20 @@ function syncThemePreferenceFromStorage() {
|
||||
|
||||
// 前台恢复时重新跑一次主题管理器,补齐 transparent CSS 和 auto 的实际 DOM 主题。
|
||||
void themeManager
|
||||
.setTheme(themeValue)
|
||||
.setTheme(themeValue === 'auto' ? resolvedTheme : themeValue)
|
||||
.then(() => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('同步主题管理器失败:', error)
|
||||
})
|
||||
|
||||
if (preferCachedAuto && themeValue === 'auto') {
|
||||
resumeThemeSyncTimer = window.setTimeout(() => {
|
||||
resumeThemeSyncTimer = null
|
||||
syncThemePreferenceFromStorage()
|
||||
}, 180)
|
||||
}
|
||||
}
|
||||
|
||||
// 系统配色变化时,在自动主题模式下刷新当前实际主题。
|
||||
@@ -324,7 +491,7 @@ function handleSystemThemeChange() {
|
||||
/** 页面重新可见时同步主题,并在连接异常时立即重新探测服务。 */
|
||||
function handleVisibilityThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
}
|
||||
@@ -334,7 +501,7 @@ function handlePageShowThemeSync() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (isLogin.value && !offlineStatus.isOnline.value) offlineStatus.requestConnectionCheck()
|
||||
}
|
||||
syncThemePreferenceFromStorage()
|
||||
syncThemePreferenceFromStorage(true)
|
||||
}
|
||||
|
||||
// 清理背景图交叉淡入淡出定时器。
|
||||
@@ -345,11 +512,44 @@ function clearBackgroundCrossfadeTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 结束当前 GPU 纹理预备等待,旧请求不得继续提交壁纸切换。 */
|
||||
function settlePendingOpticalWallpaper(ready: boolean) {
|
||||
if (pendingOpticalWallpaperTimer !== null) {
|
||||
window.clearTimeout(pendingOpticalWallpaperTimer)
|
||||
pendingOpticalWallpaperTimer = null
|
||||
}
|
||||
pendingOpticalBackgroundImage.value = ''
|
||||
pendingOpticalWallpaperResolve?.(ready)
|
||||
pendingOpticalWallpaperResolve = null
|
||||
}
|
||||
|
||||
/** 等待两个 WebGL 呈现 context 完成下一张纹理上传。 */
|
||||
function prepareOpticalWallpaper(url: string) {
|
||||
if (!shouldRenderGlassOpticalLayer.value || !url || url === activeOpticalBackgroundImage.value) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
settlePendingOpticalWallpaper(false)
|
||||
pendingOpticalBackgroundImage.value = url
|
||||
|
||||
return new Promise<boolean>(resolve => {
|
||||
pendingOpticalWallpaperResolve = resolve
|
||||
pendingOpticalWallpaperTimer = window.setTimeout(() => settlePendingOpticalWallpaper(false), 10000)
|
||||
})
|
||||
}
|
||||
|
||||
/** 只接受当前待切换 URL 的 renderer 就绪回执。 */
|
||||
function handleOpticalWallpaperPrepared(url: string) {
|
||||
if (url === pendingOpticalBackgroundImage.value) settlePendingOpticalWallpaper(true)
|
||||
}
|
||||
|
||||
// 重置背景图交叉淡入淡出状态。
|
||||
function resetBackgroundCrossfade() {
|
||||
clearBackgroundCrossfadeTimer()
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundCrossfadeStartedAt.value = 0
|
||||
backgroundLayers.value = createLoginBackgroundLayers(activeBackgroundImage.value)
|
||||
}
|
||||
|
||||
// 切换期保留上一张背景的渲染状态,避免图片合成层重建时露出透明底。
|
||||
@@ -357,52 +557,126 @@ function activateBackgroundImage(nextIndex: number) {
|
||||
if (nextIndex === activeImageIndex.value) return
|
||||
|
||||
clearBackgroundCrossfadeTimer()
|
||||
backgroundLayers.value = prepareLoginBackgroundLayer(backgroundLayers.value, backgroundImages.value[nextIndex] ?? '')
|
||||
previousImageIndex.value = activeImageIndex.value
|
||||
isBackgroundCrossfading.value = true
|
||||
backgroundCrossfadeStartedAt.value = performance.now()
|
||||
activeImageIndex.value = nextIndex
|
||||
backgroundLayers.value = activateLoginBackgroundLayer(backgroundLayers.value)
|
||||
backgroundCrossfadeTimer = window.setTimeout(() => {
|
||||
previousImageIndex.value = null
|
||||
isBackgroundCrossfading.value = false
|
||||
backgroundLayers.value = settleLoginBackgroundLayers(backgroundLayers.value)
|
||||
backgroundCrossfadeTimer = null
|
||||
}, BACKGROUND_CROSSFADE_DURATION_MS)
|
||||
}
|
||||
|
||||
// 获取背景图片
|
||||
// 获取背景图片列表;只有选出实际可用的首图后才提交到可见状态。
|
||||
async function fetchBackgroundImages() {
|
||||
backgroundRequestController?.abort()
|
||||
const controller = new AbortController()
|
||||
backgroundRequestController = controller
|
||||
try {
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = new AbortController()
|
||||
backgroundImages.value = await api.get(`/login/wallpapers`, {
|
||||
signal: backgroundRequestController.signal,
|
||||
return await api.get<string[], string[]>(`/login/wallpapers`, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
resetBackgroundCrossfade()
|
||||
activeImageIndex.value = 0
|
||||
} catch (e) {
|
||||
throw e
|
||||
} finally {
|
||||
if (backgroundRequestController === controller) backgroundRequestController = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅提前加载当前图的下一项,不建立全目录预载队列。 */
|
||||
function preloadNextBackgroundImage() {
|
||||
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
void preloadBackgroundCandidate(backgroundImages.value[nextIndex])
|
||||
}
|
||||
|
||||
/** 实时玻璃先建立可供 WebGL 读取的缓存,失败时仍允许 CSS 材质显示该壁纸。 */
|
||||
async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
const toneProfile = isGlassTheme.value
|
||||
? ensureBackgroundToneProfile(imageUrl)
|
||||
: Promise.resolve(DEFAULT_GLASS_WALLPAPER_TONE_PROFILE)
|
||||
if (!shouldRenderGlassOpticalLayer.value) {
|
||||
const [available] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
const opticalUrl = getOpticalBackgroundImage(imageUrl)
|
||||
const opticalReady = await preloadCorsImage(opticalUrl)
|
||||
if (!opticalReady) {
|
||||
const [displayReady] = await Promise.all([preloadImage(imageUrl), toneProfile])
|
||||
|
||||
return displayReady
|
||||
}
|
||||
|
||||
const [displayReady] = await Promise.all([
|
||||
opticalUrl === imageUrl ? Promise.resolve(true) : preloadImage(imageUrl),
|
||||
toneProfile,
|
||||
])
|
||||
|
||||
return displayReady
|
||||
}
|
||||
|
||||
// 背景图片轮换函数
|
||||
function rotateBackgroundImage() {
|
||||
if (!allowsBackgroundRotation.value) return
|
||||
async function rotateBackgroundImage() {
|
||||
if (!allowsBackgroundRotation.value || backgroundImages.value.length <= 1) return
|
||||
|
||||
if (backgroundImages.value.length > 1) {
|
||||
// 计算下一个图片索引
|
||||
const nextIndex = (activeImageIndex.value + 1) % backgroundImages.value.length
|
||||
const requestVersion = ++backgroundRotationVersion
|
||||
const requestVersion = ++backgroundRotationVersion
|
||||
const activeIndex = activeImageIndex.value
|
||||
for (let offset = 1; offset < backgroundImages.value.length; offset += 1) {
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
void commitPreloadedBackgroundRotation({
|
||||
canCommit: () => allowsBackgroundRotation.value && requestVersion === backgroundRotationVersion,
|
||||
commit: () => activateBackgroundImage(nextIndex),
|
||||
preload: () => preloadImage(backgroundImages.value[nextIndex]),
|
||||
const nextIndex = (activeIndex + offset) % backgroundImages.value.length
|
||||
const nextImage = backgroundImages.value[nextIndex]
|
||||
const opticalImage = shouldRenderGlassOpticalLayer.value ? getOpticalBackgroundImage(nextImage) : undefined
|
||||
if (opticalImage && !(await prepareOpticalWallpaper(opticalImage))) continue
|
||||
const imagesReady = await preloadBackgroundRotationImages({
|
||||
displayUrl: nextImage,
|
||||
opticalUrl: opticalImage,
|
||||
preload: preloadImage,
|
||||
})
|
||||
if (!imagesReady) continue
|
||||
await ensureBackgroundToneProfile(nextImage)
|
||||
if (!allowsBackgroundRotation.value || requestVersion !== backgroundRotationVersion) return
|
||||
|
||||
activateBackgroundImage(nextIndex)
|
||||
preloadNextBackgroundImage()
|
||||
return
|
||||
}
|
||||
|
||||
if (requestVersion === backgroundRotationVersion && backgroundRecoveryAttemptedVersion !== backgroundLoadVersion) {
|
||||
stopBackgroundRotation()
|
||||
const recoveryVersion = ++backgroundLoadVersion
|
||||
backgroundRecoveryAttemptedVersion = recoveryVersion
|
||||
void loadBackgroundImages(recoveryVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// 停止轮询并使已经发起的壁纸预加载失效,避免非活动状态收到迟到提交。
|
||||
// 停止轮询并使已经发起的下一图准备失效,避免非活动状态收到迟到提交。
|
||||
function stopBackgroundRotation() {
|
||||
backgroundRotationVersion += 1
|
||||
removeBackgroundTimer('background-rotation')
|
||||
settlePendingOpticalWallpaper(false)
|
||||
}
|
||||
|
||||
function clearBackgroundRotationGrace() {
|
||||
if (backgroundRotationGraceTimer !== null) {
|
||||
window.clearTimeout(backgroundRotationGraceTimer)
|
||||
backgroundRotationGraceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startBackgroundRotationGrace() {
|
||||
if (backgroundRotationGraceActive.value) return
|
||||
|
||||
backgroundRotationGraceActive.value = true
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceTimer = window.setTimeout(() => {
|
||||
backgroundRotationGraceTimer = null
|
||||
backgroundRotationGraceActive.value = false
|
||||
}, BACKGROUND_ROTATION_GRACE_MS)
|
||||
}
|
||||
|
||||
// 开始背景图片轮换
|
||||
@@ -410,19 +684,40 @@ function startBackgroundRotation() {
|
||||
stopBackgroundRotation()
|
||||
|
||||
if (allowsBackgroundRotation.value && backgroundImages.value.length > 1) {
|
||||
// 使用优化的定时器管理器,后台时自动暂停
|
||||
preloadNextBackgroundImage()
|
||||
// 隐藏页面也允许在有界宽限期内轮换,回调自身会再次核对生命周期。
|
||||
addBackgroundTimer(
|
||||
'background-rotation',
|
||||
rotateBackgroundImage,
|
||||
() => void rotateBackgroundImage(),
|
||||
10000, // 每10秒切换一次
|
||||
{
|
||||
runInBackground: false, // 后台时不运行
|
||||
runInBackground: true,
|
||||
skipInitialRun: true, // 不需要立即执行
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
appActivityState,
|
||||
(state, previousState) => {
|
||||
if (state === 'active') {
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'idle') {
|
||||
clearBackgroundRotationGrace()
|
||||
backgroundRotationGraceActive.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (previousState === 'active') startBackgroundRotationGrace()
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch(allowsBackgroundRotation, allowsRotation => {
|
||||
resetBackgroundCrossfade()
|
||||
|
||||
@@ -435,6 +730,7 @@ watch(allowsBackgroundRotation, allowsRotation => {
|
||||
|
||||
// 停止登录页、透明主题或玻璃主题背景图加载、重试和轮播。
|
||||
function stopBackgroundLoading() {
|
||||
backgroundLoadVersion += 1
|
||||
backgroundRequestController?.abort()
|
||||
backgroundRequestController = null
|
||||
|
||||
@@ -489,7 +785,7 @@ async function animateAndRemoveLoader() {
|
||||
document.body.style.removeProperty('overflow')
|
||||
completeLaunchLoading()
|
||||
resolve()
|
||||
}, 120)
|
||||
}, LAUNCH_EXIT_DURATION_MS)
|
||||
})
|
||||
} else {
|
||||
completeLaunchLoading()
|
||||
@@ -502,19 +798,27 @@ async function removeLoadingWithStateCheck() {
|
||||
// 设置各个组件的加载状态
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', true)
|
||||
|
||||
// 静默检查PWA状态恢复
|
||||
// 静默检查PWA状态恢复,但不能让恢复异常或慢请求挡住应用外壳。
|
||||
const pwaController = (window as any).pwaStateController
|
||||
if (pwaController) {
|
||||
await pwaController.waitForStateRestore()
|
||||
if (pwaController?.waitForStateRestore) {
|
||||
await waitForLaunchTask(
|
||||
Promise.resolve().then(() => pwaController.waitForStateRestore()),
|
||||
getRemainingLaunchBudget(),
|
||||
'PWA state restore',
|
||||
)
|
||||
}
|
||||
globalLoadingStateManager.setLoadingState('pwa-state', false)
|
||||
|
||||
// PWA/App 模式会影响布局和底部导航,必须在启动屏退场前稳定下来。
|
||||
await initializePWA()
|
||||
await initializeAuthenticatedState()
|
||||
await waitForLaunchTask(initializePWA(), getRemainingLaunchBudget(), 'PWA detection')
|
||||
|
||||
// 等待所有加载完成
|
||||
await globalLoadingStateManager.waitForAllComplete()
|
||||
// 用户设置不影响首帧布局,交给应用外壳出现后继续加载。
|
||||
void initializeAuthenticatedState().catch(error => {
|
||||
console.warn('[Launch] Authenticated state initialization failed', error)
|
||||
})
|
||||
|
||||
// 快速缓存命中时至少保留短暂的稳定画面,避免 iOS 只闪过一帧。
|
||||
await waitForMinimumLaunchVisibility()
|
||||
|
||||
// 移除加载界面
|
||||
await animateAndRemoveLoader()
|
||||
@@ -531,19 +835,43 @@ async function removeLoadingWithStateCheck() {
|
||||
}
|
||||
|
||||
// 加载背景图片
|
||||
async function loadBackgroundImages(retryCount = 0) {
|
||||
async function loadBackgroundImages(loadVersion: number, retryCount = 0) {
|
||||
const maxRetries = 3
|
||||
try {
|
||||
await fetchBackgroundImages()
|
||||
const images = resolveBackgroundCandidateOrder(await fetchBackgroundImages())
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
|
||||
const firstAvailableIndex = await findFirstAvailableBackground({
|
||||
urls: images,
|
||||
canContinue: () => loadVersion === backgroundLoadVersion,
|
||||
preload: preloadBackgroundCandidate,
|
||||
})
|
||||
if (firstAvailableIndex === null) throw new Error('没有可用的登录壁纸')
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
|
||||
const currentImage = activeBackgroundImage.value
|
||||
const currentIndex = images.indexOf(currentImage)
|
||||
if (currentImage && currentIndex < 0) {
|
||||
backgroundImages.value = [currentImage, ...images]
|
||||
activeImageIndex.value = 0
|
||||
} else {
|
||||
backgroundImages.value = images
|
||||
activeImageIndex.value = currentIndex >= 0 ? currentIndex : firstAvailableIndex
|
||||
}
|
||||
backgroundRecoveryAttemptedVersion = -1
|
||||
resetBackgroundCrossfade()
|
||||
startBackgroundRotation()
|
||||
} catch (error: any) {
|
||||
if (loadVersion !== backgroundLoadVersion) return
|
||||
const isAbortError = error.name === 'AbortError' || error.code === 'ERR_CANCELED'
|
||||
if (retryCount < maxRetries) {
|
||||
const baseDelay = isAbortError ? 1000 : 3000
|
||||
const retryDelay = Math.min(baseDelay * Math.pow(2, retryCount), 10000)
|
||||
backgroundRetryTimer = window.setTimeout(() => {
|
||||
backgroundRetryTimer = null
|
||||
loadBackgroundImages(retryCount + 1)
|
||||
if (loadVersion === backgroundLoadVersion) {
|
||||
void loadBackgroundImages(loadVersion, retryCount + 1)
|
||||
}
|
||||
}, retryDelay)
|
||||
}
|
||||
}
|
||||
@@ -565,7 +893,7 @@ onMounted(async () => {
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
// 初始化主题管理器 - 统一处理主题初始化
|
||||
await themeManager.setTheme(themeValue)
|
||||
await themeManager.setTheme(themeValue === 'auto' ? globalTheme.name.value : themeValue)
|
||||
applyStoredThemeCustomizerAppearance(vuetifyTheme)
|
||||
updateHtmlThemeAttribute(globalTheme.name.value)
|
||||
|
||||
@@ -587,13 +915,13 @@ onMounted(async () => {
|
||||
window.addEventListener('focus', handlePageShowThemeSync)
|
||||
window.addEventListener(TRANSPARENCY_SETTINGS_CHANGED_EVENT, handleTransparencySettingsChanged)
|
||||
|
||||
// 登录页壁纸仅在未登录登录页需要,避免其他首屏额外发起图片列表请求。
|
||||
// 登录前后复用同一壁纸列表和活动项,主题变化只改变呈现方式。
|
||||
watch(
|
||||
shouldLoadBackgroundImages,
|
||||
shouldLoad => {
|
||||
stopBackgroundLoading()
|
||||
if (shouldLoad) {
|
||||
loadBackgroundImages()
|
||||
void loadBackgroundImages(backgroundLoadVersion)
|
||||
} else if (!isBackdropTheme.value) {
|
||||
backgroundImages.value = []
|
||||
}
|
||||
@@ -601,6 +929,14 @@ onMounted(async () => {
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(isGlassTheme, enabled => {
|
||||
if (!enabled) return
|
||||
|
||||
void Promise.all(
|
||||
renderedBackgroundLayers.value.filter(layer => layer.url).map(layer => ensureBackgroundToneProfile(layer.url)),
|
||||
)
|
||||
})
|
||||
|
||||
// 使用优化后的加载界面移除逻辑
|
||||
ensureRenderComplete(() => {
|
||||
nextTick(removeLoadingWithStateCheck)
|
||||
@@ -629,6 +965,7 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
// 清除背景轮换定时器
|
||||
stopBackgroundLoading()
|
||||
clearBackgroundRotationGrace()
|
||||
if (authenticatedStateTimer) {
|
||||
window.clearTimeout(authenticatedStateTimer)
|
||||
authenticatedStateTimer = null
|
||||
@@ -650,10 +987,11 @@ onUnmounted(() => {
|
||||
:class="{
|
||||
'app-wrapper--background-transition': isBackgroundCrossfading,
|
||||
'app-wrapper--decorative-motion-paused': !allowsDecorativeMotion,
|
||||
'app-wrapper--login-glass-high': isLoginWallpaperRoute && loginVisualProfile === 'glass',
|
||||
'app-wrapper--login-wallpaper': isLoginWallpaperRoute,
|
||||
'app-wrapper--render-throttled': isRenderThrottled,
|
||||
}"
|
||||
:data-app-activity-state="appActivityState"
|
||||
:style="appWrapperStyle"
|
||||
>
|
||||
<!-- 登录页、透明主题和玻璃主题共用动态壁纸场景。 -->
|
||||
<div
|
||||
@@ -667,11 +1005,11 @@ onUnmounted(() => {
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(imageUrl, index) in backgroundImages"
|
||||
:key="`bg-${index}-${loginStateKey}`"
|
||||
v-for="layer in renderedBackgroundLayers"
|
||||
:key="layer.key"
|
||||
class="background-image"
|
||||
:class="{ 'active': index === activeImageIndex, 'previous': index === previousImageIndex }"
|
||||
:style="{ 'backgroundImage': `url(${imageUrl})` }"
|
||||
:class="layer.role"
|
||||
:style="getBackgroundLayerStyle(layer)"
|
||||
/>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
@@ -679,14 +1017,27 @@ onUnmounted(() => {
|
||||
<GlassOpticalLayer
|
||||
v-if="shouldRenderGlassOpticalLayer"
|
||||
:appearance="effectiveGlassSettings.glassAppearance"
|
||||
:class="{ 'glass-optical-layer--background-transition': isBackgroundCrossfading }"
|
||||
:quality="effectiveGlassSettings.glassQuality === 'high' ? 'high' : 'balanced'"
|
||||
:deformation-strength="opticalDeformationStrength"
|
||||
:flow-strength="opticalFlowStrength"
|
||||
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
||||
:reflection-strength="opticalReflectionStrength"
|
||||
:transparency-strength="opticalTransparencyStrength"
|
||||
:transmission-strength="opticalTransmissionStrength"
|
||||
:translation-strength="opticalTranslationStrength"
|
||||
:route-key="route.fullPath"
|
||||
:tint-color="globalTheme.current.value.colors.primary"
|
||||
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
|
||||
:transition-started-at="backgroundCrossfadeStartedAt"
|
||||
:wallpaper-url="activeOpticalBackgroundImage"
|
||||
:previous-wallpaper-url="previousOpticalBackgroundImage"
|
||||
:pending-wallpaper-url="pendingOpticalBackgroundImage"
|
||||
@wallpaper-prepared="handleOpticalWallpaperPrepared"
|
||||
/>
|
||||
<!-- 页面内容 -->
|
||||
<VApp :class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }">
|
||||
<VApp
|
||||
:class="{ 'app-shell--login-wallpaper': isLoginWallpaperRoute }"
|
||||
:data-login-visual-profile="isLoginWallpaperRoute ? loginVisualProfile : undefined"
|
||||
>
|
||||
<RouterView />
|
||||
<!-- 全局共享弹窗入口,列表与卡片按需在这里挂载业务弹窗。 -->
|
||||
<SharedDialogHost />
|
||||
@@ -714,6 +1065,14 @@ onUnmounted(() => {
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
// 登录内容与壁纸进入同一文档弹性合成上下文;sticky 仍保持普通滚动时的 viewport 锁定。
|
||||
.app-wrapper--login-wallpaper .background-container {
|
||||
position: sticky;
|
||||
block-size: 100dvh;
|
||||
margin-block-end: -100dvh;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.background-image {
|
||||
position: absolute;
|
||||
background-position: center;
|
||||
@@ -752,7 +1111,7 @@ onUnmounted(() => {
|
||||
|
||||
.background-container.is-glass-theme .background-image.active,
|
||||
.background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.86) saturate(0.95) contrast(1.02);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.86)) saturate(0.95) contrast(1.02);
|
||||
}
|
||||
|
||||
.background-container.is-glass-theme .background-image.active {
|
||||
@@ -762,13 +1121,13 @@ onUnmounted(() => {
|
||||
.background-container.is-glass-theme .background-image.active::after,
|
||||
.background-container.is-glass-theme .background-image.previous::after {
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 16%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 40%) 100%);
|
||||
radial-gradient(circle at 50% 18%, transparent 24%, rgba(6, 10, 19, 12%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 30%) 100%);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.85) saturate(0.97) contrast(1.02);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.85)) saturate(0.97) contrast(1.02);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active {
|
||||
@@ -778,13 +1137,13 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,
|
||||
html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||
background:
|
||||
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 18%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 14%) 0%, rgba(6, 10, 19, 42%) 100%), rgba(var(--v-theme-primary), 3%);
|
||||
radial-gradient(circle at 50% 18%, transparent 22%, rgba(6, 10, 19, 14%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous {
|
||||
filter: brightness(0.82) saturate(0.9);
|
||||
filter: brightness(var(--glass-wallpaper-brightness, 0.82)) saturate(0.9);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active {
|
||||
@@ -793,7 +1152,7 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active::after,
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.previous::after {
|
||||
background: linear-gradient(rgba(6, 10, 19, 30%) 0%, rgba(6, 10, 19, 58%) 100%), rgba(11, 19, 34, 10%);
|
||||
background: linear-gradient(rgba(6, 10, 19, 24%) 0%, rgba(6, 10, 19, 48%) 100%), rgba(11, 19, 34, 8%);
|
||||
}
|
||||
|
||||
.background-container.is-transparent-glass-lightweight .background-image.active,
|
||||
@@ -826,7 +1185,7 @@ html[data-glass-appearance='frosted'] .background-container.is-glass-theme .back
|
||||
}
|
||||
|
||||
.app-wrapper--decorative-motion-paused {
|
||||
.login-bg-decor *,
|
||||
.login-ambient-light *,
|
||||
.login-logo,
|
||||
.login-logo-wrapper,
|
||||
.login-logo-wrapper::before,
|
||||
|
||||
@@ -1587,6 +1587,8 @@ export interface TransferForm {
|
||||
episode_group?: string | null
|
||||
// 预览模式
|
||||
preview?: boolean
|
||||
// 清理已有成功记录后重新整理
|
||||
reorganize: boolean
|
||||
}
|
||||
|
||||
// 手动整理请求
|
||||
@@ -1613,6 +1615,14 @@ export interface ManualTransferTargetPathData {
|
||||
library_category_folder?: boolean | null
|
||||
}
|
||||
|
||||
// 手动整理命中的成功历史摘要
|
||||
export interface ManualTransferHistoryInfo {
|
||||
// 是否应执行重新整理
|
||||
reorganize: boolean
|
||||
// 命中的成功历史数量
|
||||
history_count: number
|
||||
}
|
||||
|
||||
// 手动整理预览统计
|
||||
export interface ManualTransferPreviewSummary {
|
||||
// 总数
|
||||
|
||||
@@ -376,7 +376,7 @@ watch(recognitionSource, () => {
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<article class="recognition-cache-mobile-item">
|
||||
<div class="recognition-cache-poster">
|
||||
<div class="recognition-cache-poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" size="28" />
|
||||
</div>
|
||||
@@ -432,7 +432,7 @@ watch(recognitionSource, () => {
|
||||
:loading-text="t('common.loadingText')"
|
||||
>
|
||||
<template #item.poster="{ item }">
|
||||
<div class="recognition-cache-table__poster">
|
||||
<div class="recognition-cache-table__poster rounded-md">
|
||||
<VImg v-if="getPosterUrl(item)" :src="getPosterUrl(item)" :alt="item.title || item.key" cover />
|
||||
<VIcon v-else icon="mdi-image-off-outline" />
|
||||
</div>
|
||||
@@ -590,7 +590,6 @@ watch(recognitionSource, () => {
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
color: rgba(var(--v-theme-on-surface), 0.36);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -35,8 +35,8 @@ const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -76,8 +76,8 @@ function closeInstallProgress() {
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
@@ -236,15 +236,13 @@ onUnmounted(() => {
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="showPluginDetail"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
>
|
||||
<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%)`"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getDominantColor } from '@/@core/utils/image'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -40,8 +40,8 @@ const { t } = useI18n()
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 背景颜色
|
||||
const backgroundColor = ref('#28A9E1')
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
@@ -98,8 +98,8 @@ watch(
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
@@ -573,17 +573,15 @@ watch(
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
@click="handleCardClick"
|
||||
class="app-hover-lift-card flex flex-col h-full"
|
||||
class="plugin-card app-hover-lift-card flex flex-col h-full"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
: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%)`"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
type ThemeCustomizerGlassAppearance,
|
||||
type ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
GLASS_OPTICAL_STRENGTH_MIN,
|
||||
getAvailableGlassOpticalPresets,
|
||||
getGlassOpticalPresetParameters,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalPreset,
|
||||
} from '@/utils/glassOptics'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -26,8 +34,21 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftQuality = ref<ThemeCustomizerGlassQuality>(settings.value.glassQuality)
|
||||
const draftReflectionStrength = ref(settings.value.glassReflectionStrength)
|
||||
const draftTransmissionStrength = ref(settings.value.glassTransmissionStrength)
|
||||
const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||
const isSaving = ref(false)
|
||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
||||
const showsDynamicTuning = computed(() => usesRealtimeOptics.value)
|
||||
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||
)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -44,7 +65,14 @@ watch(
|
||||
(value, previous) => {
|
||||
if (value) {
|
||||
draftAppearance.value = settings.value.glassAppearance
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftQuality.value = settings.value.glassQuality
|
||||
draftReflectionStrength.value = settings.value.glassReflectionStrength
|
||||
draftTransmissionStrength.value = settings.value.glassTransmissionStrength
|
||||
draftTranslationStrength.value = settings.value.glassTranslationStrength
|
||||
draftTransparencyStrength.value = settings.value.glassTransparencyStrength
|
||||
} else if (previous) {
|
||||
cancelGlassPreview()
|
||||
}
|
||||
@@ -61,13 +89,23 @@ const appearanceOptions: Array<{
|
||||
]
|
||||
|
||||
const qualityOptions: Array<{
|
||||
hint: string
|
||||
label: string
|
||||
value: ThemeCustomizerGlassQuality
|
||||
}> = [
|
||||
{ label: 'theme.glassQualityCss', value: 'css' },
|
||||
{ label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||
{ label: 'theme.glassQualityHigh', value: 'high' },
|
||||
{ hint: 'theme.glassQualityCssHint', label: 'theme.glassQualityCss', value: 'css' },
|
||||
{ hint: 'theme.glassQualityBalancedHint', label: 'theme.glassQualityBalanced', value: 'balanced' },
|
||||
{ hint: 'theme.glassQualityHighHint', label: 'theme.glassQualityHigh', value: 'high' },
|
||||
]
|
||||
const qualityHint = computed(() => qualityOptions.find(option => option.value === draftQuality.value)?.hint ?? '')
|
||||
const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
||||
{ label: 'theme.glassPresetNatural', value: 'natural' },
|
||||
{ label: 'theme.glassPresetGlide', value: 'glide' },
|
||||
{ label: 'theme.glassPresetLiquid', value: 'liquid' },
|
||||
]
|
||||
const visiblePresetOptions = computed(() =>
|
||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
||||
)
|
||||
|
||||
/** 仅允许已实现的材质进入待保存设置。 */
|
||||
function updateAppearance(value: unknown) {
|
||||
@@ -86,16 +124,76 @@ function updateQuality(value: unknown) {
|
||||
previewGlassSettings({ glassQuality: option.value })
|
||||
}
|
||||
|
||||
/** 将当前草稿恢复为玻璃主题默认值并立即预览。 */
|
||||
function resetSettings() {
|
||||
draftAppearance.value = 'clear'
|
||||
draftQuality.value = 'balanced'
|
||||
/** 将六个具体参数作为一个预览事务同步,预置只负责生成这些值。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** 应用当前材质与质量下的方案建议值,并将该方案作为后续重置目标。 */
|
||||
function applyPreset(value: unknown) {
|
||||
if (value !== 'natural' && value !== 'glide' && value !== 'liquid') return
|
||||
if (!availablePresets.value.includes(value)) return
|
||||
|
||||
const parameters = getGlassOpticalPresetParameters(draftAppearance.value, draftQuality.value, value)
|
||||
draftPreset.value = value
|
||||
draftDeformationStrength.value = parameters.deformation
|
||||
draftFlowStrength.value = parameters.flow
|
||||
draftReflectionStrength.value = parameters.reflection
|
||||
draftTransmissionStrength.value = parameters.transmission
|
||||
draftTranslationStrength.value = parameters.translation
|
||||
draftTransparencyStrength.value = parameters.transparency
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将采样平移限制为 renderer 支持的稳定范围。 */
|
||||
function updateTranslationStrength(value: unknown) {
|
||||
draftTranslationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTranslationStrength: draftTranslationStrength.value })
|
||||
}
|
||||
|
||||
/** 将局部形变限制为质量档软上限所消费的用户范围。 */
|
||||
function updateDeformationStrength(value: unknown) {
|
||||
draftDeformationStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassDeformationStrength: draftDeformationStrength.value })
|
||||
}
|
||||
|
||||
/** 将尾波、惯性与收敛输入限制为 renderer 的稳定范围。 */
|
||||
function updateFlowStrength(value: unknown) {
|
||||
draftFlowStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassFlowStrength: draftFlowStrength.value })
|
||||
}
|
||||
|
||||
/** 将滑杆输入限制为 renderer 的稳定范围并即时预览反射亮度。 */
|
||||
function updateReflectionStrength(value: unknown) {
|
||||
draftReflectionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassReflectionStrength: draftReflectionStrength.value })
|
||||
}
|
||||
|
||||
/** 将透射亮度限制为稳定范围并即时调整卡片内部壁纸的明暗。 */
|
||||
function updateTransmissionStrength(value: unknown) {
|
||||
draftTransmissionStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransmissionStrength: draftTransmissionStrength.value })
|
||||
}
|
||||
|
||||
/** 将通透度限制为稳定范围并即时调整材质与真实壁纸的占比。 */
|
||||
function updateTransparencyStrength(value: unknown) {
|
||||
draftTransparencyStrength.value = normalizeGlassOpticalStrength(Array.isArray(value) ? value[0] : value)
|
||||
previewGlassSettings({ glassTransparencyStrength: draftTransparencyStrength.value })
|
||||
}
|
||||
|
||||
/** 保留当前材质与质量,将参数恢复为当前高亮方案的建议值。 */
|
||||
function resetSettings() {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 一次提交当前预览,持久化后关闭不会发生视觉回跳。 */
|
||||
async function saveSettings() {
|
||||
if (isSaving.value) return
|
||||
@@ -105,7 +203,14 @@ async function saveSettings() {
|
||||
try {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassQuality: draftQuality.value,
|
||||
glassReflectionStrength: draftReflectionStrength.value,
|
||||
glassTransmissionStrength: draftTransmissionStrength.value,
|
||||
glassTranslationStrength: draftTranslationStrength.value,
|
||||
glassTransparencyStrength: draftTransparencyStrength.value,
|
||||
})
|
||||
commitGlassPreview()
|
||||
visible.value = false
|
||||
@@ -171,6 +276,141 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t(qualityHint) }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="usesRealtimeOptics">
|
||||
<div class="glass-settings-dialog__preset-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassPreset') }}</h3>
|
||||
</div>
|
||||
<VBtnToggle
|
||||
:model-value="activePreset"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__preset"
|
||||
@update:model-value="applyPreset"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in visiblePresetOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__preset-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
</section>
|
||||
|
||||
<section class="glass-settings-dialog__tuning">
|
||||
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassMaterialTuning') }}</h3>
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransparencyStrength') }}</h3>
|
||||
<output>{{ draftTransparencyStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTransparencyStrength"
|
||||
:aria-label="t('theme.glassTransparencyStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTransparencyStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTransmissionStrength') }}</h3>
|
||||
<output>{{ draftTransmissionStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTransmissionStrength"
|
||||
:aria-label="t('theme.glassTransmissionStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTransmissionStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassReflectionStrength') }}</h3>
|
||||
<output>{{ draftReflectionStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftReflectionStrength"
|
||||
:aria-label="t('theme.glassReflectionStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateReflectionStrength"
|
||||
/>
|
||||
|
||||
<div v-if="showsDynamicTuning" class="glass-settings-dialog__live-controls">
|
||||
<h3 class="glass-settings-dialog__group-label">{{ t('theme.glassDynamicTuning') }}</h3>
|
||||
<div class="glass-settings-dialog__slider-header">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassTranslationStrength') }}</h3>
|
||||
<output>{{ draftTranslationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftTranslationStrength"
|
||||
:aria-label="t('theme.glassTranslationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateTranslationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDeformationStrength') }}</h3>
|
||||
<output>{{ draftDeformationStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftDeformationStrength"
|
||||
:aria-label="t('theme.glassDeformationStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateDeformationStrength"
|
||||
/>
|
||||
|
||||
<div class="glass-settings-dialog__slider-header glass-settings-dialog__slider-header--spaced">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassFlowStrength') }}</h3>
|
||||
<output>{{ draftFlowStrength }}%</output>
|
||||
</div>
|
||||
<VSlider
|
||||
:model-value="draftFlowStrength"
|
||||
:aria-label="t('theme.glassFlowStrength')"
|
||||
:min="GLASS_OPTICAL_STRENGTH_MIN"
|
||||
:max="GLASS_OPTICAL_STRENGTH_MAX"
|
||||
:step="1"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
thumb-label
|
||||
@update:model-value="updateFlowStrength"
|
||||
/>
|
||||
</div>
|
||||
<p class="glass-settings-dialog__hint">
|
||||
{{ t(showsDynamicTuning ? 'theme.glassOpticalStrengthHint' : 'theme.glassOpticalStrengthUnavailableHint') }}
|
||||
</p>
|
||||
</section>
|
||||
</VCardText>
|
||||
|
||||
@@ -204,8 +444,67 @@ onScopeDispose(cancelGlassPreview)
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__hint {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__group-label {
|
||||
margin: 0 0 14px;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
output {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__slider-header--spaced,
|
||||
.glass-settings-dialog__slider-header + .glass-settings-dialog__slider-header {
|
||||
margin-block-start: 18px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__live-controls {
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
margin-block-start: 24px;
|
||||
padding-block-start: 20px;
|
||||
|
||||
:deep(.v-slider) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.glass-settings-dialog__label {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance,
|
||||
.glass-settings-dialog__quality {
|
||||
.glass-settings-dialog__quality,
|
||||
.glass-settings-dialog__preset {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
@@ -226,6 +525,12 @@ onScopeDispose(cancelGlassPreview)
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-block-start: 10px;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option {
|
||||
block-size: 32px !important;
|
||||
inline-size: 100%;
|
||||
@@ -233,7 +538,8 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option,
|
||||
.glass-settings-dialog__quality-option {
|
||||
.glass-settings-dialog__quality-option,
|
||||
.glass-settings-dialog__preset-option {
|
||||
border: 0 !important;
|
||||
border-radius: 7px !important;
|
||||
box-shadow: none !important;
|
||||
@@ -247,8 +553,13 @@ onScopeDispose(cancelGlassPreview)
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active) {
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.38) !important;
|
||||
}
|
||||
|
||||
@@ -454,6 +454,17 @@ onMounted(() => {
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.access_code"
|
||||
:label="t('mediaserver.accessCode')"
|
||||
:hint="t('mediaserver.accessCodeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="mediaServerInfo.sync_interval"
|
||||
|
||||
@@ -173,7 +173,12 @@ onBeforeMount(async () => {
|
||||
<template>
|
||||
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
@@ -208,7 +213,7 @@ onBeforeMount(async () => {
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -136,7 +136,12 @@ onMounted(() => {
|
||||
<template>
|
||||
<VDialog scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<!-- Vuetify 渲染模式 -->
|
||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name}`">
|
||||
<VCard
|
||||
v-if="renderMode === 'vuetify'"
|
||||
:title="`${props.plugin?.plugin_name}`"
|
||||
data-glass-optical-surface
|
||||
data-glass-optical-mode="static-material"
|
||||
>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-5" />
|
||||
<VCardText v-else class="min-h-40">
|
||||
@@ -158,7 +163,7 @@ onMounted(() => {
|
||||
/>
|
||||
</VCard>
|
||||
<!-- Vue 渲染模式 -->
|
||||
<VCard v-else-if="renderMode === 'vue'">
|
||||
<VCard v-else-if="renderMode === 'vue'" data-glass-optical-surface data-glass-optical-mode="static-material">
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { transferTypeOptions } from '@/api/constants'
|
||||
import {
|
||||
ApiResponse,
|
||||
FileItem,
|
||||
ManualTransferHistoryInfo,
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
@@ -99,6 +100,10 @@ const previewLoaded = ref(false)
|
||||
// 预览数据
|
||||
const previewData = ref<ManualTransferPreviewData>()
|
||||
|
||||
// 手动整理历史查询状态
|
||||
const manualHistoryLoading = ref(false)
|
||||
const manualHistoryCount = ref(0)
|
||||
|
||||
interface EpisodeFormatRecommendData {
|
||||
rule_name?: string
|
||||
rule_index?: number
|
||||
@@ -323,8 +328,12 @@ const transferForm = reactive<TransferForm>({
|
||||
library_type_folder: null,
|
||||
library_category_folder: null,
|
||||
episode_group: null,
|
||||
reorganize: Boolean(props.logids?.length),
|
||||
})
|
||||
|
||||
// 历史记录入口和文件浏览器命中的成功历史都属于重新整理。
|
||||
const isReorganize = computed(() => Boolean(props.logids?.length || transferForm.reorganize))
|
||||
|
||||
// 当前手动识别与刮削数据源。
|
||||
const mediaSource = computed(() => transferForm.media_source ?? 'themoviedb')
|
||||
|
||||
@@ -920,6 +929,29 @@ async function requestManualTransfer<T = any>(
|
||||
return await api.post<ApiResponse<T>, ApiResponse<T>>(`transfer/manual?background=${background}`, payload)
|
||||
}
|
||||
|
||||
// 查询当前文件或目录是否存在成功整理历史,决定是否展示重新整理语义。
|
||||
async function loadManualTransferHistory() {
|
||||
if (props.logids?.length || !normalizedItems.value.length) return
|
||||
|
||||
manualHistoryLoading.value = true
|
||||
try {
|
||||
const payload =
|
||||
normalizedItems.value.length === 1 ? { fileitem: normalizedItems.value[0] } : { fileitems: normalizedItems.value }
|
||||
const result = await api.post<ApiResponse<ManualTransferHistoryInfo>, ApiResponse<ManualTransferHistoryInfo>>(
|
||||
'transfer/manual/history',
|
||||
payload,
|
||||
)
|
||||
if (!result.success) return
|
||||
|
||||
manualHistoryCount.value = result.data?.history_count ?? 0
|
||||
transferForm.reorganize = Boolean(result.data?.reorganize)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
manualHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
|
||||
async function loadEpisodeFormatRuleConfiguration() {
|
||||
try {
|
||||
@@ -1353,7 +1385,7 @@ async function transfer(background: boolean = false) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await Promise.all([loadDirectories(), loadManualTransferHistory()])
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1386,6 +1418,16 @@ onUnmounted(() => {
|
||||
<div class="reorganize-form-pane">
|
||||
<div class="reorganize-form-pane__content pa-6">
|
||||
<VForm @submit.prevent="() => {}">
|
||||
<VAlert
|
||||
v-if="manualHistoryCount > 0"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
icon="mdi-history"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ t('dialog.reorganize.historyFound', { count: manualHistoryCount }) }}
|
||||
</VAlert>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
@@ -1631,10 +1673,11 @@ onUnmounted(() => {
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="transfer(false)"
|
||||
prepend-icon="mdi-arrow-right-bold"
|
||||
:prepend-icon="isReorganize ? 'mdi-refresh' : 'mdi-arrow-right-bold'"
|
||||
class="reorganize-action-btn reorganize-action-btn--primary"
|
||||
:loading="manualHistoryLoading"
|
||||
>
|
||||
{{ t('dialog.reorganize.reorganizeNow') }}
|
||||
{{ isReorganize ? t('dialog.reorganize.reorganizeAgain') : t('dialog.reorganize.reorganizeNow') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +209,7 @@ function loadRecentSearches() {
|
||||
|
||||
/** 获取可参与全局搜索的导航菜单和设置入口。 */
|
||||
function getMenus(): NavMenu[] {
|
||||
let menus: NavMenu[] = []
|
||||
const menus: NavMenu[] = []
|
||||
// 导航菜单
|
||||
getNavMenus(t).forEach(
|
||||
item =>
|
||||
@@ -829,7 +829,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<!-- 空状态提示 -->
|
||||
<div v-else class="empty-hint">
|
||||
<div v-else class="empty-hint pt-3">
|
||||
<span class="text-body-1 text-medium-emphasis">{{ t('dialog.searchBar.emptySearchHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -967,8 +967,8 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.search-dialog--dropdown .search-content {
|
||||
@@ -981,12 +981,12 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
|
||||
.search-item-source-row {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
block-size: 0;
|
||||
inline-size: 100%;
|
||||
margin-block-start: 0;
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
block-size 0.15s ease,
|
||||
margin-block-start 0.15s ease;
|
||||
@@ -999,6 +999,7 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
}
|
||||
|
||||
.search-item-source-toggle {
|
||||
overflow: auto hidden;
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
@@ -1006,8 +1007,6 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
block-size: 28px;
|
||||
max-inline-size: 100%;
|
||||
opacity: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
pointer-events: none;
|
||||
transform: scale(0.98);
|
||||
transform-origin: center left;
|
||||
|
||||
@@ -131,6 +131,8 @@ function episodeGroupItemProps(item: { title: string; subtitle: string }) {
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
// 兼容未记录主来源的旧 TMDB 订阅;明确为其他来源时不使用辅助 TMDB ID 查询剧集组。
|
||||
if (subscribeForm.value.media_source && subscribeForm.value.media_source !== 'themoviedb') return
|
||||
if (!subscribeForm.value.tmdbid) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
import api from '@/api'
|
||||
import { MediaInfo, MediaSeason, NotExistMediaInfo } from '@/api/types'
|
||||
import { PropType } from 'vue'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import type { SeasonSubscribeModes, SubscribeMode } from '@/composables/useMediaSubscribe'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getMediaSubscribeIdentity,
|
||||
type SeasonSubscribeModes,
|
||||
type SubscribeMode,
|
||||
} from '@/composables/useMediaSubscribe'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
type SubscribeModeOption = {
|
||||
@@ -22,7 +29,7 @@ type EpisodeGroupOption = {
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
const { locale, t } = useI18n()
|
||||
const { mdAndUp } = useDisplay()
|
||||
|
||||
// 定义事件
|
||||
@@ -65,7 +72,9 @@ const isRefreshed = ref(false)
|
||||
const episodeGroups = ref<{ [key: string]: any }[]>([])
|
||||
|
||||
// 当前选择剧集组
|
||||
const episodeGroup = ref(props.initialEpisodeGroup ?? '')
|
||||
const episodeGroup = ref(
|
||||
getMediaSubscribeIdentity(props.media)?.source === 'themoviedb' ? (props.initialEpisodeGroup ?? '') : '',
|
||||
)
|
||||
|
||||
// 剧集组横向轨道
|
||||
const episodeGroupRail = ref<HTMLElement | null>(null)
|
||||
@@ -164,19 +173,12 @@ const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
|
||||
|
||||
// 获得mediaid
|
||||
function getMediaId() {
|
||||
if (props.media?.media_id && (props.media?.source || props.media?.mediaid_prefix)) {
|
||||
const source = props.media.mediaid_prefix || props.media.source
|
||||
return `${source === 'themoviedb' ? 'tmdb' : source}:${props.media.media_id}`
|
||||
}
|
||||
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 if (props.media?.anilist_id) return `anilist:${props.media?.anilist_id}`
|
||||
return ''
|
||||
return getMediaSubscribeId(props.media)
|
||||
}
|
||||
|
||||
// 查询所有剧集组
|
||||
async function getEpisodeGroups() {
|
||||
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb') return
|
||||
if (!props.media?.tmdb_id) {
|
||||
console.warn('tmdbid is not set or is empty')
|
||||
return
|
||||
@@ -211,7 +213,7 @@ async function getMediaSeasons() {
|
||||
|
||||
// 查询剧集组的剧集
|
||||
async function getGroupSeasons() {
|
||||
if (!episodeGroup.value) return
|
||||
if (getMediaSubscribeIdentity(props.media)?.source !== 'themoviedb' || !episodeGroup.value) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
seasonInfos.value = await api.get(`media/group/seasons/${episodeGroup.value}`)
|
||||
@@ -264,24 +266,60 @@ function getExistText(season: number) {
|
||||
else return t('dialog.subscribeSeason.status.exists')
|
||||
}
|
||||
|
||||
// 拼装季图片地址
|
||||
function getSeasonPoster(posterPath: string) {
|
||||
if (!posterPath) return props.media?.poster_path
|
||||
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${posterPath}`
|
||||
// 获取季海报地址,数据源未提供季海报时使用主海报。
|
||||
function getSeasonPoster(posterPath?: string) {
|
||||
const resolvedPosterPath = posterPath?.trim() || props.media?.poster_path?.trim()
|
||||
if (!resolvedPosterPath) return noImage
|
||||
|
||||
const posterUrl = resolvedPosterPath.startsWith('/')
|
||||
? `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${resolvedPosterPath}`
|
||||
: resolvedPosterPath.replace('/t/p/original/', '/t/p/w500/')
|
||||
|
||||
return getDisplayImageUrl(posterUrl, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
// 将yyyy-mm-dd转换为yyyy年mm月dd日
|
||||
// 按当前界面语言格式化数据源返回的首播日期。
|
||||
function formatAirDate(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
const dateParts = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(airDate)
|
||||
if (!dateParts) return airDate
|
||||
|
||||
const date = new Date(Number(dateParts[1]), Number(dateParts[2]) - 1, Number(dateParts[3]))
|
||||
if (Number.isNaN(date.getTime())) return airDate
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// 从yyyy-mm-dd中提取年份
|
||||
function getYear(airDate: string) {
|
||||
if (!airDate) return ''
|
||||
const date = new Date(airDate.replaceAll(/-/g, '/'))
|
||||
return date.getFullYear()
|
||||
// 获取本地化季号标题,确保数据源未返回名称时仍有稳定文本。
|
||||
function getSeasonTitle(item: MediaSeason) {
|
||||
return t('dialog.subscribeSeason.seasonNumber', { number: item.season_number ?? 0 })
|
||||
}
|
||||
|
||||
// 获取数据源提供的非重复季名称,保留特别篇等来源语义。
|
||||
function getSeasonName(item: MediaSeason) {
|
||||
const name = item.name?.trim()
|
||||
if (!name || item.season_number === undefined) return ''
|
||||
|
||||
const number = item.season_number
|
||||
const normalizedName = name.toLocaleLowerCase().replaceAll(/\s/g, '')
|
||||
const genericNames = new Set([
|
||||
`第${number}季`,
|
||||
`season${number}`,
|
||||
`season${String(number).padStart(2, '0')}`,
|
||||
`s${number}`,
|
||||
`s${String(number).padStart(2, '0')}`,
|
||||
])
|
||||
|
||||
return genericNames.has(normalizedName) ? '' : name
|
||||
}
|
||||
|
||||
// 获取同时包含季号和来源季名称的海报替代文本。
|
||||
function getSeasonPosterAlt(item: MediaSeason) {
|
||||
return [getSeasonTitle(item), getSeasonName(item)].filter(Boolean).join(' - ')
|
||||
}
|
||||
|
||||
// 切换当前剧集组并清空上一组的派生数据。
|
||||
@@ -518,19 +556,20 @@ onBeforeUnmount(() => {
|
||||
<VList lines="three" class="subscribe-season-list">
|
||||
<VListItem
|
||||
v-for="(item, i) in seasonInfos"
|
||||
:key="i"
|
||||
:active="isSeasonSelected(item.season_number || 0)"
|
||||
:key="item.season_number ?? i"
|
||||
:active="isSeasonSelected(item.season_number ?? 0)"
|
||||
rounded="lg"
|
||||
class="subscribe-season-list-item"
|
||||
@click="toggleSeasonSelected(item.season_number || 0)"
|
||||
@click="toggleSeasonSelected(item.season_number ?? 0)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="90"
|
||||
width="60"
|
||||
:src="getSeasonPoster(item.poster_path || '')"
|
||||
:src="getSeasonPoster(item.poster_path)"
|
||||
:alt="getSeasonPosterAlt(item)"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
class="subscribe-season-poster object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
@@ -541,49 +580,59 @@ onBeforeUnmount(() => {
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ t('dialog.subscribeSeason.seasonNumber', { number: item.season_number }) }}
|
||||
<span>{{ getSeasonTitle(item) }}</span>
|
||||
<span v-if="getSeasonName(item)" class="subscribe-season-name"> · {{ getSeasonName(item) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-1 me-2">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" /> {{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
|
||||
</VChip>
|
||||
{{ getYear(item.air_date || '') }} •
|
||||
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date || '') }) }}
|
||||
<VListItemSubtitle
|
||||
v-if="item.vote_average || item.air_date || typeof item.episode_count === 'number'"
|
||||
class="mt-1 me-2"
|
||||
>
|
||||
<div class="subscribe-season-meta">
|
||||
<VChip v-if="item.vote_average" color="primary" size="small" class="mb-1">
|
||||
<VIcon icon="mdi-star" />
|
||||
{{ t('dialog.subscribeSeason.voteAverage', { score: item.vote_average }) }}
|
||||
</VChip>
|
||||
<span v-if="item.air_date" class="subscribe-season-meta-item">
|
||||
<VIcon icon="mdi-calendar-blank-outline" size="x-small" />
|
||||
{{ t('dialog.subscribeSeason.airDate', { date: formatAirDate(item.air_date) }) }}
|
||||
</span>
|
||||
<span v-if="typeof item.episode_count === 'number'" class="subscribe-season-meta-item">
|
||||
<VIcon icon="mdi-play-box-multiple-outline" size="x-small" />
|
||||
{{ t('dialog.subscribeSeason.episodeCount', { count: item.episode_count }) }}
|
||||
</span>
|
||||
</div>
|
||||
</VListItemSubtitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-if="seasonsNotExisted"
|
||||
class="mt-2"
|
||||
size="small"
|
||||
:color="getExistColor(item.season_number || 0)"
|
||||
:color="getExistColor(item.season_number ?? 0)"
|
||||
>
|
||||
{{ getExistText(item.season_number || 0) }}
|
||||
{{ getExistText(item.season_number ?? 0) }}
|
||||
</VChip>
|
||||
<VChip v-if="isSeasonSubscribed(item.season_number || 0)" class="mt-2 ms-2" size="small" color="error">
|
||||
<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>
|
||||
<VListItemAction start class="subscribe-season-actions">
|
||||
<VSwitch
|
||||
:model-value="isSeasonSelected(item.season_number || 0)"
|
||||
:model-value="isSeasonSelected(item.season_number ?? 0)"
|
||||
hide-details
|
||||
@click.stop
|
||||
@update:model-value="setSeasonSelected(item.season_number || 0, $event)"
|
||||
@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'"
|
||||
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)"
|
||||
@update:model-value="updateSeasonMode(item.season_number ?? 0, $event)"
|
||||
>
|
||||
<VBtn
|
||||
v-for="mode in subscribeModeOptions"
|
||||
@@ -784,6 +833,29 @@ onBeforeUnmount(() => {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.subscribe-season-poster {
|
||||
flex: 0 0 3.75rem;
|
||||
}
|
||||
|
||||
.subscribe-season-name {
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subscribe-season-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.subscribe-season-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.subscribe-season-list-item :deep(.v-list-item__append) {
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
|
||||
@@ -3,11 +3,35 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassSettingsDialog from '@/components/dialog/GlassSettingsDialog.vue'
|
||||
|
||||
const slotStub = { template: '<div><slot /></div>' }
|
||||
const toggleStub = {
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
}
|
||||
const sliderStub = {
|
||||
emits: ['update:modelValue'],
|
||||
name: 'VSlider',
|
||||
props: ['disabled', 'modelValue'],
|
||||
template:
|
||||
'<input class="slider-stub" type="range" :data-disabled="String(disabled)" :value="modelValue" @input="$emit(\'update:modelValue\', Number($event.target.value))" />',
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cancelGlassPreview: vi.fn(),
|
||||
commitGlassPreview: vi.fn(),
|
||||
previewGlassSettings: vi.fn(),
|
||||
settings: {
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 50,
|
||||
glassTransmissionStrength: 50,
|
||||
glassTranslationStrength: 50,
|
||||
glassTransparencyStrength: 50,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
@@ -15,12 +39,7 @@ vi.mock('@/composables/useThemeCustomizer', () => ({
|
||||
commitGlassPreview: mocks.commitGlassPreview,
|
||||
previewGlassSettings: mocks.previewGlassSettings,
|
||||
useThemeCustomizer: () => ({
|
||||
settings: {
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'css',
|
||||
},
|
||||
},
|
||||
settings: mocks.settings,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -28,31 +47,55 @@ vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mdAndUp: { value: true } }),
|
||||
}))
|
||||
|
||||
describe('GlassSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.cancelGlassPreview.mockClear()
|
||||
mocks.commitGlassPreview.mockClear()
|
||||
mocks.previewGlassSettings.mockClear()
|
||||
mocks.settings.value.glassAppearance = 'clear'
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassQuality = 'css'
|
||||
mocks.settings.value.glassReflectionStrength = 50
|
||||
mocks.settings.value.glassTransmissionStrength = 50
|
||||
mocks.settings.value.glassTranslationStrength = 50
|
||||
mocks.settings.value.glassTransparencyStrength = 50
|
||||
})
|
||||
|
||||
it('cancels an active preview when the parent closes the dialog', async () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: { VDialogCloseBtn: true },
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(3)
|
||||
expect(sliders[0].attributes('data-disabled')).toBe('undefined')
|
||||
expect(sliders[1].attributes('data-disabled')).toBe('undefined')
|
||||
await wrapper.setProps({ modelValue: false })
|
||||
|
||||
expect(mocks.cancelGlassPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('resets the draft to the default glass settings without committing', async () => {
|
||||
it('resets parameters to the current material, quality, and preset without committing', async () => {
|
||||
mocks.settings.value.glassAppearance = 'frosted'
|
||||
mocks.settings.value.glassPreset = 'liquid'
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
mocks.settings.value.glassDeformationStrength = 65
|
||||
mocks.settings.value.glassFlowStrength = 61
|
||||
mocks.settings.value.glassReflectionStrength = 58
|
||||
mocks.settings.value.glassTransmissionStrength = 57
|
||||
mocks.settings.value.glassTranslationStrength = 57
|
||||
mocks.settings.value.glassTransparencyStrength = 55
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -66,6 +109,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VDivider: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
@@ -75,9 +119,147 @@ describe('GlassSettingsDialog', () => {
|
||||
await resetButton.trigger('click')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassQuality: 'balanced',
|
||||
glassDeformationStrength: 82,
|
||||
glassFlowStrength: 80,
|
||||
glassPreset: 'liquid',
|
||||
glassReflectionStrength: 31,
|
||||
glassTransmissionStrength: 58,
|
||||
glassTranslationStrength: 54,
|
||||
glassTransparencyStrength: 46,
|
||||
})
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps static reflection adjustable in standard quality', async () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(3)
|
||||
expect(sliders[2].attributes('data-disabled')).toBe('undefined')
|
||||
await sliders[2].setValue('86')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({ glassReflectionStrength: 86 })
|
||||
})
|
||||
|
||||
it('hides preset choices in standard quality', () => {
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted after slider adjustments', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassPreset = 'glide'
|
||||
mocks.settings.value.glassTransparencyStrength = 61
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
|
||||
const preset = wrapper.find('.glass-settings-dialog__preset')
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
expect(wrapper.findAll('.glass-settings-dialog__preset-option')).toHaveLength(3)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
|
||||
await sliders[0].setValue('77')
|
||||
|
||||
expect(preset.attributes('data-model-value')).toBe('glide')
|
||||
})
|
||||
|
||||
it('normalizes and previews all six independent slider values', async () => {
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(6)
|
||||
expect(sliders.every(slider => slider.attributes('data-disabled') !== 'true')).toBe(true)
|
||||
|
||||
await sliders[0].setValue('91.4')
|
||||
await sliders[1].setValue('77.5')
|
||||
await sliders[2].setValue('73.6')
|
||||
await sliders[3].setValue('84.2')
|
||||
await sliders[4].setValue('68.7')
|
||||
await sliders[5].setValue('62.2')
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(1, { glassTransparencyStrength: 91 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(2, { glassTransmissionStrength: 78 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(3, { glassReflectionStrength: 74 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(4, { glassTranslationStrength: 84 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(5, { glassDeformationStrength: 69 })
|
||||
expect(mocks.previewGlassSettings).toHaveBeenNthCalledWith(6, { glassFlowStrength: 62 })
|
||||
expect(mocks.commitGlassPreview).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps motion tuning available in high quality', () => {
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardText: slotStub,
|
||||
VDialog: slotStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(6)
|
||||
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
|
||||
'theme.glassTransparencyStrength',
|
||||
'theme.glassTransmissionStrength',
|
||||
'theme.glassReflectionStrength',
|
||||
'theme.glassTranslationStrength',
|
||||
'theme.glassDeformationStrength',
|
||||
'theme.glassFlowStrength',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -128,6 +128,27 @@ describe('SubscribeEditDialog', () => {
|
||||
expect(episodeGroupsRequested).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips episode groups for a non-TMDB subscription with an auxiliary TMDB ID', async () => {
|
||||
const record = createSubscribe({
|
||||
anilistid: 154587,
|
||||
id: 810,
|
||||
media_id: '154587',
|
||||
media_source: 'anilist',
|
||||
name: 'AniList 编辑测试剧',
|
||||
season: 1,
|
||||
tmdbid: 8100,
|
||||
type: '电视剧',
|
||||
})
|
||||
const episodeGroupsRequested = vi.fn()
|
||||
server.use(subscribeDetailsHandler(810, record))
|
||||
useDialogOptions({ onEpisodeGroups: episodeGroupsRequested, tmdbId: 8100 })
|
||||
|
||||
await renderDialog({ subid: 810 })
|
||||
|
||||
expect(await screen.findByText('AniList 编辑测试剧 S01')).toBeInTheDocument()
|
||||
expect(episodeGroupsRequested).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows enabled sites and stable downloader, directory, and rule options', async () => {
|
||||
const activeSite = createSubscribeSite({ id: 1, is_active: true, name: '启用站点' })
|
||||
const inactiveSite = createSubscribeSite({ id: 2, is_active: false, name: '停用站点' })
|
||||
@@ -202,7 +223,12 @@ describe('SubscribeEditDialog', () => {
|
||||
const configRequested = vi.fn()
|
||||
const saved = vi.fn()
|
||||
server.use(
|
||||
defaultSubscribeConfigHandler(type, createSubscribe({ id: 0, show_edit_dialog: false, type }), 200, configRequested),
|
||||
defaultSubscribeConfigHandler(
|
||||
type,
|
||||
createSubscribe({ id: 0, show_edit_dialog: false, type }),
|
||||
200,
|
||||
configRequested,
|
||||
),
|
||||
saveDefaultSubscribeConfigHandler(type, { success: true }, 200, saved),
|
||||
)
|
||||
useDialogOptions()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { renderWithProviders } from '@tests/support/render'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** 季订阅弹窗测试属性。 */
|
||||
interface SeasonDialogProps {
|
||||
defaultSubscribeMode?: SubscribeMode
|
||||
initialEpisodeGroup?: string
|
||||
@@ -26,6 +27,7 @@ interface SeasonDialogProps {
|
||||
subscribedSeasons?: number[]
|
||||
}
|
||||
|
||||
/** 创建可由测试主动完成的异步门闩。 */
|
||||
function createDeferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(done => {
|
||||
@@ -34,6 +36,7 @@ function createDeferred() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
/** 创建季订阅测试使用的电视剧媒体。 */
|
||||
function createTvMedia(overrides: Partial<MediaInfo> = {}) {
|
||||
return createMediaInfo({
|
||||
poster_path: '/images/fallback-poster.jpg',
|
||||
@@ -46,6 +49,7 @@ function createTvMedia(overrides: Partial<MediaInfo> = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 使用完整应用依赖渲染季订阅弹窗。 */
|
||||
async function renderDialog(props: SeasonDialogProps = {}) {
|
||||
const events = {
|
||||
close: vi.fn(),
|
||||
@@ -56,6 +60,12 @@ async function renderDialog(props: SeasonDialogProps = {}) {
|
||||
components: {
|
||||
VDialogCloseBtn: DialogCloseBtn,
|
||||
},
|
||||
stubs: {
|
||||
VImg: {
|
||||
props: ['alt', 'src'],
|
||||
template: '<img :alt="alt" :src="src" />',
|
||||
},
|
||||
},
|
||||
},
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
@@ -77,6 +87,7 @@ async function renderDialog(props: SeasonDialogProps = {}) {
|
||||
return { ...result, events }
|
||||
}
|
||||
|
||||
/** 获取指定季号对应的列表行。 */
|
||||
function seasonRow(number: number) {
|
||||
const title = screen.getByText(`第 ${number} 季`)
|
||||
const row = title.closest('.v-list-item')
|
||||
@@ -84,6 +95,7 @@ function seasonRow(number: number) {
|
||||
return row as HTMLElement
|
||||
}
|
||||
|
||||
/** 等待弹窗发起的并行请求全部结算。 */
|
||||
async function settleRequests() {
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
@@ -98,7 +110,7 @@ describe('SubscribeSeasonDialog', () => {
|
||||
it('loads the default TMDB seasons once with exact requests and renders season states', async () => {
|
||||
const media = createTvMedia({ season: 0, tmdb_id: 7302 })
|
||||
const seasons = [
|
||||
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, poster_path: '', season_number: 0 }),
|
||||
createMediaSeason({ air_date: '2020-01-02', episode_count: 3, name: '', poster_path: '', season_number: 0 }),
|
||||
createMediaSeason({ air_date: '2021-02-03', episode_count: 4, season_number: 1 }),
|
||||
createMediaSeason({ air_date: '2022-03-04', episode_count: 5, season_number: 2 }),
|
||||
]
|
||||
@@ -127,6 +139,10 @@ describe('SubscribeSeasonDialog', () => {
|
||||
expect(screen.getByText('缺失')).toBeInTheDocument()
|
||||
expect(screen.getByText('首播于 2020年1月2日')).toBeInTheDocument()
|
||||
expect(seasonRow(0)).toHaveTextContent('3 集')
|
||||
expect(within(seasonRow(0)).getByAltText('第 0 季')).toHaveAttribute(
|
||||
'src',
|
||||
'https://image.tmdb.org/t/p/w500/images/fallback-poster.jpg',
|
||||
)
|
||||
await settleRequests()
|
||||
|
||||
expect(seasonRequests).toHaveLength(1)
|
||||
@@ -139,10 +155,78 @@ describe('SubscribeSeasonDialog', () => {
|
||||
expect(missingPayloads[0]).toMatchObject({ episode_group: '', season: 0, tmdb_id: media.tmdb_id })
|
||||
})
|
||||
|
||||
it('preserves source image URLs and hides unavailable season metadata', async () => {
|
||||
const media = createTvMedia({
|
||||
poster_path: 'https://images.example.com/main-poster.jpg',
|
||||
source: 'anilist',
|
||||
tmdb_id: undefined,
|
||||
})
|
||||
server.use(
|
||||
mediaSeasonsHandler([
|
||||
createMediaSeason({
|
||||
air_date: undefined,
|
||||
episode_count: undefined,
|
||||
name: '葬送的芙莉莲 第一部分',
|
||||
poster_path: 'https://images.example.com/original/season-poster.jpg',
|
||||
season_number: 1,
|
||||
vote_average: undefined,
|
||||
}),
|
||||
]),
|
||||
mediaNotExistsHandler([]),
|
||||
)
|
||||
|
||||
await renderDialog({ media })
|
||||
|
||||
await screen.findByText('第 1 季')
|
||||
const row = seasonRow(1)
|
||||
expect(row).toHaveTextContent('葬送的芙莉莲 第一部分')
|
||||
expect(within(row).getByAltText('第 1 季 - 葬送的芙莉莲 第一部分')).toHaveAttribute(
|
||||
'src',
|
||||
'https://images.example.com/original/season-poster.jpg',
|
||||
)
|
||||
expect(row).not.toHaveTextContent('undefined')
|
||||
expect(within(row).queryByText(/首播于/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders safe fallbacks for incomplete season metadata', async () => {
|
||||
const media = createTvMedia({ poster_path: '' })
|
||||
server.use(
|
||||
mediaSeasonsHandler([
|
||||
createMediaSeason({
|
||||
air_date: 'unknown',
|
||||
episode_count: 0,
|
||||
name: '来源特别篇',
|
||||
poster_path: '',
|
||||
season_number: undefined,
|
||||
}),
|
||||
createMediaSeason({
|
||||
air_date: '',
|
||||
episode_count: undefined,
|
||||
name: 'Season 01',
|
||||
poster_path: '/images/season-one.jpg',
|
||||
season_number: 1,
|
||||
}),
|
||||
]),
|
||||
mediaNotExistsHandler([]),
|
||||
mediaEpisodeGroupsHandler(media.tmdb_id!, []),
|
||||
)
|
||||
|
||||
await renderDialog({ media })
|
||||
|
||||
expect(await screen.findByText('第 0 季')).toBeInTheDocument()
|
||||
expect(seasonRow(0)).toHaveTextContent('unknown')
|
||||
expect(within(seasonRow(0)).getByAltText('第 0 季')).toHaveAttribute('src')
|
||||
expect(seasonRow(1)).not.toHaveTextContent('Season 01')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Douban', { douban_id: 'db-7303', tmdb_id: undefined }, 'douban:db-7303'],
|
||||
['Bangumi', { bangumi_id: 'bgm-7304', douban_id: undefined, tmdb_id: undefined }, 'bangumi:bgm-7304'],
|
||||
['AniList', { anilist_id: 154587, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:154587'],
|
||||
['Douban', { douban_id: 'db-7303', source: 'douban', tmdb_id: undefined }, 'douban:db-7303'],
|
||||
[
|
||||
'Bangumi',
|
||||
{ bangumi_id: 'bgm-7304', douban_id: undefined, source: 'bangumi', tmdb_id: undefined },
|
||||
'bangumi:bgm-7304',
|
||||
],
|
||||
['AniList', { anilist_id: 154587, bangumi_id: undefined, source: 'anilist', tmdb_id: undefined }, 'anilist:154587'],
|
||||
[
|
||||
'custom source',
|
||||
{
|
||||
@@ -182,6 +266,34 @@ describe('SubscribeSeasonDialog', () => {
|
||||
expect(requested.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Douban', { douban_id: 'db-7310', source: 'douban' }, 'douban:db-7310'],
|
||||
['Bangumi', { bangumi_id: 'bgm-7310', source: 'bangumi' }, 'bangumi:bgm-7310'],
|
||||
['AniList', { anilist_id: 154587, source: 'anilist' }, 'anilist:154587'],
|
||||
] as const)(
|
||||
'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists',
|
||||
async (_label, overrides, mediaId) => {
|
||||
const media = createTvMedia({ ...overrides, tmdb_id: 7310 })
|
||||
const seasonRequest = vi.fn()
|
||||
const groupRequest = vi.fn()
|
||||
const groupSeasonsRequest = vi.fn()
|
||||
server.use(
|
||||
mediaSeasonsHandler([createMediaSeason({ season_number: 1 })], 200, seasonRequest),
|
||||
mediaNotExistsHandler([]),
|
||||
mediaEpisodeGroupsHandler(7310, [], 200, groupRequest),
|
||||
mediaGroupSeasonsHandler('auxiliary-group', [], 200, groupSeasonsRequest),
|
||||
)
|
||||
|
||||
await renderDialog({ initialEpisodeGroup: 'auxiliary-group', media })
|
||||
|
||||
expect(await screen.findByText('第 1 季')).toBeInTheDocument()
|
||||
await settleRequests()
|
||||
expect(seasonRequest.mock.calls[0][0].searchParams.get('mediaid')).toBe(mediaId)
|
||||
expect(groupRequest).not.toHaveBeenCalled()
|
||||
expect(groupSeasonsRequest).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('synchronizes visible selections and modes, then emits the five-argument subscription payload', async () => {
|
||||
const media = createTvMedia({ tmdb_id: 7306 })
|
||||
const seasons = [
|
||||
|
||||
78
src/components/misc/DashboardMediaState.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 空结果使用的图标。 */
|
||||
emptyIcon: string
|
||||
/** 成功空结果对应的业务文案。 */
|
||||
emptyText: string
|
||||
/** 当前是否正在等待首次可用结果。 */
|
||||
loading: boolean
|
||||
/** 当前是否因请求失败而没有可展示的快照。 */
|
||||
failed: boolean
|
||||
/** 卡片标题。 */
|
||||
title: string
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
/** 在标题栏右侧显示与当前状态相关的轻量操作。 */
|
||||
append?: () => unknown
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="dashboard-media-state dashboard-grid-fill">
|
||||
<VCardItem class="dashboard-media-state-header">
|
||||
<VCardTitle>{{ props.title }}</VCardTitle>
|
||||
<template #append>
|
||||
<slot name="append" />
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText
|
||||
class="dashboard-media-state-content text-medium-emphasis"
|
||||
:role="props.failed ? 'alert' : 'status'"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template v-if="props.loading">
|
||||
<VProgressCircular indeterminate color="primary" size="28" width="2" />
|
||||
<span>{{ t('common.loading') }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="props.failed">
|
||||
<VIcon icon="mdi-server-network-off" color="warning" size="30" />
|
||||
<span>{{ t('dashboard.mediaServerLoadFailed') }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<VIcon :icon="props.emptyIcon" size="30" />
|
||||
<span>{{ props.emptyText }}</span>
|
||||
</template>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-media-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 10rem;
|
||||
}
|
||||
|
||||
.dashboard-media-state-header {
|
||||
padding-block-end: 0.25rem;
|
||||
}
|
||||
|
||||
.dashboard-media-state-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
min-block-size: 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
58
src/components/misc/DashboardRetryButton.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
/** 有可展示快照时,在支持悬停的桌面设备上延后到卡片交互时显示。 */
|
||||
deferred?: boolean
|
||||
/** 描述当前卡片重试目标的可访问文案。 */
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 请求用户主动重新加载当前卡片。 */
|
||||
retry: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBtn
|
||||
icon
|
||||
variant="text"
|
||||
color="warning"
|
||||
size="small"
|
||||
:class="{ 'dashboard-retry-button--deferred': props.deferred }"
|
||||
:aria-label="props.label"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
<VIcon icon="mdi-cloud-alert-outline" size="20" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ props.label }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.dashboard-retry-button--deferred {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.dashboard-retry-button--deferred {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
:global(.v-card:hover .dashboard-retry-button--deferred),
|
||||
:global(.v-card:focus-within .dashboard-retry-button--deferred) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-retry-button--deferred {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { useGlassOpticalRenderer } from '@/composables/useGlassOpticalRenderer'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
import {
|
||||
setGlassRendererState,
|
||||
useGlassOpticalInteractionSource,
|
||||
useGlassOpticalRenderer,
|
||||
type GlassRendererState,
|
||||
} from '@/composables/useGlassOpticalRenderer'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 当前玻璃材质,用于选择透明、色调或磨砂的光学参数。 */
|
||||
appearance: ThemeCustomizerGlassAppearance
|
||||
/** 用户选择的局部非均匀形变强度。 */
|
||||
deformationStrength: number
|
||||
/** 用户选择的轨迹、尾波与惯性强度。 */
|
||||
flowStrength: number
|
||||
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
||||
reflectionStrength: number
|
||||
/** 用户选择的真实壁纸可见度。 */
|
||||
transparencyStrength: number
|
||||
/** 玻璃内部壁纸采样的透射亮度;不会改变外层壁纸的曝光合同。 */
|
||||
transmissionStrength: number
|
||||
/** 用户选择的共享壁纸采样平移强度。 */
|
||||
translationStrength: number
|
||||
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
|
||||
routeKey: string
|
||||
/** 当前主题主色,用于同步色调材质的光学高光。 */
|
||||
tintColor: string
|
||||
/** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */
|
||||
transitionDuration: number
|
||||
/** 外层壁纸交叉淡化的 performance timeline 起点。 */
|
||||
transitionStartedAt: number
|
||||
/** 与 CSS 背景保持一致的活动壁纸。 */
|
||||
wallpaperUrl: string
|
||||
/** 切换期保留的上一张壁纸;空值表示当前没有交叉淡化。 */
|
||||
previousWallpaperUrl: string
|
||||
/** 下一张同源壁纸;两个 context 均完成上传后才允许外层提交切换。 */
|
||||
pendingWallpaperUrl?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
/** fixed 与 scroll renderer 均已准备同一张待切换纹理。 */
|
||||
wallpaperPrepared: [url: string]
|
||||
}>()
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||
const { state } = useGlassOpticalRenderer({
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const interactionSource = useGlassOpticalInteractionSource()
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
const fixedRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
canvas,
|
||||
canvas: fixedCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
quality: () => props.quality,
|
||||
reflectionStrength: () => props.reflectionStrength,
|
||||
transparencyStrength: () => props.transparencyStrength,
|
||||
transmissionStrength: () => props.transmissionStrength,
|
||||
translationStrength: () => props.translationStrength,
|
||||
routeKey: () => props.routeKey,
|
||||
tintColor: () => props.tintColor,
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||
surfaceSpace: 'fixed',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
const scrollRenderer = useGlassOpticalRenderer({
|
||||
active: true,
|
||||
appearance: () => props.appearance,
|
||||
canvas: scrollCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
pageMotion: pagePresentationMotion.reader,
|
||||
quality: () => props.quality,
|
||||
reflectionStrength: () => props.reflectionStrength,
|
||||
transparencyStrength: () => props.transparencyStrength,
|
||||
transmissionStrength: () => props.transmissionStrength,
|
||||
translationStrength: () => props.translationStrength,
|
||||
routeKey: () => props.routeKey,
|
||||
tintColor: () => props.tintColor,
|
||||
transitionDuration: () => props.transitionDuration,
|
||||
transitionStartedAt: () => props.transitionStartedAt,
|
||||
wallpaperUrl: () => props.wallpaperUrl,
|
||||
previousWallpaperUrl: () => props.previousWallpaperUrl,
|
||||
pendingWallpaperUrl: () => props.pendingWallpaperUrl ?? '',
|
||||
surfaceSpace: 'scroll',
|
||||
syncDocumentState: false,
|
||||
})
|
||||
|
||||
const rendererState = ref<GlassRendererState>('loading')
|
||||
|
||||
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
||||
watchEffect(() => {
|
||||
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
||||
const state: GlassRendererState = states.every(value => value === 'ready')
|
||||
? 'ready'
|
||||
: states.some(value => value === 'loading')
|
||||
? 'loading'
|
||||
: 'fallback'
|
||||
|
||||
setGlassRendererState(rendererState, state)
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
if (url && fixedRenderer.preparedWallpaperUrl.value === url && scrollRenderer.preparedWallpaperUrl.value === url) {
|
||||
emit('wallpaperPrepared', url)
|
||||
}
|
||||
})
|
||||
|
||||
onScopeDispose(() => {
|
||||
setGlassRendererState(rendererState, 'fallback')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="glass-optical-layer" aria-hidden="true" :data-state="state" />
|
||||
<canvas
|
||||
ref="fixedCanvas"
|
||||
class="glass-optical-layer glass-optical-layer--fixed"
|
||||
aria-hidden="true"
|
||||
data-presentation-space="fixed"
|
||||
:data-state="fixedRenderer.state.value"
|
||||
/>
|
||||
<canvas
|
||||
ref="scrollCanvas"
|
||||
class="glass-optical-layer glass-optical-layer--scroll"
|
||||
aria-hidden="true"
|
||||
data-presentation-space="scroll"
|
||||
:data-state="scrollRenderer.state.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { GLASS_OPTICAL_STRENGTH_DEFAULT, getGlassOpticalPresetParameters } from '@/utils/glassOptics'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
@@ -37,6 +38,7 @@ const { appMode } = usePWA()
|
||||
const { t } = useI18n()
|
||||
const { global: globalTheme } = useTheme()
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const defaultGlassTransmissionStrength = getGlassOpticalPresetParameters('clear', 'balanced', 'natural').transmission
|
||||
|
||||
// 将主题定制器打开状态同步到根节点,供全局悬浮按钮避让右侧面板。
|
||||
function syncThemeCustomizerOpenState(isOpen: boolean) {
|
||||
@@ -140,7 +142,13 @@ const hasAppModeCustomization = computed(() => {
|
||||
return (
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== 'clear' ||
|
||||
settings.value.glassDeformationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassFlowStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassQuality !== 'css' ||
|
||||
settings.value.glassReflectionStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTransmissionStrength !== defaultGlassTransmissionStrength ||
|
||||
settings.value.glassTranslationStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.glassTransparencyStrength !== GLASS_OPTICAL_STRENGTH_DEFAULT ||
|
||||
settings.value.radius !== 'default' ||
|
||||
settings.value.shadow !== '0' ||
|
||||
settings.value.skin !== 'default' ||
|
||||
|
||||
67
src/components/theme/__tests__/GlassOpticalLayer.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||
|
||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
const interactionSource = vi.hoisted(() => ({ subscribe: vi.fn() }))
|
||||
const setRendererState = vi.hoisted(() =>
|
||||
vi.fn((state: { value: string }, value: string) => {
|
||||
state.value = value
|
||||
}),
|
||||
)
|
||||
|
||||
vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
||||
setGlassRendererState: setRendererState,
|
||||
useGlassOpticalInteractionSource: vi.fn(() => interactionSource),
|
||||
useGlassOpticalRenderer: vi.fn((options: Record<string, unknown>) => {
|
||||
rendererCalls.push(options)
|
||||
|
||||
return {
|
||||
renderedFrames: ref(0),
|
||||
state: ref('ready'),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('GlassOpticalLayer', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||
rendererCalls.length = 0
|
||||
setRendererState.mockClear()
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 84,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
const canvases = wrapper.findAll('canvas')
|
||||
expect(canvases).toHaveLength(2)
|
||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||
expect(rendererCalls[1].pageMotion).toEqual(
|
||||
expect.objectContaining({
|
||||
opacity: expect.any(Object),
|
||||
revision: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
})
|
||||
67
src/composables/__tests__/useDynamicHeaderTab.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { defineComponent, onMounted, provide, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('useDynamicHeaderTab', () => {
|
||||
it('registers before mount and restores within the keep-alive activation flush', async () => {
|
||||
const register = vi.fn()
|
||||
const unregister = vi.fn()
|
||||
const mountedRegistrationCount = vi.fn()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/recommend', component: { template: '<div />' } }],
|
||||
})
|
||||
await router.push('/recommend')
|
||||
await router.isReady()
|
||||
|
||||
const Page = defineComponent({
|
||||
setup() {
|
||||
const activeTab = ref('movie')
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
registerHeaderTab({
|
||||
enableStateRestore: false,
|
||||
items: [{ tab: 'movie', title: '电影' }],
|
||||
modelValue: activeTab,
|
||||
})
|
||||
onMounted(() => mountedRegistrationCount(register.mock.calls.length))
|
||||
|
||||
return {}
|
||||
},
|
||||
template: '<div>推荐页</div>',
|
||||
})
|
||||
const Host = defineComponent({
|
||||
components: { Page },
|
||||
setup() {
|
||||
const active = ref(true)
|
||||
provide('registerDynamicHeaderTab', register)
|
||||
provide('unregisterDynamicHeaderTab', unregister)
|
||||
|
||||
return { active }
|
||||
},
|
||||
template: `
|
||||
<button type="button" @click="active = !active">切换</button>
|
||||
<KeepAlive><Page v-if="active" /></KeepAlive>
|
||||
`,
|
||||
})
|
||||
const wrapper = mount(Host, {
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
|
||||
expect(mountedRegistrationCount).toHaveBeenCalledWith(expect.any(Number))
|
||||
expect(mountedRegistrationCount.mock.calls[0][0]).toBeGreaterThan(0)
|
||||
expect(register.mock.calls.at(-1)?.[0].routePath).toBe('/recommend')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(unregister).toHaveBeenCalledWith('/recommend')
|
||||
|
||||
const registrationsBeforeActivation = register.mock.calls.length
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(register.mock.calls.length).toBeGreaterThan(registrationsBeforeActivation)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MediaInfo, MediaSeason, Subscribe } from '@/api/types'
|
||||
import {
|
||||
getMediaSubscribeId,
|
||||
getMediaSubscribeIdentity,
|
||||
getSubscribeMode,
|
||||
type SeasonSubscribeModes,
|
||||
type SubscribeMode,
|
||||
@@ -190,6 +191,20 @@ describe('media subscribe identifiers and modes', () => {
|
||||
expect(getMediaSubscribeId(createSubscribeMovie(overrides))).toBe(expected)
|
||||
})
|
||||
|
||||
it('keeps the declared AniList identity when TMDB is only auxiliary data', () => {
|
||||
const media = createSubscribeTv({
|
||||
anilist_id: 154587,
|
||||
source: 'anilist',
|
||||
tmdb_id: 209867,
|
||||
})
|
||||
|
||||
expect(getMediaSubscribeIdentity(media)).toEqual({
|
||||
mediaId: '154587',
|
||||
mediaKey: 'anilist:154587',
|
||||
source: 'anilist',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ best_version: false, best_version_full: true }, 'normal'],
|
||||
[{ best_version: 0, best_version_full: 1 }, 'normal'],
|
||||
@@ -223,8 +238,9 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
await waitFor(() => expect(mocks.doneProgress).toHaveBeenCalledOnce())
|
||||
expect(created).toHaveBeenCalledWith({
|
||||
episode_group: '',
|
||||
media_id: '101',
|
||||
media_source: 'themoviedb',
|
||||
mediaid: '',
|
||||
mediaid: 'tmdb:101',
|
||||
name: '普通电影',
|
||||
season: null,
|
||||
tmdbid: 101,
|
||||
@@ -238,6 +254,33 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
expect(mocks.doneProgress).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
|
||||
const media = createSubscribeTv({
|
||||
anilist_id: 154587,
|
||||
source: 'anilist',
|
||||
title: 'AniList 订阅剧集',
|
||||
tmdb_id: 209867,
|
||||
})
|
||||
const created = vi.fn()
|
||||
server.use(
|
||||
createSubscribeHandler({ data: { id: 512 }, success: true }, 200, created),
|
||||
defaultSubscribeConfigHandler('电视剧', { show_edit_dialog: false }),
|
||||
)
|
||||
await renderSubscribeHarness({ media })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'add-normal' }))
|
||||
|
||||
await waitFor(() => expect(created).toHaveBeenCalledOnce())
|
||||
expect(created.mock.calls[0][0]).toMatchObject({
|
||||
anilistid: 154587,
|
||||
media_id: '154587',
|
||||
media_source: 'anilist',
|
||||
mediaid: 'anilist:154587',
|
||||
season: 1,
|
||||
tmdbid: 209867,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a successful creation successful when default configuration loading fails', async () => {
|
||||
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const media = createSubscribeMovie({ title: '辅助查询失败电影', tmdb_id: 111 })
|
||||
|
||||
163
src/composables/__tests__/usePagePresentationMotion.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
getPagePresentationMotionProgress,
|
||||
PAGE_PRESENTATION_MOTION_DURATION_MS,
|
||||
PAGE_PRESENTATION_MOTION_START_OPACITY,
|
||||
PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y,
|
||||
usePagePresentationMotion,
|
||||
} from '@/composables/usePagePresentationMotion'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const motion = usePagePresentationMotion()
|
||||
let callbacks: Map<number, FrameRequestCallback>
|
||||
let frameId: number
|
||||
|
||||
beforeEach(() => {
|
||||
motion.cancel()
|
||||
callbacks = new Map()
|
||||
frameId = 0
|
||||
document.documentElement.dataset.theme = 'glass'
|
||||
vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
motion.cancel()
|
||||
delete document.documentElement.dataset.theme
|
||||
delete document.documentElement.dataset.pagePresentationMotion
|
||||
document.documentElement.style.removeProperty('--mp-page-motion-opacity')
|
||||
document.documentElement.style.removeProperty('--mp-page-motion-translate-y')
|
||||
})
|
||||
|
||||
describe('page presentation motion', () => {
|
||||
it('holds a glass route until its shared layout geometry remains stable', () => {
|
||||
const routeRoot = document.createElement('div')
|
||||
let routeHeight = 2096
|
||||
Object.defineProperties(routeRoot, {
|
||||
offsetHeight: { configurable: true, get: () => routeHeight },
|
||||
offsetWidth: { configurable: true, get: () => 1200 },
|
||||
scrollHeight: { configurable: true, get: () => routeHeight },
|
||||
scrollWidth: { configurable: true, get: () => 1200 },
|
||||
})
|
||||
document.body.append(routeRoot)
|
||||
|
||||
expect(motion.start('/dashboard', routeRoot)).toBe(true)
|
||||
expect(motion.active.value).toBe(true)
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
|
||||
|
||||
;[1016, 1080].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
|
||||
routeHeight = 1520
|
||||
;[1110, 1200].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(0)
|
||||
|
||||
;[1231].forEach(timestamp => [...callbacks.values()].at(-1)!(timestamp))
|
||||
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
|
||||
routeRoot.remove()
|
||||
})
|
||||
|
||||
it('uses one eased timeline for the initial, intermediate, and settled states', () => {
|
||||
const initialRevision = motion.revision.value
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(true)
|
||||
expect(motion.active.value).toBe(true)
|
||||
expect(motion.opacity.value).toBe(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.translateY.value).toBe(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
expect(motion.revision.value).toBe(initialRevision + 1)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBe('active')
|
||||
|
||||
const [firstFrame] = callbacks.values()
|
||||
firstFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS / 2)
|
||||
expect(motion.progress.value).toBeGreaterThan(0)
|
||||
expect(motion.progress.value).toBeLessThan(1)
|
||||
expect(motion.opacity.value).toBeGreaterThan(PAGE_PRESENTATION_MOTION_START_OPACITY)
|
||||
expect(motion.opacity.value).toBeLessThan(1)
|
||||
expect(motion.translateY.value).toBeGreaterThan(0)
|
||||
expect(motion.translateY.value).toBeLessThan(PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y)
|
||||
|
||||
const finalFrame = [...callbacks.values()].at(-1)!
|
||||
finalFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.progress.value).toBe(1)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.translateY.value).toBe(0)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('invalidates stale route frames without cancelling the current epoch', () => {
|
||||
motion.start('/first')
|
||||
const [staleFrame] = callbacks.values()
|
||||
|
||||
motion.start('/second')
|
||||
const currentRevision = motion.revision.value
|
||||
const currentFrame = [...callbacks.values()].at(-1)!
|
||||
staleFrame(1016)
|
||||
|
||||
expect(motion.routeKey.value).toBe('/second')
|
||||
expect(motion.revision.value).toBe(currentRevision)
|
||||
expect([...callbacks.values()]).toContain(currentFrame)
|
||||
|
||||
currentFrame(1000 + PAGE_PRESENTATION_MOTION_DURATION_MS)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
})
|
||||
|
||||
it('publishes a final renderer revision when an active motion is cancelled', () => {
|
||||
motion.start('/dashboard')
|
||||
const [frame] = callbacks.values()
|
||||
frame(1040)
|
||||
const activeRevision = motion.revision.value
|
||||
|
||||
motion.cancel()
|
||||
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.translateY.value).toBe(0)
|
||||
expect(motion.revision.value).toBe(activeRevision + 1)
|
||||
expect(document.documentElement.dataset.pagePresentationMotion).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settles immediately when reduced motion is requested', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
||||
...window.matchMedia(''),
|
||||
matches: true,
|
||||
})
|
||||
const initialRevision = motion.revision.value
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(true)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(motion.opacity.value).toBe(1)
|
||||
expect(motion.revision.value).toBe(initialRevision + 1)
|
||||
expect(callbacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves non-glass themes on the existing CSS animation path', () => {
|
||||
document.documentElement.dataset.theme = 'dark'
|
||||
|
||||
expect(motion.start('/dashboard')).toBe(false)
|
||||
expect(motion.active.value).toBe(false)
|
||||
expect(callbacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the shared cubic-bezier progress bounded and monotonic', () => {
|
||||
const quarter = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.25)
|
||||
const half = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.5)
|
||||
const threeQuarters = getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS * 0.75)
|
||||
|
||||
expect(getPagePresentationMotionProgress(0)).toBe(0)
|
||||
expect(quarter).toBeGreaterThan(0)
|
||||
expect(half).toBeGreaterThan(quarter)
|
||||
expect(threeQuarters).toBeGreaterThan(half)
|
||||
expect(getPagePresentationMotionProgress(PAGE_PRESENTATION_MOTION_DURATION_MS)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
previewGlassSettings,
|
||||
readThemeCustomizerSettings,
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
useEffectiveGlassSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
@@ -17,11 +18,18 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('uses clear appearance and balanced quality by default', () => {
|
||||
it('uses the checkpoint appearance, quality, and strength values by default', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(50)
|
||||
expect(settings.glassFlowStrength).toBe(50)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
expect(settings.glassReflectionStrength).toBe(35)
|
||||
expect(settings.glassTransmissionStrength).toBe(70)
|
||||
expect(settings.glassTranslationStrength).toBe(50)
|
||||
expect(settings.glassTransparencyStrength).toBe(70)
|
||||
})
|
||||
|
||||
it.each(['balanced', 'high'] as const)('preserves the %s quality contract', quality => {
|
||||
@@ -39,15 +47,55 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('falls back when stored glass settings are invalid', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassQuality: 'ultra' }),
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }),
|
||||
)
|
||||
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
})
|
||||
|
||||
it('rounds and clamps persisted optical strength values', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
glassDeformationStrength: -12,
|
||||
glassFlowStrength: 42.6,
|
||||
glassReflectionStrength: 140.6,
|
||||
glassTransmissionStrength: 78.4,
|
||||
glassTranslationStrength: 101,
|
||||
glassTransparencyStrength: 83.7,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassDeformationStrength: 0,
|
||||
glassFlowStrength: 43,
|
||||
glassReflectionStrength: 100,
|
||||
glassTransmissionStrength: 78,
|
||||
glassTranslationStrength: 100,
|
||||
glassTransparencyStrength: 84,
|
||||
})
|
||||
})
|
||||
|
||||
it('migrates the legacy motion value into translation, deformation, and flow', () => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassMotionStrength: 67 }))
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassDeformationStrength: 67,
|
||||
glassFlowStrength: 67,
|
||||
glassTranslationStrength: 67,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps neutral transmission for stored settings created before the field existed', () => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassAppearance: 'clear' }))
|
||||
|
||||
expect(readThemeCustomizerSettings().glassTransmissionStrength).toBe(50)
|
||||
})
|
||||
|
||||
it('syncs glass settings to the document roots', () => {
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
@@ -61,6 +109,12 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.documentElement.dataset.glassQuality).toBe('high')
|
||||
expect(document.body.dataset.glassAppearance).toBe('tinted')
|
||||
expect(document.body.dataset.glassQuality).toBe('high')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.35')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBe(1)
|
||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-transparency'))).toBeCloseTo(0.96, 2)
|
||||
})
|
||||
|
||||
it('previews glass settings without persisting them', () => {
|
||||
@@ -76,11 +130,31 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('commits the latest glass preview as one persisted state', () => {
|
||||
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'clear' })
|
||||
previewGlassSettings({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTransmissionStrength: 76,
|
||||
glassTranslationStrength: 69,
|
||||
glassTransparencyStrength: 80,
|
||||
})
|
||||
|
||||
commitGlassPreview()
|
||||
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({ glassAppearance: 'tinted', glassQuality: 'css' })
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'glide',
|
||||
glassQuality: 'css',
|
||||
glassReflectionStrength: 81,
|
||||
glassTransmissionStrength: 76,
|
||||
glassTranslationStrength: 69,
|
||||
glassTransparencyStrength: 80,
|
||||
})
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
})
|
||||
|
||||
@@ -96,12 +170,46 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
})
|
||||
|
||||
it('restores persisted glass settings when a preview is cancelled', () => {
|
||||
persistPartialThemeCustomizerSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'clear' })
|
||||
const effective = useEffectiveGlassSettings()
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassReflectionStrength: 66,
|
||||
glassTransmissionStrength: 64,
|
||||
glassTranslationStrength: 46,
|
||||
glassTransparencyStrength: 72,
|
||||
})
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTransmissionStrength: 92,
|
||||
glassTranslationStrength: 86,
|
||||
glassTransparencyStrength: 94,
|
||||
})
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTransmissionStrength: 92,
|
||||
glassTranslationStrength: 86,
|
||||
glassTransparencyStrength: 94,
|
||||
})
|
||||
|
||||
cancelGlassPreview()
|
||||
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe('tinted')
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassFlowStrength: 44,
|
||||
glassReflectionStrength: 66,
|
||||
glassTransmissionStrength: 64,
|
||||
glassTranslationStrength: 46,
|
||||
glassTransparencyStrength: 72,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useDynamicHeaderTab() {
|
||||
|
||||
// 尝试从inject获取
|
||||
const registerDynamicHeaderTab = inject<(tab: DynamicHeaderTabConfig) => void>('registerDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<() => void>('unregisterDynamicHeaderTab')
|
||||
const unregisterDynamicHeaderTab = inject<(routePath?: string) => void>('unregisterDynamicHeaderTab')
|
||||
|
||||
/** 注册当前页面的动态头部标签配置。 */
|
||||
const registerHeaderTab = (config: {
|
||||
@@ -153,12 +153,12 @@ export function useDynamicHeaderTab() {
|
||||
// 取消注册函数
|
||||
const doUnregister = () => {
|
||||
if (unregisterDynamicHeaderTab) {
|
||||
unregisterDynamicHeaderTab()
|
||||
unregisterDynamicHeaderTab(tabConfig.routePath)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始注册(延迟到下个tick,确保路由已经完全切换)
|
||||
nextTick(() => {
|
||||
// 页签高度必须在页面首帧布局前确定,避免内容先渲染后再整体下移。
|
||||
onBeforeMount(() => {
|
||||
doRegister()
|
||||
})
|
||||
|
||||
@@ -166,15 +166,13 @@ export function useDynamicHeaderTab() {
|
||||
onActivated(() => {
|
||||
// 页面激活时,优先使用当前页面的实际状态,而不是恢复的PWA状态
|
||||
// 这样可以避免从后台切换回来时显示错误的标签页
|
||||
nextTick(() => {
|
||||
// 确保使用当前页面的实际modelValue,不受PWA状态恢复影响
|
||||
tabConfig.modelValue = config.modelValue.value
|
||||
// 同步当前状态到PWA存储,确保状态一致性
|
||||
if (pwaTabState && config.modelValue.value) {
|
||||
pwaTabState.activeTab.value = config.modelValue.value
|
||||
}
|
||||
doRegister()
|
||||
})
|
||||
// 确保使用当前页面的实际modelValue,不受PWA状态恢复影响
|
||||
tabConfig.modelValue = config.modelValue.value
|
||||
// 同步当前状态到PWA存储,确保状态一致性
|
||||
if (pwaTabState && config.modelValue.value) {
|
||||
pwaTabState.activeTab.value = config.modelValue.value
|
||||
}
|
||||
doRegister()
|
||||
})
|
||||
|
||||
// 处理页面失活时取消注册(支持keep-alive缓存的页面)
|
||||
|
||||
@@ -49,17 +49,55 @@ const SubscribeSeasonDialog = defineAsyncComponent(() => import('@/components/di
|
||||
|
||||
export type SeasonSubscribeModes = Record<number, SubscribeMode>
|
||||
|
||||
export interface MediaSubscribeIdentity {
|
||||
mediaId: string
|
||||
mediaKey: string
|
||||
source: string
|
||||
}
|
||||
|
||||
/** 按媒体声明的主来源解析订阅身份,避免辅助 ID 覆盖原始识别源。 */
|
||||
export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIdentity | undefined {
|
||||
if (!media) return undefined
|
||||
|
||||
const normalizeSource = (value?: string) => {
|
||||
const source = (value || '').trim().toLowerCase()
|
||||
return source === 'tmdb' ? 'themoviedb' : source
|
||||
}
|
||||
const sourceIds: Record<string, unknown> = {
|
||||
anilist: media.anilist_id,
|
||||
bangumi: media.bangumi_id,
|
||||
douban: media.douban_id,
|
||||
themoviedb: media.tmdb_id,
|
||||
}
|
||||
const buildIdentity = (identitySource: string, value: unknown): MediaSubscribeIdentity | undefined => {
|
||||
if (value === undefined || value === null || !String(value).trim()) return undefined
|
||||
const mediaId = String(value).trim()
|
||||
const prefix = identitySource === 'themoviedb' ? 'tmdb' : identitySource
|
||||
return {
|
||||
mediaId,
|
||||
mediaKey: `${prefix}:${mediaId}`,
|
||||
source: identitySource,
|
||||
}
|
||||
}
|
||||
|
||||
const declaredSources = [media.mediaid_prefix, media.source]
|
||||
.map(normalizeSource)
|
||||
.filter((source, index, sources) => source && sources.indexOf(source) === index)
|
||||
for (const source of declaredSources) {
|
||||
const declaredIdentity = buildIdentity(source, media.media_id ?? sourceIds[source])
|
||||
if (declaredIdentity) return declaredIdentity
|
||||
}
|
||||
|
||||
for (const fallbackSource of ['themoviedb', 'douban', 'bangumi', 'anilist']) {
|
||||
const fallbackIdentity = buildIdentity(fallbackSource, sourceIds[fallbackSource])
|
||||
if (fallbackIdentity) return fallbackIdentity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 生成跨媒体源稳定的订阅媒体标识。
|
||||
export function getMediaSubscribeId(media?: MediaInfo) {
|
||||
if (media?.media_id && (media.source || media.mediaid_prefix)) {
|
||||
const source = media.mediaid_prefix || media.source
|
||||
return `${source === 'themoviedb' ? 'tmdb' : source}:${media.media_id}`
|
||||
}
|
||||
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}`
|
||||
if (media?.anilist_id) return `anilist:${media.anilist_id}`
|
||||
return ''
|
||||
return getMediaSubscribeIdentity(media)?.mediaKey ?? ''
|
||||
}
|
||||
|
||||
// 将订阅模式转换为后端订阅字段。
|
||||
@@ -271,6 +309,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
) {
|
||||
const media = currentMedia()
|
||||
if (!media) return
|
||||
const identity = getMediaSubscribeIdentity(media)
|
||||
|
||||
startNProgress()
|
||||
try {
|
||||
@@ -282,11 +321,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
|
||||
doubanid: media.douban_id,
|
||||
bangumiid: media.bangumi_id,
|
||||
anilistid: media.anilist_id,
|
||||
media_source: media.source || media.mediaid_prefix,
|
||||
media_id: media.media_id,
|
||||
mediaid: media.media_id
|
||||
? `${(media.mediaid_prefix || media.source) === 'themoviedb' ? 'tmdb' : media.mediaid_prefix || media.source}:${media.media_id}`
|
||||
: '',
|
||||
media_source: identity?.source,
|
||||
media_id: identity?.mediaId,
|
||||
mediaid: identity?.mediaKey ?? '',
|
||||
season: media.type === '电影' ? null : season,
|
||||
...payload,
|
||||
episode_group: episodeGroup.value,
|
||||
|
||||
224
src/composables/usePagePresentationMotion.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { readonly, ref, type Ref } from 'vue'
|
||||
|
||||
export const PAGE_PRESENTATION_MOTION_DURATION_MS = 180
|
||||
export const PAGE_PRESENTATION_MOTION_START_OPACITY = 0.88
|
||||
export const PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y = 4
|
||||
export const PAGE_PRESENTATION_LAYOUT_STABLE_MS = 120
|
||||
export const PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS = 480
|
||||
|
||||
/** renderer 只读取同一帧已经提交到 DOM 的页面呈现状态。 */
|
||||
export interface PagePresentationMotionReader {
|
||||
/** 页面是否处于共享呈现事务中。 */
|
||||
active: Readonly<Ref<boolean>>
|
||||
/** 当前页面材质与 DOM 共同使用的透明度。 */
|
||||
opacity: Readonly<Ref<number>>
|
||||
/** 每次 DOM motion 样式提交后递增,renderer 据此在同一帧刷新表面。 */
|
||||
revision: Readonly<Ref<number>>
|
||||
}
|
||||
|
||||
const active = ref(false)
|
||||
const epoch = ref(0)
|
||||
const opacity = ref(1)
|
||||
const progress = ref(1)
|
||||
const revision = ref(0)
|
||||
const routeKey = ref('')
|
||||
const translateY = ref(0)
|
||||
let animationFrame: number | null = null
|
||||
let layoutHoldStartedAt = 0
|
||||
let layoutStableSince = 0
|
||||
let layoutSignature = ''
|
||||
let startedAt = 0
|
||||
|
||||
function sampleBezier(time: number, start: number, end: number) {
|
||||
const inverse = 1 - time
|
||||
|
||||
return 3 * inverse * inverse * time * start + 3 * inverse * time * time * end + time * time * time
|
||||
}
|
||||
|
||||
/** 计算玻璃页面统一使用的 `cubic-bezier(0.2, 0.8, 0.2, 1)` 进度。 */
|
||||
export function getPagePresentationMotionProgress(elapsed: number, duration = PAGE_PRESENTATION_MOTION_DURATION_MS) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
if (elapsed <= 0) return 0
|
||||
|
||||
const target = elapsed / duration
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
let parameter = target
|
||||
|
||||
for (let iteration = 0; iteration < 10; iteration += 1) {
|
||||
parameter = (lower + upper) * 0.5
|
||||
if (sampleBezier(parameter, 0.2, 0.2) < target) lower = parameter
|
||||
else upper = parameter
|
||||
}
|
||||
|
||||
return sampleBezier(parameter, 0.8, 1)
|
||||
}
|
||||
|
||||
function clearDocumentMotionState() {
|
||||
const root = document.documentElement
|
||||
delete root.dataset.pagePresentationMotion
|
||||
root.style.removeProperty('--mp-page-motion-opacity')
|
||||
root.style.removeProperty('--mp-page-motion-translate-y')
|
||||
}
|
||||
|
||||
/** 先提交 DOM 样式,再发布 revision,保证 renderer 读取到同一帧的真实矩形。 */
|
||||
function applyMotionFrame(nextProgress: number) {
|
||||
const root = document.documentElement
|
||||
const nextOpacity =
|
||||
PAGE_PRESENTATION_MOTION_START_OPACITY + (1 - PAGE_PRESENTATION_MOTION_START_OPACITY) * nextProgress
|
||||
const nextTranslateY = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y * (1 - nextProgress)
|
||||
|
||||
root.dataset.pagePresentationMotion = 'active'
|
||||
root.style.setProperty('--mp-page-motion-opacity', nextOpacity.toFixed(4))
|
||||
root.style.setProperty('--mp-page-motion-translate-y', `${nextTranslateY.toFixed(3)}px`)
|
||||
opacity.value = nextOpacity
|
||||
progress.value = nextProgress
|
||||
translateY.value = nextTranslateY
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
/** 布局门关闭时 DOM 与 renderer 都不暴露尚未稳定的页面几何。 */
|
||||
function applyLayoutHoldFrame() {
|
||||
const root = document.documentElement
|
||||
|
||||
root.dataset.pagePresentationMotion = 'active'
|
||||
root.style.setProperty('--mp-page-motion-opacity', '0')
|
||||
root.style.setProperty('--mp-page-motion-translate-y', `${PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y}px`)
|
||||
opacity.value = 0
|
||||
progress.value = 0
|
||||
translateY.value = PAGE_PRESENTATION_MOTION_START_TRANSLATE_Y
|
||||
revision.value += 1
|
||||
}
|
||||
|
||||
function getLayoutSignature(root: HTMLElement) {
|
||||
return `${root.offsetWidth},${root.offsetHeight},${root.scrollWidth},${root.scrollHeight}`
|
||||
}
|
||||
|
||||
function beginReveal(timestamp: number, motionEpoch: number) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
|
||||
startedAt = timestamp
|
||||
applyMotionFrame(0)
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
||||
}
|
||||
|
||||
/** 页面根持续稳定后才开始 reveal;上限避免持续布局页面永久不可见。 */
|
||||
function sampleLayoutHold(timestamp: number, motionEpoch: number, root: HTMLElement) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
animationFrame = null
|
||||
|
||||
const nextSignature = getLayoutSignature(root)
|
||||
if (nextSignature !== layoutSignature) {
|
||||
layoutSignature = nextSignature
|
||||
layoutStableSince = timestamp
|
||||
}
|
||||
|
||||
if (
|
||||
timestamp - layoutStableSince >= PAGE_PRESENTATION_LAYOUT_STABLE_MS ||
|
||||
timestamp - layoutHoldStartedAt >= PAGE_PRESENTATION_LAYOUT_HOLD_MAX_MS
|
||||
) {
|
||||
beginReveal(timestamp, motionEpoch)
|
||||
return
|
||||
}
|
||||
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
||||
sampleLayoutHold(nextTimestamp, motionEpoch, root),
|
||||
)
|
||||
}
|
||||
|
||||
function settleMotion() {
|
||||
active.value = false
|
||||
opacity.value = 1
|
||||
progress.value = 1
|
||||
translateY.value = 0
|
||||
clearDocumentMotionState()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
const needsRendererCommit =
|
||||
active.value ||
|
||||
opacity.value !== 1 ||
|
||||
translateY.value !== 0 ||
|
||||
document.documentElement.dataset.pagePresentationMotion === 'active'
|
||||
|
||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
if (needsRendererCommit) epoch.value += 1
|
||||
settleMotion()
|
||||
if (needsRendererCommit) revision.value += 1
|
||||
}
|
||||
|
||||
function renderFrame(timestamp: number, motionEpoch: number) {
|
||||
if (!active.value || epoch.value !== motionEpoch) return
|
||||
animationFrame = null
|
||||
|
||||
const nextProgress = getPagePresentationMotionProgress(timestamp - startedAt)
|
||||
applyMotionFrame(nextProgress)
|
||||
if (nextProgress < 1) {
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp => renderFrame(nextTimestamp, motionEpoch))
|
||||
return
|
||||
}
|
||||
|
||||
settleMotion()
|
||||
}
|
||||
|
||||
/**
|
||||
* 玻璃主题由共享控制器接管页面入场;其他主题继续使用既有 CSS keyframe。
|
||||
* 返回 true 表示本次路由变化已经处理,包括 reduced-motion 的即时提交。
|
||||
*/
|
||||
function start(nextRouteKey: string, layoutRoot?: HTMLElement | null) {
|
||||
if (document.documentElement.dataset.theme !== 'glass') {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
|
||||
if (animationFrame !== null) window.cancelAnimationFrame(animationFrame)
|
||||
animationFrame = null
|
||||
epoch.value += 1
|
||||
const motionEpoch = epoch.value
|
||||
routeKey.value = nextRouteKey
|
||||
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
||||
settleMotion()
|
||||
revision.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
active.value = true
|
||||
const timestamp = performance.now()
|
||||
if (layoutRoot) {
|
||||
layoutHoldStartedAt = timestamp
|
||||
layoutStableSince = timestamp
|
||||
layoutSignature = getLayoutSignature(layoutRoot)
|
||||
applyLayoutHoldFrame()
|
||||
animationFrame = window.requestAnimationFrame(nextTimestamp =>
|
||||
sampleLayoutHold(nextTimestamp, motionEpoch, layoutRoot),
|
||||
)
|
||||
} else {
|
||||
beginReveal(timestamp, motionEpoch)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const reader: PagePresentationMotionReader = {
|
||||
active: readonly(active),
|
||||
opacity: readonly(opacity),
|
||||
revision: readonly(revision),
|
||||
}
|
||||
|
||||
/** 提供默认布局与 glass renderer 共享的短时页面呈现事务。 */
|
||||
export function usePagePresentationMotion() {
|
||||
return {
|
||||
active: readonly(active),
|
||||
cancel,
|
||||
epoch: readonly(epoch),
|
||||
opacity: reader.opacity,
|
||||
progress: readonly(progress),
|
||||
reader,
|
||||
revision: reader.revision,
|
||||
routeKey: readonly(routeKey),
|
||||
start,
|
||||
translateY: readonly(translateY),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,16 @@ import { useTheme } from 'vuetify'
|
||||
import { checkPrefersColorSchemeIsDark } from '@/@core/utils'
|
||||
import { saveLocalTheme } from '@/@core/utils/theme'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import {
|
||||
GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalPresetParameters,
|
||||
getGlassOpticalTransmissionStrength,
|
||||
getGlassOpticalTransparency,
|
||||
normalizeGlassOpticalStrength,
|
||||
type GlassOpticalPreset,
|
||||
} from '@/utils/glassOptics'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||
|
||||
@@ -64,8 +74,22 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
|
||||
export interface ThemeCustomizerSettings {
|
||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||
glassAppearance: ThemeCustomizerGlassAppearance
|
||||
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
||||
glassDeformationStrength: number
|
||||
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
||||
glassFlowStrength: number
|
||||
/** 当前玻璃方案;具体参数独立保存,滑杆调整不会丢失方案归属。 */
|
||||
glassPreset: GlassOpticalPreset
|
||||
/** 玻璃主题的渲染质量,决定使用标准 CSS 或共享光学渲染器。 */
|
||||
glassQuality: ThemeCustomizerGlassQuality
|
||||
/** 玻璃亮边、镜面高光与焦散光照强度,范围 0 到 100。 */
|
||||
glassReflectionStrength: number
|
||||
/** 玻璃内部壁纸采样的亮度与暗部展开强度,范围 0 到 100。 */
|
||||
glassTransmissionStrength: number
|
||||
/** 共享壁纸在表面内的统一采样平移强度,范围 0 到 100。 */
|
||||
glassTranslationStrength: number
|
||||
/** 玻璃材质释放真实壁纸的程度,范围 0 到 100。 */
|
||||
glassTransparencyStrength: number
|
||||
/** 桌面导航布局。 */
|
||||
layout: ThemeCustomizerLayout
|
||||
/** 主题强调色,也是色调玻璃的颜色来源。 */
|
||||
@@ -86,6 +110,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
||||
const validLayouts: ThemeCustomizerLayout[] = ['vertical', 'collapsed', 'horizontal']
|
||||
@@ -123,9 +148,18 @@ function readStoredThemePreference(): ThemeCustomizerTheme {
|
||||
|
||||
/** 生成与当前主题偏好一致的定制器默认设置。 */
|
||||
function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: glassParameters.translation,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -136,6 +170,19 @@ function getDefaultThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizableThemeCustomizerSettings = Partial<ThemeCustomizerSettings> & {
|
||||
/** 旧版单一动态强度仅用于一次性迁移到三个独立维度。 */
|
||||
glassMotionStrength?: unknown
|
||||
}
|
||||
|
||||
/** 新字段缺失时继承旧版动态强度,避免升级后无声重置用户手感。 */
|
||||
function normalizeMigratedGlassStrength(value: unknown, legacyValue: unknown, fallback: number) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return normalizeGlassOpticalStrength(value)
|
||||
if (typeof legacyValue === 'number' && Number.isFinite(legacyValue)) return normalizeGlassOpticalStrength(legacyValue)
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 将旧版语义阴影档位迁移到 Vuetify elevation 数值档位。 */
|
||||
function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow {
|
||||
if (validShadows.includes(shadow as ThemeCustomizerShadow)) return shadow as ThemeCustomizerShadow
|
||||
@@ -145,7 +192,10 @@ function normalizeThemeCustomizerShadow(shadow: unknown): ThemeCustomizerShadow
|
||||
}
|
||||
|
||||
/** 规范化持久化的主题定制设置并迁移旧值。 */
|
||||
function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSettings>): ThemeCustomizerSettings {
|
||||
function normalizeThemeCustomizerSettings(
|
||||
settings: NormalizableThemeCustomizerSettings,
|
||||
preserveLegacyTransmission = false,
|
||||
): ThemeCustomizerSettings {
|
||||
const fallback = getDefaultThemeCustomizerSettings()
|
||||
const storedRadius = settings.radius as string | undefined
|
||||
const radius = storedRadius === 'huge' ? 'extra' : storedRadius
|
||||
@@ -155,9 +205,41 @@ function normalizeThemeCustomizerSettings(settings: Partial<ThemeCustomizerSetti
|
||||
glassAppearance: validGlassAppearances.includes(settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
? (settings.glassAppearance as ThemeCustomizerGlassAppearance)
|
||||
: fallback.glassAppearance,
|
||||
glassDeformationStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassDeformationStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassDeformationStrength,
|
||||
),
|
||||
glassFlowStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassFlowStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassFlowStrength,
|
||||
),
|
||||
glassPreset: validGlassPresets.includes(settings.glassPreset as GlassOpticalPreset)
|
||||
? (settings.glassPreset as GlassOpticalPreset)
|
||||
: fallback.glassPreset,
|
||||
glassQuality: validGlassQualities.includes(settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
? (settings.glassQuality as ThemeCustomizerGlassQuality)
|
||||
: fallback.glassQuality,
|
||||
glassReflectionStrength:
|
||||
settings.glassReflectionStrength === undefined
|
||||
? fallback.glassReflectionStrength
|
||||
: normalizeGlassOpticalStrength(settings.glassReflectionStrength),
|
||||
glassTransmissionStrength:
|
||||
settings.glassTransmissionStrength === undefined
|
||||
? preserveLegacyTransmission
|
||||
? GLASS_OPTICAL_STRENGTH_DEFAULT
|
||||
: fallback.glassTransmissionStrength
|
||||
: normalizeGlassOpticalStrength(settings.glassTransmissionStrength),
|
||||
glassTranslationStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassTranslationStrength,
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassTranslationStrength,
|
||||
),
|
||||
glassTransparencyStrength:
|
||||
settings.glassTransparencyStrength === undefined
|
||||
? fallback.glassTransparencyStrength
|
||||
: normalizeGlassOpticalStrength(settings.glassTransparencyStrength),
|
||||
layout: validLayouts.includes(settings.layout as ThemeCustomizerLayout)
|
||||
? (settings.layout as ThemeCustomizerLayout)
|
||||
: fallback.layout,
|
||||
@@ -183,11 +265,7 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||
const parsed = stored ? JSON.parse(stored) : {}
|
||||
return normalizeThemeCustomizerSettings({
|
||||
...fallback,
|
||||
...parsed,
|
||||
theme: readStoredThemePreference(),
|
||||
})
|
||||
return normalizeThemeCustomizerSettings({ ...parsed, theme: readStoredThemePreference() }, Boolean(stored))
|
||||
} catch (error) {
|
||||
console.warn('读取主题定制设置失败,已使用默认设置:', error)
|
||||
|
||||
@@ -197,13 +275,37 @@ export function readThemeCustomizerSettings(): ThemeCustomizerSettings {
|
||||
|
||||
// 生产构建会改写导出函数的声明形式,状态初始化必须放在读取函数定义之后,避免首屏执行时引用未完成赋值的函数。
|
||||
const settingsState = ref<ThemeCustomizerSettings>(readThemeCustomizerSettings())
|
||||
const glassPreviewState = ref<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'> | null>(null)
|
||||
type ThemeCustomizerGlassSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTransmissionStrength'
|
||||
| 'glassTranslationStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
>
|
||||
const glassPreviewState = ref<ThemeCustomizerGlassSettings | null>(null)
|
||||
const effectiveGlassSettings = computed(() => ({
|
||||
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||
glassDeformationStrength:
|
||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||
glassQuality: glassPreviewState.value?.glassQuality ?? settingsState.value.glassQuality,
|
||||
glassReflectionStrength:
|
||||
glassPreviewState.value?.glassReflectionStrength ?? settingsState.value.glassReflectionStrength,
|
||||
glassTransmissionStrength:
|
||||
glassPreviewState.value?.glassTransmissionStrength ?? settingsState.value.glassTransmissionStrength,
|
||||
glassTranslationStrength:
|
||||
glassPreviewState.value?.glassTranslationStrength ?? settingsState.value.glassTranslationStrength,
|
||||
glassTransparencyStrength:
|
||||
glassPreviewState.value?.glassTransparencyStrength ?? settingsState.value.glassTransparencyStrength,
|
||||
}))
|
||||
|
||||
/** 提供当前实际生效的玻璃外观;临时预览优先于已持久化设置。 */
|
||||
/** 提供当前实际生效的玻璃设置;临时预览优先于已持久化设置。 */
|
||||
export function useEffectiveGlassSettings() {
|
||||
return readonly(effectiveGlassSettings)
|
||||
}
|
||||
@@ -259,13 +361,38 @@ export function applyPrimaryColorToVuetify(color: string, themeApi: VuetifyTheme
|
||||
export function applyThemeCustomizerRootSettings(
|
||||
settings: Pick<
|
||||
ThemeCustomizerSettings,
|
||||
'glassAppearance' | 'glassQuality' | 'layout' | 'radius' | 'semiDarkMenu' | 'shadow' | 'skin'
|
||||
| 'glassAppearance'
|
||||
| 'glassQuality'
|
||||
| 'glassReflectionStrength'
|
||||
| 'glassTransmissionStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
| 'layout'
|
||||
| 'radius'
|
||||
| 'semiDarkMenu'
|
||||
| 'shadow'
|
||||
| 'skin'
|
||||
>,
|
||||
) {
|
||||
if (!isBrowser()) return
|
||||
|
||||
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
document.documentElement.setAttribute('data-glass-quality', settings.glassQuality)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-reflection',
|
||||
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-transmission',
|
||||
String(getGlassOpticalTransmissionStrength(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-transmission-brightness',
|
||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.documentElement.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
||||
)
|
||||
document.documentElement.setAttribute('data-theme-layout', settings.layout)
|
||||
document.documentElement.setAttribute('data-theme-radius', settings.radius)
|
||||
document.documentElement.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -273,6 +400,22 @@ export function applyThemeCustomizerRootSettings(
|
||||
document.documentElement.setAttribute('data-theme-skin', settings.skin)
|
||||
document.body.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
document.body.setAttribute('data-glass-quality', settings.glassQuality)
|
||||
document.body.style.setProperty(
|
||||
'--glass-reflection',
|
||||
String(normalizeGlassOpticalStrength(settings.glassReflectionStrength) / GLASS_OPTICAL_STRENGTH_MAX),
|
||||
)
|
||||
document.body.style.setProperty(
|
||||
'--glass-transmission',
|
||||
String(getGlassOpticalTransmissionStrength(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.body.style.setProperty(
|
||||
'--glass-transmission-brightness',
|
||||
String(getGlassOpticalCssTransmissionBrightness(settings.glassTransmissionStrength)),
|
||||
)
|
||||
document.body.style.setProperty(
|
||||
'--glass-transparency',
|
||||
String(getGlassOpticalTransparency(settings.glassTransparencyStrength)),
|
||||
)
|
||||
document.body.setAttribute('data-theme-layout', settings.layout)
|
||||
document.body.setAttribute('data-theme-radius', settings.radius)
|
||||
document.body.setAttribute('data-theme-semi-dark-menu', String(settings.semiDarkMenu))
|
||||
@@ -340,10 +483,8 @@ export function persistPartialThemeCustomizerSettings(patch: Partial<ThemeCustom
|
||||
return nextSettings
|
||||
}
|
||||
|
||||
/** 临时应用玻璃外观,不写入存储或广播持久化变更。 */
|
||||
export function previewGlassSettings(
|
||||
patch: Partial<Pick<ThemeCustomizerSettings, 'glassAppearance' | 'glassQuality'>>,
|
||||
) {
|
||||
/** 临时应用玻璃设置,不写入存储或广播持久化变更。 */
|
||||
export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings>) {
|
||||
const previewSettings = normalizeThemeCustomizerSettings({
|
||||
...settingsState.value,
|
||||
...glassPreviewState.value,
|
||||
@@ -352,7 +493,14 @@ export function previewGlassSettings(
|
||||
|
||||
glassPreviewState.value = {
|
||||
glassAppearance: previewSettings.glassAppearance,
|
||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||
glassPreset: previewSettings.glassPreset,
|
||||
glassQuality: previewSettings.glassQuality,
|
||||
glassReflectionStrength: previewSettings.glassReflectionStrength,
|
||||
glassTransmissionStrength: previewSettings.glassTransmissionStrength,
|
||||
glassTranslationStrength: previewSettings.glassTranslationStrength,
|
||||
glassTransparencyStrength: previewSettings.glassTransparencyStrength,
|
||||
}
|
||||
applyThemeCustomizerRootSettings({
|
||||
...settingsState.value,
|
||||
@@ -385,9 +533,17 @@ export function cancelGlassPreview() {
|
||||
|
||||
/** 判断当前主题定制设置是否仍为默认值。 */
|
||||
export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettings) {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
const defaults = normalizeThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassFlowStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: GLASS_OPTICAL_STRENGTH_DEFAULT,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -399,7 +555,14 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
|
||||
return (
|
||||
settings.glassAppearance === defaults.glassAppearance &&
|
||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||
settings.glassPreset === defaults.glassPreset &&
|
||||
settings.glassQuality === defaults.glassQuality &&
|
||||
settings.glassReflectionStrength === defaults.glassReflectionStrength &&
|
||||
settings.glassTransmissionStrength === defaults.glassTransmissionStrength &&
|
||||
settings.glassTranslationStrength === defaults.glassTranslationStrength &&
|
||||
settings.glassTransparencyStrength === defaults.glassTransparencyStrength &&
|
||||
settings.layout === defaults.layout &&
|
||||
settings.primaryColor === defaults.primaryColor &&
|
||||
settings.radius === defaults.radius &&
|
||||
@@ -449,11 +612,46 @@ export function useThemeCustomizer() {
|
||||
return updateSettings({ glassAppearance })
|
||||
}
|
||||
|
||||
/** 更新玻璃局部非均匀形变强度。 */
|
||||
function setGlassDeformationStrength(glassDeformationStrength: number) {
|
||||
return updateSettings({ glassDeformationStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||
return updateSettings({ glassFlowStrength })
|
||||
}
|
||||
|
||||
/** 更新当前玻璃方案,不隐式覆盖用户已保存的具体参数。 */
|
||||
function setGlassPreset(glassPreset: GlassOpticalPreset) {
|
||||
return updateSettings({ glassPreset })
|
||||
}
|
||||
|
||||
/** 更新玻璃主题渲染质量档位。 */
|
||||
function setGlassQuality(glassQuality: ThemeCustomizerGlassQuality) {
|
||||
return updateSettings({ glassQuality })
|
||||
}
|
||||
|
||||
/** 更新玻璃表面反射亮度。 */
|
||||
function setGlassReflectionStrength(glassReflectionStrength: number) {
|
||||
return updateSettings({ glassReflectionStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃内部壁纸采样的透射亮度。 */
|
||||
function setGlassTransmissionStrength(glassTransmissionStrength: number) {
|
||||
return updateSettings({ glassTransmissionStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃统一采样平移强度。 */
|
||||
function setGlassTranslationStrength(glassTranslationStrength: number) {
|
||||
return updateSettings({ glassTranslationStrength })
|
||||
}
|
||||
|
||||
/** 更新玻璃材质的真实壁纸可见度。 */
|
||||
function setGlassTransparencyStrength(glassTransparencyStrength: number) {
|
||||
return updateSettings({ glassTransparencyStrength })
|
||||
}
|
||||
|
||||
/** 更新全局圆角档位。 */
|
||||
function setRadius(radius: ThemeCustomizerRadius) {
|
||||
return updateSettings({ radius })
|
||||
@@ -486,9 +684,18 @@ export function useThemeCustomizer() {
|
||||
|
||||
/** 将主题定制器恢复到默认设置。 */
|
||||
async function resetSettings() {
|
||||
const glassParameters = getGlassOpticalPresetParameters('clear', defaultGlassQuality, 'natural')
|
||||
|
||||
await updateSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassQuality: defaultGlassQuality,
|
||||
glassReflectionStrength: glassParameters.reflection,
|
||||
glassTransmissionStrength: glassParameters.transmission,
|
||||
glassTranslationStrength: glassParameters.translation,
|
||||
glassTransparencyStrength: glassParameters.transparency,
|
||||
layout: 'vertical',
|
||||
primaryColor: defaultPrimaryColor,
|
||||
radius: 'default',
|
||||
@@ -525,7 +732,14 @@ export function useThemeCustomizer() {
|
||||
isCustomized: computed(() => !isDefaultThemeCustomizerSettings(settings.value)),
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassDeformationStrength,
|
||||
setGlassFlowStrength,
|
||||
setGlassPreset,
|
||||
setGlassQuality,
|
||||
setGlassReflectionStrength,
|
||||
setGlassTransmissionStrength,
|
||||
setGlassTranslationStrength,
|
||||
setGlassTransparencyStrength,
|
||||
setLayout,
|
||||
setPrimaryColor,
|
||||
setRadius,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import DefaultLayout from './default/components/DefaultLayout.vue'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
|
||||
const route = useRoute()
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
|
||||
// keep-alive 缓存按页面身份命中,避免 query 变化导致同一页面反复新建实例。
|
||||
const routeCacheKey = computed(() => {
|
||||
@@ -16,6 +18,7 @@ const routeCacheKey = computed(() => {
|
||||
// 页面过渡按实际页面身份触发;keep-alive 页面避免 query 变化时反复入场。
|
||||
const routeTransitionKey = computed(() => (route.meta.keepAlive ? routeCacheKey.value : route.fullPath))
|
||||
const isPageEntering = ref(false)
|
||||
const pageRouteRef = ref<HTMLElement | null>(null)
|
||||
let pageMotionTimer: number | null = null
|
||||
let pageMotionFrame: number | null = null
|
||||
|
||||
@@ -32,9 +35,11 @@ function playPageEnterMotion() {
|
||||
}
|
||||
|
||||
isPageEntering.value = false
|
||||
if (pagePresentationMotion.start(routeTransitionKey.value, pageRouteRef.value)) return
|
||||
|
||||
pageMotionFrame = window.requestAnimationFrame(() => {
|
||||
isPageEntering.value = true
|
||||
pageMotionFrame = null
|
||||
isPageEntering.value = true
|
||||
pageMotionTimer = window.setTimeout(() => {
|
||||
isPageEntering.value = false
|
||||
pageMotionTimer = null
|
||||
@@ -49,13 +54,14 @@ onMounted(playPageEnterMotion)
|
||||
onBeforeUnmount(() => {
|
||||
if (pageMotionTimer) window.clearTimeout(pageMotionTimer)
|
||||
if (pageMotionFrame) window.cancelAnimationFrame(pageMotionFrame)
|
||||
pagePresentationMotion.cancel()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DefaultLayout>
|
||||
<router-view v-slot="{ Component }">
|
||||
<div class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||
<div ref="pageRouteRef" class="mp-page-route" :class="{ 'mp-page-route--entering': isPageEntering }">
|
||||
<keep-alive :max="24">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" :key="routeCacheKey" />
|
||||
</keep-alive>
|
||||
|
||||
@@ -156,8 +156,10 @@ const registerDynamicHeaderTab = (tab: DynamicHeaderTab) => {
|
||||
applyPendingHorizontalTab()
|
||||
}
|
||||
|
||||
/** 取消当前页面注册的动态标签页。 */
|
||||
const unregisterDynamicHeaderTab = () => {
|
||||
/** 仅注销仍由指定路由持有的动态标签,避免相邻页面生命周期互相覆盖。 */
|
||||
const unregisterDynamicHeaderTab = (routePath?: string) => {
|
||||
if (routePath && dynamicHeaderTab.value?.routePath !== routePath) return
|
||||
|
||||
dynamicHeaderTab.value = null
|
||||
}
|
||||
|
||||
@@ -844,5 +846,4 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -327,6 +327,17 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// 底部导航挂载在 body 上,而主题定制器与 Agent 面板受 .v-application 的层叠上下文限制,
|
||||
// 无法仅靠 z-index 压过它。移动端两者都是全屏面板,打开时直接隐藏底部导航。
|
||||
html[data-theme-customizer-open='true'],
|
||||
html[data-agent-assistant-open='true'] {
|
||||
.footer-nav-container {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -156,6 +156,26 @@ export default {
|
||||
glassQualityCss: 'Standard',
|
||||
glassQualityBalanced: 'Balanced',
|
||||
glassQualityHigh: 'High',
|
||||
glassQualityCssHint:
|
||||
'Static CSS material with the lowest resource use; clarity, transmission, and reflection remain available.',
|
||||
glassQualityBalancedHint: 'Shared live refraction that balances liquid feedback and GPU use.',
|
||||
glassQualityHighHint: 'Full temporal flow, diffusion detail, and content protection at a higher GPU cost.',
|
||||
glassPreset: 'Preset',
|
||||
glassPresetNatural: 'Natural',
|
||||
glassPresetGlide: 'Glide',
|
||||
glassPresetLiquid: 'Liquid',
|
||||
glassMaterialTuning: 'Material',
|
||||
glassDynamicTuning: 'Motion',
|
||||
glassTranslationStrength: 'Sample Translation',
|
||||
glassDeformationStrength: 'Deformation',
|
||||
glassFlowStrength: 'Flow Strength',
|
||||
glassReflectionStrength: 'Reflection Brightness',
|
||||
glassTransmissionStrength: 'Transmission Brightness',
|
||||
glassTransparencyStrength: 'Clarity',
|
||||
glassOpticalStrengthHint:
|
||||
'Sample Translation moves the shared wallpaper. Deformation controls local bending. Flow Strength controls trail range and inertia.',
|
||||
glassOpticalStrengthUnavailableHint:
|
||||
'Standard quality keeps all three material controls. Switch to Balanced or High to adjust sample translation, deformation, and flow.',
|
||||
purple: 'Purple',
|
||||
custom: 'Custom Style',
|
||||
transparency: 'Transparency',
|
||||
@@ -1001,6 +1021,11 @@ export default {
|
||||
library: 'My Media Library',
|
||||
playing: 'Continue Watching',
|
||||
latest: 'Latest Imports',
|
||||
noLatest: 'No recent imports',
|
||||
noPlaying: 'No continue-watching items',
|
||||
noLibrary: 'No media library data',
|
||||
mediaServerLoadFailed: 'Failed to load media server data',
|
||||
staleData: 'Refresh failed. Showing the last successful data.',
|
||||
recentImports: 'Recent Transfers',
|
||||
viewAll: 'View All',
|
||||
settings: 'Dashboard Settings',
|
||||
@@ -1263,6 +1288,8 @@ export default {
|
||||
searching: 'Searching, please wait...',
|
||||
noData: 'No Data',
|
||||
noResourceFound: 'No resources found',
|
||||
searchStreamTimeout: 'The search connection timed out. Please try again later.',
|
||||
searchStreamDisconnected: 'The search connection was interrupted. Please try again later.',
|
||||
aiRecommend: 'AI Recommendation',
|
||||
reRecommend: 'Regenerate Recommendation',
|
||||
aiRecommendError: 'AI Recommendation Failed',
|
||||
@@ -3153,6 +3180,9 @@ export default {
|
||||
copyFailed: 'Copy failed',
|
||||
addToQueue: 'Add to Organization Queue',
|
||||
reorganizeNow: 'Organize Now',
|
||||
reorganizeAgain: 'Reorganize',
|
||||
historyFound:
|
||||
'{count} successful organization record(s) found. Reorganizing removes the old target and history; move mode keeps the current source file.',
|
||||
auto: 'Auto',
|
||||
processing: 'Processing ...',
|
||||
successMessage: 'File {name} has been added to the organization queue!',
|
||||
@@ -3779,6 +3809,9 @@ export default {
|
||||
usernameHint: 'Login username',
|
||||
password: 'Password',
|
||||
passwordHint: 'Login password',
|
||||
accessCode: 'Access Code',
|
||||
accessCodeHint:
|
||||
'Access code enabled in fnOS Settings -> Security -> Access Settings. Leave empty if not enabled',
|
||||
syncLibraries: 'Sync Libraries',
|
||||
syncLibrariesHint: 'Only selected libraries will be synchronized',
|
||||
scanMode: 'Scan Mode',
|
||||
|
||||
@@ -154,6 +154,23 @@ export default {
|
||||
glassQualityCss: '标准',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高质量',
|
||||
glassQualityCssHint: '静态 CSS 材质,资源占用最低,保留通透、透射与反射,不启用实时流动。',
|
||||
glassQualityBalancedHint: '共享实时折射,优先平衡流动反馈与 GPU 占用。',
|
||||
glassQualityHighHint: '完整时序流场、扩散细节与内容保护,GPU 占用更高。',
|
||||
glassPreset: '方案',
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液态',
|
||||
glassMaterialTuning: '材质参数',
|
||||
glassDynamicTuning: '动态参数',
|
||||
glassTranslationStrength: '采样平移',
|
||||
glassDeformationStrength: '形变强度',
|
||||
glassFlowStrength: '流动强度',
|
||||
glassReflectionStrength: '反射亮度',
|
||||
glassTransmissionStrength: '透射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '采样平移控制整体滑移,形变强度控制局部弯曲,流动强度控制轨迹范围与惯性。',
|
||||
glassOpticalStrengthUnavailableHint: '标准质量保留三项材质参数。切换到均衡或高质量后,可调整采样平移、形变和流动。',
|
||||
purple: '幻紫',
|
||||
custom: '附加样式',
|
||||
transparency: '透明度',
|
||||
@@ -995,6 +1012,11 @@ export default {
|
||||
library: '我的媒体库',
|
||||
playing: '继续观看',
|
||||
latest: '最新入库',
|
||||
noLatest: '暂无最近入库记录',
|
||||
noPlaying: '暂无继续观看记录',
|
||||
noLibrary: '暂无媒体库数据',
|
||||
mediaServerLoadFailed: '媒体服务器数据加载失败',
|
||||
staleData: '刷新失败,当前显示上次数据',
|
||||
recentImports: '近期整理',
|
||||
viewAll: '查看全部',
|
||||
settings: '设置仪表板',
|
||||
@@ -1256,6 +1278,8 @@ export default {
|
||||
searching: '正在搜索,请稍候...',
|
||||
noData: '没有数据',
|
||||
noResourceFound: '未搜索到任何资源',
|
||||
searchStreamTimeout: '搜索连接长时间无响应,请稍后重试',
|
||||
searchStreamDisconnected: '搜索连接已中断,请稍后重试',
|
||||
aiRecommend: '智能推荐',
|
||||
reRecommend: '重新生成推荐',
|
||||
aiRecommendError: '智能推荐失败',
|
||||
@@ -3101,6 +3125,8 @@ export default {
|
||||
copyFailed: '复制失败',
|
||||
addToQueue: '加入整理队列',
|
||||
reorganizeNow: '立即整理',
|
||||
reorganizeAgain: '重新整理',
|
||||
historyFound: '检测到 {count} 条成功整理记录。重新整理会清理旧目标和历史记录;移动模式会保留当前源文件。',
|
||||
auto: '自动',
|
||||
processing: '正在处理 ...',
|
||||
successMessage: '文件 {name} 已加入整理队列!',
|
||||
@@ -3721,6 +3747,8 @@ export default {
|
||||
usernameHint: '登录用户名',
|
||||
password: '密码',
|
||||
passwordHint: '登录密码',
|
||||
accessCode: '访问码',
|
||||
accessCodeHint: '飞牛系统设置->安全性->访问设置中开启的访问码,未开启请留空',
|
||||
syncLibraries: '同步媒体库',
|
||||
syncLibrariesHint: '只有选中的媒体库才会被同步',
|
||||
scanMode: '扫描模式',
|
||||
|
||||
@@ -154,6 +154,23 @@ export default {
|
||||
glassQualityCss: '標準',
|
||||
glassQualityBalanced: '均衡',
|
||||
glassQualityHigh: '高品質',
|
||||
glassQualityCssHint: '靜態 CSS 材質,資源佔用最低,保留通透、透射與反射,不啟用即時流動。',
|
||||
glassQualityBalancedHint: '共享即時折射,優先平衡流動回饋與 GPU 佔用。',
|
||||
glassQualityHighHint: '完整時序流場、擴散細節與內容保護,GPU 佔用更高。',
|
||||
glassPreset: '方案',
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液態',
|
||||
glassMaterialTuning: '材質參數',
|
||||
glassDynamicTuning: '動態參數',
|
||||
glassTranslationStrength: '採樣平移',
|
||||
glassDeformationStrength: '形變強度',
|
||||
glassFlowStrength: '流動強度',
|
||||
glassReflectionStrength: '反射亮度',
|
||||
glassTransmissionStrength: '透射亮度',
|
||||
glassTransparencyStrength: '通透度',
|
||||
glassOpticalStrengthHint: '採樣平移控制整體滑移,形變強度控制局部彎曲,流動強度控制軌跡範圍與慣性。',
|
||||
glassOpticalStrengthUnavailableHint: '標準品質保留三項材質參數。切換到均衡或高品質後,可調整採樣平移、形變和流動。',
|
||||
purple: '幻紫',
|
||||
custom: '附加樣式',
|
||||
transparency: '透明度',
|
||||
@@ -995,6 +1012,11 @@ export default {
|
||||
library: '我的媒體庫',
|
||||
playing: '繼續觀看',
|
||||
latest: '最新入庫',
|
||||
noLatest: '暫無最近入庫記錄',
|
||||
noPlaying: '暫無繼續觀看記錄',
|
||||
noLibrary: '暫無媒體庫數據',
|
||||
mediaServerLoadFailed: '媒體伺服器數據加載失敗',
|
||||
staleData: '刷新失敗,當前顯示上次數據',
|
||||
recentImports: '近期整理',
|
||||
viewAll: '查看全部',
|
||||
settings: '設置儀表板',
|
||||
@@ -1254,6 +1276,8 @@ export default {
|
||||
searching: '正在搜索,請稍候...',
|
||||
noData: '沒有數據',
|
||||
noResourceFound: '未搜索到任何資源',
|
||||
searchStreamTimeout: '搜索連線長時間無回應,請稍後重試',
|
||||
searchStreamDisconnected: '搜索連線已中斷,請稍後重試',
|
||||
aiRecommend: '智能推薦',
|
||||
reRecommend: '重新生成推薦',
|
||||
aiRecommendError: '智能推薦失敗',
|
||||
@@ -3100,6 +3124,8 @@ export default {
|
||||
copyFailed: '複製失敗',
|
||||
addToQueue: '加入整理隊列',
|
||||
reorganizeNow: '立即整理',
|
||||
reorganizeAgain: '重新整理',
|
||||
historyFound: '偵測到 {count} 筆成功整理記錄。重新整理會清理舊目標和歷史記錄;移動模式會保留目前來源檔案。',
|
||||
auto: '自動',
|
||||
processing: '正在處理 ...',
|
||||
successMessage: '文件 {name} 已加入整理隊列!',
|
||||
@@ -3723,6 +3749,8 @@ export default {
|
||||
usernameHint: '登錄用戶名',
|
||||
password: '密碼',
|
||||
passwordHint: '登錄密碼',
|
||||
accessCode: '訪問碼',
|
||||
accessCodeHint: '飛牛系統設定->安全性->訪問設定中開啟的訪問碼,未開啟請留空',
|
||||
syncLibraries: '同步媒體庫',
|
||||
syncLibrariesHint: '只有選中的媒體庫才會被同步',
|
||||
scanMode: '掃描模式',
|
||||
|
||||
@@ -44,6 +44,7 @@ const mocks = vi.hoisted(() => {
|
||||
grid,
|
||||
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
|
||||
openSharedDialog: vi.fn(),
|
||||
themeName: undefined as unknown as { value: string },
|
||||
useDynamicButton: vi.fn(),
|
||||
}
|
||||
})
|
||||
@@ -76,10 +77,12 @@ vi.mock('@/api', () => ({
|
||||
vi.mock('vuetify', async importOriginal => {
|
||||
const { ref } = await import('vue')
|
||||
mocks.displayWidth = ref(1512)
|
||||
mocks.themeName = ref('light')
|
||||
|
||||
return {
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ width: mocks.displayWidth }),
|
||||
useTheme: () => ({ global: { name: mocks.themeName } }),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -184,6 +187,7 @@ describe('dashboard page initial layout', () => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.displayWidth.value = 1512
|
||||
mocks.themeName.value = 'light'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
})
|
||||
|
||||
@@ -210,9 +214,9 @@ describe('dashboard page initial layout', () => {
|
||||
|
||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(1)
|
||||
expect(screen.getByTestId('dashboard-item')).toHaveAttribute('data-dashboard-id', 'systemInfo')
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
|
||||
expect(container.querySelector('.dashboard-grid')).not.toHaveClass('is-revealed')
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
|
||||
await waitFor(() => expect(container.querySelector('.dashboard-grid')).toHaveClass('is-revealed'))
|
||||
|
||||
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||
remoteProfile.resolve({
|
||||
@@ -228,6 +232,26 @@ describe('dashboard page initial layout', () => {
|
||||
await waitFor(() => expect(mocks.grid.load).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables automatic grid transitions only while browsing with the glass theme', async () => {
|
||||
mocks.themeName.value = 'glass'
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === '/user/config/DashboardOrder' || url === '/user/config/DashboardGridLayout') {
|
||||
return { data: {} }
|
||||
}
|
||||
if (url === '/user/config/Dashboard') return { data: {} }
|
||||
if (url === '/plugin/dashboard/meta') return []
|
||||
throw new Error('Unexpected GET ' + url)
|
||||
})
|
||||
|
||||
await renderDashboard()
|
||||
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false))
|
||||
|
||||
await fireEvent.click(document.querySelector('.compact-fab--primary') as HTMLElement)
|
||||
await waitFor(() => expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true))
|
||||
})
|
||||
|
||||
it('keeps the upstream progressive default while an uncached remote profile is loading', async () => {
|
||||
const remoteOrder = deferred<unknown>()
|
||||
const remoteProfile = deferred<unknown>()
|
||||
@@ -241,7 +265,7 @@ describe('dashboard page initial layout', () => {
|
||||
await renderDashboard()
|
||||
|
||||
expect(screen.getAllByTestId('dashboard-item')).toHaveLength(10)
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: false }), expect.any(HTMLElement))
|
||||
expect(mocks.gridInit).toHaveBeenCalledWith(expect.objectContaining({ animate: true }), expect.any(HTMLElement))
|
||||
|
||||
remoteOrder.resolve({ data: { value: [{ id: 'systemInfo', key: '' }] } })
|
||||
remoteProfile.resolve({
|
||||
@@ -746,7 +770,7 @@ describe('dashboard page initial layout', () => {
|
||||
await waitFor(() => expect(mocks.grid.column).toHaveBeenCalledWith(1, 'list'))
|
||||
await waitFor(() => expect(mocks.grid.removeAll).toHaveBeenCalledTimes(1))
|
||||
await waitFor(() => expect(mocks.grid.makeWidget).toHaveBeenCalledTimes(10))
|
||||
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(false)
|
||||
expect(mocks.grid.setAnimation).toHaveBeenCalledWith(true)
|
||||
expect(mocks.grid.makeWidget.mock.calls.map(([, widget]) => widget.id)).toEqual([
|
||||
'storage',
|
||||
'mediaStatistic',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||
@@ -24,6 +24,7 @@ const { t } = useI18n()
|
||||
// PWA模式检测
|
||||
const { appMode } = usePWA()
|
||||
const display = useDisplay()
|
||||
const vuetifyTheme = useTheme()
|
||||
const userStore = useUserStore()
|
||||
const userPermissionContext = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
|
||||
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
||||
@@ -97,9 +98,6 @@ interface DashboardGridItem {
|
||||
// 是否处于仪表板布局编辑模式
|
||||
const isLayoutEditing = ref(false)
|
||||
|
||||
// 首次布局完成后触发轻量整体入场,不延迟或隐藏渐进渲染内容。
|
||||
const isDashboardGridRevealed = ref(false)
|
||||
|
||||
// 是否发送请求的总开关
|
||||
const isRequest = ref(true)
|
||||
|
||||
@@ -148,7 +146,6 @@ let dashboardGridContentObserver: ResizeObserver | null = null
|
||||
let dashboardGridContentResizeFrame: number | null = null
|
||||
let dashboardGridResizeRefreshFrame: number | null = null
|
||||
let dashboardGridAnimationFrame: number | null = null
|
||||
let dashboardGridEntranceFrame: number | null = null
|
||||
let dashboardRevealFrame: number | null = null
|
||||
let isDashboardRevealPending = false
|
||||
let dashboardProfileSaveQueue = Promise.resolve()
|
||||
@@ -389,16 +386,6 @@ function scheduleDashboardReveal() {
|
||||
})
|
||||
}
|
||||
|
||||
// 首次 GridStack 坐标提交后的下一帧立即入场,不等待卡片内部异步数据。
|
||||
function scheduleDashboardGridEntrance() {
|
||||
if (isDashboardGridRevealed.value || dashboardGridEntranceFrame !== null || typeof window === 'undefined') return
|
||||
|
||||
dashboardGridEntranceFrame = requestAnimationFrame(() => {
|
||||
dashboardGridEntranceFrame = null
|
||||
isDashboardGridRevealed.value = true
|
||||
})
|
||||
}
|
||||
|
||||
// 程序化批量布局不播放中间态;稳定后恢复用户拖拽、缩放和让位动画。
|
||||
function pauseDashboardGridAnimation() {
|
||||
if (dashboardGridAnimationFrame !== null) {
|
||||
@@ -408,15 +395,21 @@ function pauseDashboardGridAnimation() {
|
||||
dashboardGrid.value?.setAnimation(false)
|
||||
}
|
||||
|
||||
// 动画恢复延后一帧,确保 GridStack 已提交最终坐标后才重新响应交互。
|
||||
// 玻璃浏览态直接提交自动布局,普通主题和显式编辑交互保留 GridStack 过渡。
|
||||
function shouldAnimateDashboardGrid(editable = isLayoutEditing.value) {
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
|
||||
return !reduceMotion && (editable || vuetifyTheme.global.name.value !== 'glass')
|
||||
}
|
||||
|
||||
// 批量布局同步期间暂停几何过渡,稳定后恢复浏览与编辑状态的卡片动画。
|
||||
function scheduleDashboardGridAnimationResume() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (dashboardGridAnimationFrame !== null) cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||
|
||||
dashboardGridAnimationFrame = requestAnimationFrame(() => {
|
||||
dashboardGridAnimationFrame = null
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false
|
||||
dashboardGrid.value?.setAnimation(!reduceMotion)
|
||||
dashboardGrid.value?.setAnimation(shouldAnimateDashboardGrid())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1192,7 +1185,7 @@ function initializeDashboardGrid() {
|
||||
|
||||
dashboardGrid.value = GridStack.init(
|
||||
{
|
||||
animate: false,
|
||||
animate: shouldAnimateDashboardGrid(),
|
||||
cellHeight: DASHBOARD_GRID_CELL_HEIGHT,
|
||||
column: getDashboardGridColumnsForProfile(dashboardLayoutProfile.value),
|
||||
draggable: {
|
||||
@@ -1222,6 +1215,7 @@ function updateDashboardGridEditableState(editable: boolean) {
|
||||
if (!dashboardGrid.value) return
|
||||
|
||||
dashboardGrid.value.setStatic(!editable)
|
||||
dashboardGrid.value.setAnimation(shouldAnimateDashboardGrid(editable))
|
||||
if (editable) {
|
||||
dashboardGrid.value.enableMove(true)
|
||||
dashboardGrid.value.enableResize(true)
|
||||
@@ -1306,7 +1300,6 @@ async function syncDashboardGrid(resumeAnimation = true) {
|
||||
resizeAutoDashboardItemsToContent()
|
||||
scheduleDashboardReveal()
|
||||
})
|
||||
scheduleDashboardGridEntrance()
|
||||
} finally {
|
||||
isSyncingDashboardGrid.value = false
|
||||
if (resumeAnimation) scheduleDashboardGridAnimationResume()
|
||||
@@ -1547,6 +1540,11 @@ watch(isLayoutEditing, value => {
|
||||
updateDashboardGridEditableState(value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => vuetifyTheme.global.name.value,
|
||||
() => updateDashboardGridEditableState(isLayoutEditing.value),
|
||||
)
|
||||
|
||||
watch(
|
||||
dashboardGridItems,
|
||||
() => {
|
||||
@@ -1664,10 +1662,6 @@ onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(dashboardGridAnimationFrame)
|
||||
dashboardGridAnimationFrame = null
|
||||
}
|
||||
if (dashboardGridEntranceFrame !== null) {
|
||||
cancelAnimationFrame(dashboardGridEntranceFrame)
|
||||
dashboardGridEntranceFrame = null
|
||||
}
|
||||
if (dashboardRevealFrame !== null) {
|
||||
cancelAnimationFrame(dashboardRevealFrame)
|
||||
dashboardRevealFrame = null
|
||||
@@ -1685,7 +1679,7 @@ onBeforeUnmount(() => {
|
||||
<div
|
||||
ref="dashboardGridRef"
|
||||
class="grid-stack dashboard-grid"
|
||||
:class="{ 'is-editing': isLayoutEditing, 'is-revealed': isDashboardGridRevealed }"
|
||||
:class="{ 'is-editing': isLayoutEditing }"
|
||||
>
|
||||
<div
|
||||
v-for="gridItem in dashboardGridItems"
|
||||
@@ -1753,17 +1747,7 @@ onBeforeUnmount(() => {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.dashboard-grid {
|
||||
opacity: 0.92;
|
||||
pointer-events: auto;
|
||||
transform: translateY(4px);
|
||||
transition:
|
||||
opacity 0.18s ease-out,
|
||||
transform 0.18s ease-out;
|
||||
}
|
||||
|
||||
.dashboard-grid.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dashboard-grid :deep(.v-card) {
|
||||
@@ -1893,11 +1877,4 @@ onBeforeUnmount(() => {
|
||||
inset-inline-end: -4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-grid {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -237,39 +237,29 @@ onActivated(async () => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem value="themoviedb">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<TheMovieDbView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<TheMovieDbView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="douban">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<DoubanView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<DoubanView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="bangumi">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<BangumiView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<BangumiView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="anilist">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<AniListView />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<AniListView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<ExtraSourceView :source="item" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<ExtraSourceView :source="item" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
<!-- 快速滚动到顶部按钮 -->
|
||||
|
||||
@@ -26,6 +26,10 @@ const downloaderItems = computed(() => {
|
||||
|
||||
// 使用动态标签页
|
||||
const { registerHeaderTab } = useDynamicHeaderTab()
|
||||
registerHeaderTab({
|
||||
items: downloaderItems,
|
||||
modelValue: activeTab,
|
||||
})
|
||||
|
||||
// 调用API查询下载器设置
|
||||
async function loadDownloaderSetting() {
|
||||
@@ -38,24 +42,12 @@ async function loadDownloaderSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 注册动态标签页
|
||||
const registerTabs = () => {
|
||||
if (downloaderItems.value.length > 0) {
|
||||
registerHeaderTab({
|
||||
items: downloaderItems,
|
||||
modelValue: activeTab,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDownloaderSetting()
|
||||
registerTabs()
|
||||
})
|
||||
|
||||
useKeepAliveRefresh(async () => {
|
||||
await loadDownloaderSetting()
|
||||
registerTabs()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,11 +55,9 @@ useKeepAliveRefresh(async () => {
|
||||
<div v-if="downloaders.length > 0">
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in downloaders" :key="item.name" :value="item.name">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<DownloadingListView :name="item.name" :active="activeTab === item.name" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
@@ -3,5 +3,7 @@ import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileBrowserView />
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<FileBrowserView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<TransferHistoryView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { Component } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
import api from '@/api'
|
||||
@@ -15,8 +16,8 @@ import { buildUserPermissionContext, filterMenusByPermission } from '@/utils/per
|
||||
import type { ApiResponse } from '@/api/types'
|
||||
import { loadRemoteComponentFromModule, type RemoteModule } from '@/utils/federationLoader'
|
||||
import type { MfaMethod } from '@/types/auth'
|
||||
import { getLoginVisualProfile } from '@/utils/loginPresentation'
|
||||
|
||||
const loginRootRef = ref<HTMLElement | null>(null)
|
||||
type LabTapTarget = 'logo' | 'title'
|
||||
|
||||
const LAB_TAP_COUNT = 5
|
||||
@@ -25,10 +26,8 @@ const labTapSequences: Record<LabTapTarget, { count: number; startedAt: number }
|
||||
logo: { count: 0, startedAt: 0 },
|
||||
title: { count: 0, startedAt: 0 },
|
||||
}
|
||||
let cardLightFrame: number | null = null
|
||||
let pendingCardLightX = 0.5
|
||||
let pendingCardLightY = 0
|
||||
let pendingCardLightEnergy = 0
|
||||
const { global: loginTheme } = useTheme()
|
||||
const loginVisualProfile = computed(() => getLoginVisualProfile(loginTheme.name.value))
|
||||
|
||||
/** 在指定区域连续点击五次时进入隐藏的 Logo 实验室。 */
|
||||
function handleLabTap(target: LabTapTarget) {
|
||||
@@ -53,40 +52,6 @@ function handleLabTap(target: LabTapTarget) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片顶部反射与指针共用光源位置,避免通过响应式状态触发页面重渲染。 */
|
||||
function renderCardLight() {
|
||||
cardLightFrame = null
|
||||
const root = loginRootRef.value
|
||||
root?.style.setProperty('--login-card-light-x', `${(pendingCardLightX * 100).toFixed(2)}%`)
|
||||
root?.style.setProperty('--login-card-light-y', `${(pendingCardLightY * 100).toFixed(2)}%`)
|
||||
root?.style.setProperty('--login-card-highlight-alpha', (0.035 + pendingCardLightEnergy * 0.08).toFixed(3))
|
||||
root?.style.setProperty('--login-card-primary-alpha', (0.018 + pendingCardLightEnergy * 0.04).toFixed(3))
|
||||
root?.style.setProperty('--login-card-top-prism-alpha', (0.2 + pendingCardLightEnergy * 0.32).toFixed(3))
|
||||
root?.style.setProperty('--login-refraction-shift-x', `${((0.5 - pendingCardLightX) * 8).toFixed(2)}px`)
|
||||
root?.style.setProperty('--login-refraction-shift-y', `${((0.5 - pendingCardLightY) * 6).toFixed(2)}px`)
|
||||
}
|
||||
|
||||
/** 根据指针在登录卡片中的位置更新光照目标。 */
|
||||
function handlePointerLight(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return
|
||||
const bounds = loginRootRef.value?.querySelector<HTMLElement>('.login-card')?.getBoundingClientRect()
|
||||
if (!bounds?.width || !bounds.height) return
|
||||
|
||||
pendingCardLightX = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width))
|
||||
pendingCardLightY = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height))
|
||||
const centerDistance = Math.hypot(pendingCardLightX - 0.5, pendingCardLightY - 0.5)
|
||||
pendingCardLightEnergy = Math.max(0.16, 1 - centerDistance * 1.2)
|
||||
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
|
||||
}
|
||||
|
||||
/** 指针离开登录页后恢复卡片的默认光照位置。 */
|
||||
function resetPointerLight() {
|
||||
pendingCardLightX = 0.5
|
||||
pendingCardLightY = 0
|
||||
pendingCardLightEnergy = 0
|
||||
if (cardLightFrame === null) cardLightFrame = window.requestAnimationFrame(renderCardLight)
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t, te } = useI18n()
|
||||
|
||||
@@ -766,8 +731,6 @@ async function initConditionalPasskey() {
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
if (cardLightFrame !== null) window.cancelAnimationFrame(cardLightFrame)
|
||||
|
||||
if (conditionalAbortController) {
|
||||
conditionalAbortController.abort()
|
||||
conditionalAbortController = null
|
||||
@@ -781,30 +744,20 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<!-- 登录页面容器 -->
|
||||
<div ref="loginRootRef" class="login-root" @pointermove="handlePointerLight" @pointerleave="resetPointerLight">
|
||||
<!-- SVG 位移只处理卡片内重复绘制的壁纸,不读取跨域图片像素。 -->
|
||||
<svg class="login-liquid-filter-defs" aria-hidden="true" focusable="false">
|
||||
<div class="login-root" :data-login-visual-profile="loginVisualProfile">
|
||||
<svg class="login-glass-filter-defs" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id="login-liquid-refraction-clear"
|
||||
x="-8%"
|
||||
y="-8%"
|
||||
width="116%"
|
||||
height="116%"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.009 0.014" numOctaves="2" seed="7" result="noise" />
|
||||
<feGaussianBlur in="noise" stdDeviation="1.2" result="soft-noise" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="soft-noise" scale="24" xChannelSelector="R" yChannelSelector="G" />
|
||||
<filter id="login-glass-static-refraction" x="-12%" y="-12%" width="124%" height="124%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.008 0.014" numOctaves="2" seed="8" result="noise" />
|
||||
<feGaussianBlur in="noise" stdDeviation="0.35" result="softNoise" />
|
||||
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="11" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- 装饰性背景光晕 -->
|
||||
<div class="login-bg-decor" aria-hidden="true">
|
||||
<div class="login-orb login-orb--1" />
|
||||
<div class="login-orb login-orb--2" />
|
||||
<div class="login-orb login-orb--3" />
|
||||
<!-- 经典主题保留一层低频品牌环境光;透明与玻璃 profile 不挂载该装饰。 -->
|
||||
<div v-if="loginVisualProfile === 'classic'" class="login-ambient-light" aria-hidden="true">
|
||||
<span class="login-ambient-light__wash" />
|
||||
</div>
|
||||
|
||||
<!-- 顶部漂浮语言切换 -->
|
||||
@@ -841,10 +794,7 @@ onUnmounted(() => {
|
||||
max-width="24rem"
|
||||
flat
|
||||
>
|
||||
<div class="login-card__glass" aria-hidden="true">
|
||||
<span class="login-card__wallpaper-refraction" />
|
||||
<span class="login-card__glass-caustic" />
|
||||
</div>
|
||||
<div class="login-card__surface" aria-hidden="true" />
|
||||
|
||||
<!-- 卡片头部:Logo + 标题 + 欢迎语 -->
|
||||
<div class="login-head">
|
||||
@@ -1023,34 +973,10 @@ onUnmounted(() => {
|
||||
|
||||
/* ===================== 布局根容器 ===================== */
|
||||
.login-root {
|
||||
--login-card-light-x: 50%;
|
||||
--login-card-light-y: 0%;
|
||||
--login-card-highlight-alpha: 0.035;
|
||||
--login-card-primary-alpha: 0.018;
|
||||
--login-card-top-prism-alpha: 0.2;
|
||||
--login-refraction-shift-x: 0px;
|
||||
--login-refraction-shift-y: 3px;
|
||||
--optical-glass-x: 50%;
|
||||
--optical-glass-y: 22%;
|
||||
--optical-glass-blur: 24px;
|
||||
--optical-glass-saturate: 146%;
|
||||
--optical-glass-contrast: 104%;
|
||||
--optical-glass-glow-opacity: 0.36;
|
||||
--optical-glass-caustic-opacity: 0.28;
|
||||
--optical-glass-caustic-scale: 0.9;
|
||||
--optical-glass-pulse-blur: 14px;
|
||||
--optical-glass-shift-x: 0px;
|
||||
--optical-glass-shift-y: 0px;
|
||||
--optical-glass-rotation: -5deg;
|
||||
--optical-glass-scale: 1;
|
||||
--optical-glass-caustic-highlight-alpha: 0.16;
|
||||
--optical-glass-caustic-primary-alpha: 0.12;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1060,15 +986,8 @@ onUnmounted(() => {
|
||||
padding-block: calc(env(safe-area-inset-top, 0px) + 24px) calc(env(safe-area-inset-bottom, 0px) + 24px);
|
||||
}
|
||||
|
||||
.login-liquid-filter-defs {
|
||||
position: absolute;
|
||||
block-size: 0;
|
||||
inline-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===================== 装饰性背景光晕 ===================== */
|
||||
.login-bg-decor {
|
||||
/* 经典 profile 只保留一层覆盖视口的低频品牌环境光。 */
|
||||
.login-ambient-light {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
@@ -1076,69 +995,20 @@ onUnmounted(() => {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
.login-ambient-light__wash {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
will-change: transform;
|
||||
animation: ambient-drift 18s ease-in-out infinite alternate;
|
||||
background:
|
||||
radial-gradient(ellipse at 78% 18%, rgba(var(--v-theme-primary), 0.18), transparent 48%),
|
||||
linear-gradient(145deg, transparent 28%, rgba(var(--v-theme-primary), 0.07) 72%, transparent);
|
||||
filter: blur(40px);
|
||||
inset: -12%;
|
||||
transform: translate3d(1.5%, -1%, 0) scale(1.04);
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
animation: orb-float-1 12s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.35);
|
||||
block-size: 360px;
|
||||
filter: blur(60px);
|
||||
inline-size: 360px;
|
||||
inset-block-start: -15%;
|
||||
inset-inline-end: -12%;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
animation: orb-float-2 15s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.25);
|
||||
block-size: 300px;
|
||||
filter: blur(55px);
|
||||
inline-size: 300px;
|
||||
inset-block-end: -10%;
|
||||
inset-inline-start: -15%;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
animation: orb-float-3 10s ease-in-out infinite alternate;
|
||||
background: rgba(var(--v-theme-primary), 0.15);
|
||||
block-size: 220px;
|
||||
filter: blur(50px);
|
||||
inline-size: 220px;
|
||||
inset-block-start: 50%;
|
||||
inset-inline-end: 15%;
|
||||
}
|
||||
|
||||
@keyframes orb-float-1 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-30px, 40px) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-2 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(25px, -30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes orb-float-3 {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-20px, 20px) scale(0.92);
|
||||
@keyframes ambient-drift {
|
||||
to {
|
||||
transform: translate3d(-1.5%, 1%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,6 +1042,14 @@ onUnmounted(() => {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.login-glass-filter-defs {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
block-size: 0;
|
||||
inline-size: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===================== 玻璃卡片 ===================== */
|
||||
.login-card {
|
||||
position: relative;
|
||||
@@ -1181,35 +1059,28 @@ onUnmounted(() => {
|
||||
border-radius: var(--app-surface-radius, 20px) !important;
|
||||
box-shadow: 0 20px 54px rgba(var(--app-shadow-rgb, 0, 0, 0), 0.12) !important;
|
||||
|
||||
> :not(.login-card__glass) {
|
||||
> :not(.login-card__surface) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 顶部迎光棱镜保持单方向,不随指针另建一套光源。 */
|
||||
&::before {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(255, 255, 255, 0.92), rgba(232, 219, 255, 0.48) 28%, transparent 72%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(117, 212, 255, 0.16) 30%,
|
||||
rgba(177, 139, 255, 0.22) 50%,
|
||||
rgba(255, 105, 210, 0.12) 70%,
|
||||
transparent
|
||||
);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.42) 32%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
transparent
|
||||
);
|
||||
block-size: 1px;
|
||||
content: '';
|
||||
filter: drop-shadow(0 1px 3px rgba(var(--v-theme-primary), 0.16));
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: clamp(44px, var(--login-card-light-x), calc(100% - 44px));
|
||||
inline-size: 88px;
|
||||
mix-blend-mode: screen;
|
||||
opacity: var(--login-card-top-prism-alpha);
|
||||
inset-inline: 13% 38%;
|
||||
opacity: 0.34;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,76 +1090,38 @@ onUnmounted(() => {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.login-card__glass {
|
||||
.login-card__surface {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
|
||||
contrast(var(--optical-glass-contrast));
|
||||
background:
|
||||
radial-gradient(
|
||||
180px 150px at var(--login-card-light-x) var(--login-card-light-y),
|
||||
rgba(255, 255, 255, var(--login-card-highlight-alpha)),
|
||||
rgba(var(--v-theme-primary), var(--login-card-primary-alpha)) 44%,
|
||||
transparent 76%
|
||||
),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.login-card__glass::before {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
border-radius: 44% 56% 61% 39% / 42% 39% 61% 58%;
|
||||
.login-root[data-login-visual-profile='classic'] .login-card__surface {
|
||||
backdrop-filter: blur(22px) saturate(118%);
|
||||
background: rgba(var(--v-theme-surface), 0.76);
|
||||
}
|
||||
|
||||
.login-root[data-login-visual-profile='transparent'] .login-card__surface {
|
||||
backdrop-filter: blur(var(--optical-glass-blur)) saturate(var(--optical-glass-saturate))
|
||||
contrast(var(--optical-glass-contrast));
|
||||
background:
|
||||
radial-gradient(circle at 36% 32%, rgba(255, 255, 255, 0.2), transparent 24%),
|
||||
conic-gradient(
|
||||
from 218deg,
|
||||
transparent,
|
||||
rgba(var(--v-theme-primary), 0.13),
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 0.09),
|
||||
transparent 72%
|
||||
);
|
||||
content: '';
|
||||
filter: blur(var(--optical-glass-pulse-blur));
|
||||
inset: -28%;
|
||||
opacity: var(--optical-glass-glow-opacity);
|
||||
transform: translate(var(--optical-glass-shift-x), var(--optical-glass-shift-y)) rotate(var(--optical-glass-rotation))
|
||||
scale(var(--optical-glass-scale));
|
||||
transition: opacity 220ms ease;
|
||||
radial-gradient(
|
||||
180px 150px at 50% 0%,
|
||||
rgba(255, 255, 255, var(--login-card-highlight-alpha, 0.035)),
|
||||
rgba(var(--v-theme-primary), var(--login-card-primary-alpha, 0.02)) 44%,
|
||||
transparent 76%
|
||||
),
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.075), transparent 34%), rgba(var(--v-theme-surface), 0.7);
|
||||
}
|
||||
|
||||
.login-card__wallpaper-refraction {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
display: none;
|
||||
inset: -2%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-card__glass-caustic {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
ellipse,
|
||||
rgba(255, 255, 255, var(--optical-glass-caustic-highlight-alpha)),
|
||||
rgba(var(--v-theme-primary), var(--optical-glass-caustic-primary-alpha)) 34%,
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(var(--optical-glass-pulse-blur));
|
||||
inset-block-start: calc(var(--optical-glass-y) - 88px);
|
||||
inset-inline-start: calc(var(--optical-glass-x) - 112px);
|
||||
block-size: 176px;
|
||||
inline-size: 224px;
|
||||
opacity: var(--optical-glass-caustic-opacity);
|
||||
pointer-events: none;
|
||||
transform: scale(var(--optical-glass-caustic-scale));
|
||||
.login-root[data-login-visual-profile='glass'] .login-card__surface {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.08), transparent 34%),
|
||||
linear-gradient(rgba(7, 14, 25, 0.04), rgba(7, 14, 25, 0.1));
|
||||
}
|
||||
|
||||
/* ===================== 卡片头部 ===================== */
|
||||
@@ -1500,11 +1333,9 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮:渐变 + 悬浮抬升 + 光泽 */
|
||||
/* 登录按钮使用克制的主题色层级,避免在壁纸上形成独立霓虹光源。 */
|
||||
.login-submit {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(var(--v-theme-primary), 0.35);
|
||||
box-shadow: 0 6px 18px rgba(var(--v-theme-primary), 0.2);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
transition:
|
||||
@@ -1512,40 +1343,16 @@ onUnmounted(() => {
|
||||
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 32px rgba(var(--v-theme-primary), 0.45);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 22px rgba(var(--v-theme-primary), 0.26);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.3);
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 3px 10px rgba(var(--v-theme-primary), 0.18);
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
/* 登录按钮内部光泽扫描层 */
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 35%,
|
||||
rgba(255, 255, 255, 30%) 43%,
|
||||
rgba(255, 255, 255, 40%) 50%,
|
||||
rgba(255, 255, 255, 30%) 57%,
|
||||
transparent 65%
|
||||
);
|
||||
content: '';
|
||||
inset-block: -50%;
|
||||
inset-inline: -50%;
|
||||
pointer-events: none;
|
||||
transform: translateX(-120%);
|
||||
transition: transform 700ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.login-submit:hover :deep(.v-btn__content)::after {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
|
||||
/* Passkey 按钮 */
|
||||
.passkey-btn {
|
||||
border-radius: 12px;
|
||||
@@ -1670,35 +1477,22 @@ onUnmounted(() => {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.login-submit :deep(.v-btn__content)::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-orb {
|
||||
.login-ambient-light__wash {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.login-card__glass::before,
|
||||
.login-card__glass-caustic {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.login-card__wallpaper-refraction {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-card__glass,
|
||||
.login-card__surface,
|
||||
.native-login-field {
|
||||
backdrop-filter: none !important;
|
||||
background: rgb(var(--v-theme-surface)) !important;
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
.login-card__glass {
|
||||
.login-card__surface {
|
||||
background: rgba(var(--v-theme-surface), 0.94);
|
||||
}
|
||||
|
||||
@@ -1708,11 +1502,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.login-card__wallpaper-refraction {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.login-card__glass,
|
||||
.login-card__surface,
|
||||
.native-login-field {
|
||||
background: rgba(var(--v-theme-surface), 0.96) !important;
|
||||
}
|
||||
@@ -1732,20 +1522,6 @@ onUnmounted(() => {
|
||||
padding: 1.5rem !important;
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
.login-orb--1 {
|
||||
block-size: 220px;
|
||||
inline-size: 220px;
|
||||
}
|
||||
|
||||
.login-orb--2 {
|
||||
block-size: 180px;
|
||||
inline-size: 180px;
|
||||
}
|
||||
|
||||
.login-orb--3 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 480px) and (height <= 600px) {
|
||||
|
||||
@@ -42,7 +42,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugin-app-page">
|
||||
<div class="plugin-app-page" data-glass-optical-mode="static-material">
|
||||
<VAlert v-if="loadError" type="error" class="ma-4" title="组件加载错误">
|
||||
无法加载插件全页组件。多入口时请暴露 AppPage 或 AppPage{Pascal}(见文档),并确认插件已启用。
|
||||
</VAlert>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { debounce } from 'lodash-es'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import api from '@/api'
|
||||
@@ -19,6 +18,7 @@ import { useToast } from 'vue-toastification'
|
||||
import { useKeepAliveRefresh } from '@/composables/useKeepAliveRefresh'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { SearchReplaceBatchCollector, isSearchReplaceBatchEvent } from '@/utils/searchStream'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -313,7 +313,8 @@ let searchStreamIdleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const streamPreviewLimit = 24
|
||||
const streamUiFlushDelay = 1000
|
||||
const streamPreviewBufferLimit = streamPreviewLimit * 4
|
||||
const searchStreamIdleTimeout = 90_000
|
||||
// 兼容尚未提供心跳的旧后端,同时保留异常半开连接的最终兜底。
|
||||
const searchStreamIdleTimeout = 5 * 60_000
|
||||
const searchStreamDoneCloseDelay = 1500
|
||||
|
||||
const streamTotalCount = ref(0)
|
||||
@@ -342,6 +343,8 @@ const showResultHeader = computed(() => isRefreshed.value && !progressActive.val
|
||||
|
||||
let pendingStreamItems: Array<Context> = []
|
||||
let pendingSubtitleStreamItems: Array<SubtitleInfo> = []
|
||||
const streamReplaceBatchCollector = new SearchReplaceBatchCollector<Context>()
|
||||
const subtitleReplaceBatchCollector = new SearchReplaceBatchCollector<SubtitleInfo>()
|
||||
let streamFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let streamFinalResultApplied = false
|
||||
let pendingProgressText: string | null = null
|
||||
@@ -393,21 +396,9 @@ function handleRemoveFilter(key: string, value: string) {
|
||||
torrentFilter.removeFilter(key, value)
|
||||
}
|
||||
|
||||
// 添加安全超时,确保进度条不会永远卡住
|
||||
const watchProgressValue = watch(
|
||||
progressValue,
|
||||
debounce(async () => {
|
||||
if (progressActive.value && progressValue.value < 100) {
|
||||
console.warn('卡进度超时 关闭进度条')
|
||||
stopLoadingProgress()
|
||||
}
|
||||
}, 60_000),
|
||||
)
|
||||
|
||||
// 使用SSE监听加载进度
|
||||
function startLoadingProgress() {
|
||||
clearProgressResetTimer()
|
||||
watchProgressValue.resume()
|
||||
progressText.value = t('resource.searching')
|
||||
progressValue.value = 0
|
||||
progressEnabled.value = true
|
||||
@@ -416,7 +407,6 @@ function startLoadingProgress() {
|
||||
|
||||
// 停止监听加载进度
|
||||
function stopLoadingProgress() {
|
||||
watchProgressValue.pause()
|
||||
progressActive.value = false
|
||||
|
||||
// 确保进度显示100%,然后再渐进清零
|
||||
@@ -429,6 +419,7 @@ function stopLoadingProgress() {
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 清除延迟归零任务,避免新搜索继承上一轮的进度重置。
|
||||
function clearProgressResetTimer() {
|
||||
if (progressResetTimer) {
|
||||
clearTimeout(progressResetTimer)
|
||||
@@ -451,6 +442,7 @@ function closeSearchEventSource(source?: EventSource) {
|
||||
clearSearchStreamIdleTimer()
|
||||
}
|
||||
|
||||
// 清除搜索流空闲计时器。
|
||||
function clearSearchStreamIdleTimer() {
|
||||
if (searchStreamIdleTimer) {
|
||||
clearTimeout(searchStreamIdleTimer)
|
||||
@@ -473,6 +465,8 @@ function clearStreamPreviewState(resetFinalState: boolean = false) {
|
||||
pendingProgressText = null
|
||||
pendingProgressValue = null
|
||||
pendingStreamTotalCount = null
|
||||
streamReplaceBatchCollector.reset()
|
||||
subtitleReplaceBatchCollector.reset()
|
||||
streamPreviewDataList.value = []
|
||||
streamPreviewSubtitleDataList.value = []
|
||||
if (resetFinalState) {
|
||||
@@ -514,6 +508,7 @@ function flushBufferedStreamState() {
|
||||
isRefreshed.value = true
|
||||
}
|
||||
|
||||
// 合并短时间内到达的预览和进度更新。
|
||||
function scheduleStreamFlush() {
|
||||
if (streamFlushTimer) return
|
||||
streamFlushTimer = setTimeout(() => {
|
||||
@@ -673,6 +668,7 @@ function appendSubtitleStreamResults(items: SubtitleInfo[]) {
|
||||
scheduleStreamFlush()
|
||||
}
|
||||
|
||||
// 完整最终结果到达后原子替换资源列表。
|
||||
function applyFinalStreamResults(items: Context[]) {
|
||||
streamFinalResultApplied = true
|
||||
flushBufferedStreamState()
|
||||
@@ -708,12 +704,27 @@ function getSubtitleItemKey(item: SubtitleInfo, index: number) {
|
||||
|
||||
// 处理搜索流消息
|
||||
function handleSearchStreamMessage(eventData: { [key: string]: any }) {
|
||||
if (eventData.type === 'heartbeat') return
|
||||
|
||||
if (eventData.type === 'error') {
|
||||
updateSearchProgress(eventData, true)
|
||||
errorDescription.value = eventData.message_i18n || eventData.message || t('resource.noResourceFound')
|
||||
return
|
||||
}
|
||||
|
||||
if (isSearchReplaceBatchEvent<Context | SubtitleInfo>(eventData)) {
|
||||
if (isSubtitleSearch.value) {
|
||||
const completedItems = subtitleReplaceBatchCollector.append(eventData)
|
||||
updateSearchProgress(eventData, completedItems !== null)
|
||||
if (completedItems) applyFinalSubtitleStreamResults(completedItems)
|
||||
} else {
|
||||
const completedItems = streamReplaceBatchCollector.append(eventData)
|
||||
updateSearchProgress(eventData, completedItems !== null)
|
||||
if (completedItems) applyFinalStreamResults(completedItems)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isSubtitleSearch.value) {
|
||||
const subtitleItems = Array.isArray(eventData.items) ? (eventData.items as SubtitleInfo[]) : []
|
||||
if (eventData.type === 'append') {
|
||||
@@ -834,7 +845,8 @@ function searchByStream(params: SearchParams, requestToken?: string) {
|
||||
const resetIdleTimeout = () => {
|
||||
clearSearchStreamIdleTimer()
|
||||
searchStreamIdleTimer = setTimeout(() => {
|
||||
settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
|
||||
errorDescription.value = t('resource.searchStreamTimeout')
|
||||
settleSearchStream(() => reject(new Error(errorDescription.value)))
|
||||
}, searchStreamIdleTimeout)
|
||||
}
|
||||
|
||||
@@ -874,7 +886,8 @@ function searchByStream(params: SearchParams, requestToken?: string) {
|
||||
return
|
||||
}
|
||||
|
||||
settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
|
||||
errorDescription.value = t('resource.searchStreamDisconnected')
|
||||
settleSearchStream(() => reject(new Error(errorDescription.value)))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -927,8 +940,14 @@ async function fetchData(options: { force?: boolean; params?: SearchParams; sile
|
||||
try {
|
||||
await searchByStream(currentSearchParams, requestToken)
|
||||
} catch (error) {
|
||||
const streamErrorMessage = error instanceof Error ? error.message : t('resource.searchStreamDisconnected')
|
||||
console.warn('渐进式搜索连接失败,回退到普通搜索:', error)
|
||||
await searchByRequest(currentSearchParams, requestToken)
|
||||
try {
|
||||
await searchByRequest(currentSearchParams, requestToken)
|
||||
} catch (fallbackError) {
|
||||
errorDescription.value = streamErrorMessage
|
||||
throw fallbackError
|
||||
}
|
||||
}
|
||||
stopLoadingProgress()
|
||||
// 搜索完成后移除地址栏参数,避免分享/刷新残留搜索条件
|
||||
@@ -1298,7 +1317,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-glass-optical-mode="static-material">
|
||||
<!-- 搜索加载状态 -->
|
||||
<VFadeTransition>
|
||||
<div
|
||||
|
||||
@@ -64,15 +64,9 @@ watch(activeTab, markTabVisited, { immediate: true })
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="settings-content-window disable-tab-transition" :touch="false">
|
||||
<VWindowItem v-for="item in settingTabComponents" :key="item.value" :value="item.value">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<component
|
||||
:is="item.component"
|
||||
v-if="visitedTabs.has(item.value)"
|
||||
:active="activeTab === item.value"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<component :is="item.component" v-if="visitedTabs.has(item.value)" :active="activeTab === item.value" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
</div>
|
||||
|
||||
@@ -342,9 +342,7 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
titleKey: subscribeBatchState.value.allSelected
|
||||
? 'subscribe.batchDeselectAll'
|
||||
: 'subscribe.batchSelectAll',
|
||||
titleKey: subscribeBatchState.value.allSelected ? 'subscribe.batchDeselectAll' : 'subscribe.batchSelectAll',
|
||||
icon: subscribeBatchState.value.allSelected ? 'mdi-checkbox-blank-outline' : 'mdi-checkbox-multiple-marked',
|
||||
permission: 'subscribe',
|
||||
disabled: subscribeBatchState.value.totalCount === 0,
|
||||
@@ -442,7 +440,9 @@ useDynamicButton({
|
||||
menuItems: subscribeDynamicMenuItems,
|
||||
permission: 'subscribe',
|
||||
show: computed(
|
||||
() => appMode.value && (subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
||||
() =>
|
||||
appMode.value &&
|
||||
(subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
|
||||
),
|
||||
})
|
||||
|
||||
@@ -519,37 +519,31 @@ onMounted(() => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||
<VWindowItem value="mysub">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeListView
|
||||
ref="subscribeListViewRef"
|
||||
:type="subType"
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
:sort-by="subscribeSortBy"
|
||||
:active="activeTab === 'mysub'"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
@update:sort-by="subscribeSortBy = $event"
|
||||
@batch-state-change="handleSubscribeBatchStateChange"
|
||||
/>
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribeListView
|
||||
ref="subscribeListViewRef"
|
||||
:type="subType"
|
||||
:subid="subId"
|
||||
:keyword="subscribeFilter"
|
||||
:status-filter="subscribeStatusFilter ?? ''"
|
||||
:sort-mode="subscribeSortMode"
|
||||
:sort-by="subscribeSortBy"
|
||||
:active="activeTab === 'mysub'"
|
||||
@update:sort-mode="subscribeSortMode = $event"
|
||||
@update:sort-by="subscribeSortBy = $event"
|
||||
@batch-state-change="handleSubscribeBatchStateChange"
|
||||
/>
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="popular">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribePopularView :type="subType" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribePopularView :type="subType" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<SubscribeShareView :keyword="shareKeyword" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<SubscribeShareView :keyword="shareKeyword" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
@@ -722,7 +716,6 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -107,18 +107,14 @@ onMounted(() => {
|
||||
<div>
|
||||
<VWindow v-model="activeTab" class="disable-tab-transition content-window" :touch="false">
|
||||
<VWindowItem value="list">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowListView ref="workflowListViewRef" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<WorkflowListView ref="workflowListViewRef" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="share">
|
||||
<transition name="fade-slide" appear>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||
</div>
|
||||
</transition>
|
||||
<div>
|
||||
<WorkflowShareView :keyword="shareKeyword" @update="refreshWorkflowList" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
|
||||
@@ -1338,6 +1338,22 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
}
|
||||
|
||||
// 插件卡片头部染色层:普通主题保持原有实色底+压暗遮罩,玻璃主题只替换色层与材质。
|
||||
.plugin-card__banner {
|
||||
position: relative;
|
||||
-webkit-backdrop-filter: var(--plugin-card-banner-backdrop-filter, none);
|
||||
backdrop-filter: var(--plugin-card-banner-backdrop-filter, none);
|
||||
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
||||
transition: background-image 0.2s ease;
|
||||
|
||||
--plugin-card-accent-rgb: 40, 169, 225;
|
||||
--plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
rgb(var(--plugin-card-accent-rgb)) 0%,
|
||||
rgb(var(--plugin-card-accent-rgb)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.grid-downloading-card {
|
||||
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
|
||||
}
|
||||
|
||||