Compare commits

..

25 Commits

Author SHA1 Message Date
jxxghp
8a3b99ae51 feat(pwa): 完善 iOS 启动与主题恢复 2026-07-29 10:06:14 +08:00
jxxghp
e5339d524b fix(cache): use fixed poster corner radius 2026-07-29 08:31:53 +08:00
jxxghp
e218508c2d fix(editor): Ace 输入框玻璃质感与圆角内边距对齐
- 玻璃主题下让 .ace_editor 与普通输入框一致的染色玻璃、描边、聚焦态
- 重命名格式编辑器改用全局圆角 token,并通过 renderer 补齐内边距

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:07:05 +08:00
jxxghp
e7bfc4ec1c fix(glass): 捷径卡片补齐玻璃描边与高光
弹层内嵌套卡片被通用折叠规则抹平 border 与 box-shadow,只补回背景会显得与其它玻璃卡片不一致,这里补齐完整表面并加上 hover 态。同时带上搜索弹窗的空状态间距与样式顺序整理。
2026-07-28 19:30:05 +08:00
jxxghp
5c63fe2313 feat(mediaserver): 飞牛影视增加访问码配置项
配置弹窗与初始化向导的飞牛表单增加访问码输入项,未开启时留空。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:09:17 +08:00
jxxghp
8d86bd6e92 feat(glass): 插件卡片改为染色玻璃
将插件卡片与市场卡片头部的实色背景改为变量驱动的染色层:
图标主色只作为透光色相,玻璃主题下叠加吸收遮罩与镜面高光,
并按 frosted/tinted 材质与透明度调整强度;文件夹卡片渐变底
同步降低不透明度让卡片本体玻璃透出。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 13:52:50 +08:00
InfinityPacer
8a4ca2482c fix(glass): stabilize optical rendering and page presentation (#595) 2026-07-28 10:41:41 +08:00
InfinityPacer
3daa031a04 fix(glass): stabilize optical surfaces and page transitions (#594) 2026-07-27 17:33:05 +08:00
jxxghp
15ef332ea2 fix(pwa): 面板打开时隐藏底部导航
底部导航 Teleport 到 body(z-index 1999),而主题定制器(2102)与
Agent 面板(2601)渲染在 VApp 内部。玻璃主题给 .v-application 设置了
position: relative + z-index: 1,形成层叠上下文,两个面板的数值被限制在
该层内部,无法压过 body 层的底部导航。

移动端这两个面板均为全屏,改为在根节点开关标记打开时隐藏底部导航。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 16:33:27 +08:00
InfinityPacer
57448ca609 fix(glass): load cross-origin wallpapers directly (#593) 2026-07-27 13:49:09 +08:00
InfinityPacer
0f7c95cfaf fix(glass): 收敛壁纸预加载与失效恢复 (#592)
* fix(glass): streamline wallpaper loading

* docs(glass): remove stale compatibility comment

* docs(glass): remove obsolete texture fallback note
2026-07-27 11:26:29 +08:00
InfinityPacer
3886909e9e feat(glass): unify login and app optical presentation (#591) 2026-07-27 10:26:46 +08:00
jxxghp
0f8a817413 fix tests 2026-07-27 10:20:30 +08:00
jxxghp
eb47545b76 fix #6199 2026-07-27 10:05:32 +08:00
jxxghp
6fc123e0f6 fix: simplify media key generation to prevent duplicates
- Refactor getMediaItemKey function with clear priority chain
- Replace complex nested ternary with readable if-else structure
- Ensure consistent key generation for same media items
- Prioritize tmdb_id > douban_id > bangumi_id > anilist_id > media_id

Fixes duplicate items in popular subscribe list
Related to jxxghp/MoviePilot#6199
2026-07-27 09:43:00 +08:00
jxxghp
5bdef2ba8f fix(transfer): 显示并确认重新整理 2026-07-26 08:58:49 +08:00
jxxghp
42ca5b8f94 fix(search): assemble batched SSE results (#6186) 2026-07-26 08:55:34 +08:00
InfinityPacer
ba48a25982 fix(glass): stabilize optical surfaces after scrolling (#590) 2026-07-25 19:14:52 +08:00
InfinityPacer
76c524eb9b fix(dashboard): show media empty and retry states (#589) 2026-07-25 12:57:28 +08:00
InfinityPacer
48f8e8fc71 fix(glass): reset renderer state on unmount (#588) 2026-07-25 10:44:51 +08:00
InfinityPacer
2a0f15c1bd test(subscribe): stabilize calendar and season coverage (#587) 2026-07-25 10:31:54 +08:00
InfinityPacer
e94ade364e test(subscribe): stabilize mobile calendar filter (#586) 2026-07-25 10:31:04 +08:00
InfinityPacer
27f6c33782 feat(glass): improve optical quality and controls (#585) 2026-07-25 10:30:29 +08:00
jxxghp
b3e596c761 fix(subscribe): preserve source identity across season flows 2026-07-25 08:51:30 +08:00
jxxghp
0ee9e466c3 fix(subscribe): improve season metadata display (#584) 2026-07-25 08:01:43 +08:00
131 changed files with 10723 additions and 2217 deletions

View File

@@ -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 容器:

View File

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

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 16 KiB

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

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

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

View File

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

View File

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

View File

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

View File

@@ -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 {
// 总数

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

@@ -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缓存的页面

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '扫描模式',

View File

@@ -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: '掃描模式',

View File

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

View File

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

View File

@@ -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>
<!-- 快速滚动到顶部按钮 -->

View File

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

View File

@@ -3,5 +3,7 @@ import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
</script>
<template>
<FileBrowserView />
<div data-glass-optical-mode="static-material">
<FileBrowserView />
</div>
</template>

View File

@@ -3,7 +3,7 @@ import TransferHistoryView from '@/views/reorganize/TransferHistoryView.vue'
</script>
<template>
<div>
<div data-glass-optical-mode="static-material">
<TransferHistoryView />
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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