mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 03:27:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19cead06f7 | ||
|
|
288d46c32a | ||
|
|
1d0ca1d163 | ||
|
|
f41fcbff6d | ||
|
|
eb3b7b63b6 | ||
|
|
309ce3c92e | ||
|
|
e6b26cc831 | ||
|
|
7ea14bc919 | ||
|
|
1dfb51f3c4 | ||
|
|
871a9a072b | ||
|
|
0eb81b4ecb | ||
|
|
4814f53c4e | ||
|
|
0703a99e41 | ||
|
|
21b485ca84 | ||
|
|
fc6313b79e | ||
|
|
8badfb0da9 | ||
|
|
bfd61cd479 | ||
|
|
ac29818fd3 | ||
|
|
8d18f82e68 | ||
|
|
fd1bf8df7f | ||
|
|
6fd259427e |
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"src/ace-config.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 31
|
||||
"count": 24
|
||||
},
|
||||
"no-useless-escape": {
|
||||
"count": 4
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.15.4",
|
||||
"version": "2.15.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
+3
-2
@@ -65,13 +65,14 @@ http {
|
||||
root html;
|
||||
}
|
||||
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
|
||||
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
|
||||
# SSE MIME类型设置
|
||||
default_type text/event-stream;
|
||||
|
||||
# 禁用缓存
|
||||
add_header Cache-Control no-cache;
|
||||
add_header Cache-Control "no-cache, no-transform";
|
||||
add_header X-Accel-Buffering no;
|
||||
gzip off;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ function handleCancel() {
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
<VCardActions class="mx-auto">
|
||||
<VCardActions class="app-confirm-dialog-actions mx-auto">
|
||||
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</VBtn>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// 定义输入参数
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps({
|
||||
// 是否显示
|
||||
/** 覆盖关闭按钮默认的右上角定位 class。 */
|
||||
innerClass: String,
|
||||
})
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['click', 'update:modelValue'])
|
||||
// 按钮点击
|
||||
|
||||
function onClick() {
|
||||
emit('update:modelValue', false)
|
||||
emit('click')
|
||||
@@ -15,6 +16,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<IconBtn
|
||||
:aria-label="t('common.close')"
|
||||
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
|
||||
@click.stop="onClick"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = 'zh-CN'
|
||||
})
|
||||
|
||||
describe('DialogCloseBtn', () => {
|
||||
it('keeps its visual and event contracts while exposing a localized name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, emitted, rerender } = await renderWithProviders(DialogCloseBtn)
|
||||
const button = screen.getByRole('button', { name: '关闭' })
|
||||
|
||||
expect(button).toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
|
||||
const icon = container.querySelector('svg.v-icon')
|
||||
expect(icon).not.toBeNull()
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true')
|
||||
await waitFor(() =>
|
||||
expect(icon?.querySelector('path')).toHaveAttribute(
|
||||
'd',
|
||||
'M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z',
|
||||
),
|
||||
)
|
||||
|
||||
i18n.global.locale.value = 'en-US'
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toBe(button))
|
||||
|
||||
await user.click(button)
|
||||
expect(emitted()['update:modelValue']).toEqual([[false]])
|
||||
expect(emitted().click).toEqual([[]])
|
||||
|
||||
await rerender({ innerClass: 'dialog-close-custom' })
|
||||
expect(button).toHaveClass('dialog-close-custom')
|
||||
expect(button).not.toHaveClass('absolute', 'right-3', 'top-3', 'z-10')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,73 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {},
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getColor: vi.fn(),
|
||||
}))
|
||||
|
||||
import { preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
vi.mock('colorthief', () => ({
|
||||
default: class ColorThief {
|
||||
getColor(image: HTMLImageElement, quality?: number) {
|
||||
return mocks.getColor(image, quality)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import { extractDominantColor, getDominantColor, preloadCorsImage } from '@/@core/utils/image'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
function createImage(cacheKey: string) {
|
||||
const image = document.createElement('img')
|
||||
Object.defineProperty(image, 'currentSrc', { configurable: true, value: `https://image.example/${cacheKey}.png` })
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
describe('dominant color extraction', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getColor.mockReset()
|
||||
})
|
||||
|
||||
it('shares a pending extraction and reuses only the successful result', async () => {
|
||||
const image = createImage('shared-success')
|
||||
mocks.getColor.mockReturnValue([18, 52, 86])
|
||||
|
||||
await expect(Promise.all([extractDominantColor(image), extractDominantColor(image)])).resolves.toEqual([
|
||||
'#123456',
|
||||
'#123456',
|
||||
])
|
||||
await expect(extractDominantColor(image)).resolves.toBe('#123456')
|
||||
expect(mocks.getColor).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not cache failures or let one caller fallback pollute another', async () => {
|
||||
const image = createImage('retry-after-failure')
|
||||
mocks.getColor.mockImplementation(() => {
|
||||
throw new Error('tainted canvas')
|
||||
})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await expect(getDominantColor(image, { fallback: '#111111' })).resolves.toBe('#111111')
|
||||
await expect(getDominantColor(image, { fallback: '#222222' })).resolves.toBe('#222222')
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the existing default fallback contract for callers such as QuickAccess', async () => {
|
||||
await expect(getDominantColor(null)).resolves.toBe('#28A9E1')
|
||||
expect(mocks.getColor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the bounded FIFO success cache', async () => {
|
||||
mocks.getColor.mockReturnValue([1, 2, 3])
|
||||
|
||||
for (let index = 0; index <= 100; index += 1) await extractDominantColor(createImage(`fifo-${index}`))
|
||||
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||
await extractDominantColor(createImage('fifo-50'))
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||
await extractDominantColor(createImage('fifo-0'))
|
||||
expect(mocks.getColor).toHaveBeenCalledTimes(102)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preloadCorsImage', () => {
|
||||
afterEach(() => {
|
||||
|
||||
+41
-22
@@ -4,7 +4,8 @@ export { preloadCorsImage } from './corsImage'
|
||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||
const colorThief = new ColorThief()
|
||||
const dominantColorCache = new Map<string, Promise<string>>()
|
||||
const dominantColorCache = new Map<string, string>()
|
||||
const pendingDominantColorRequests = new Map<string, Promise<string | undefined>>()
|
||||
|
||||
interface DominantColorOptions {
|
||||
fallback?: string
|
||||
@@ -29,42 +30,60 @@ function getImageCacheKey(image: HTMLImageElement) {
|
||||
return image.currentSrc || image.src || ''
|
||||
}
|
||||
|
||||
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
|
||||
if (!key) return colorPromise
|
||||
function rememberDominantColor(key: string, color: string) {
|
||||
if (!key) return
|
||||
|
||||
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
||||
const firstKey = dominantColorCache.keys().next().value
|
||||
if (firstKey) dominantColorCache.delete(firstKey)
|
||||
}
|
||||
|
||||
dominantColorCache.set(key, colorPromise)
|
||||
dominantColorCache.set(key, color)
|
||||
}
|
||||
|
||||
/** 提取真实主色;失败不写入成功缓存,允许后续请求重试。 */
|
||||
export async function extractDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: Pick<DominantColorOptions, 'quality'> = {},
|
||||
): Promise<string | undefined> {
|
||||
if (!image) return undefined
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const pendingRequest = cacheKey ? pendingDominantColorRequests.get(cacheKey) : undefined
|
||||
if (pendingRequest) return pendingRequest
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
const color = rgbStringToHex(dominantColor)
|
||||
rememberDominantColor(cacheKey, color)
|
||||
|
||||
return color
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
if (cacheKey) pendingDominantColorRequests.delete(cacheKey)
|
||||
})
|
||||
|
||||
if (cacheKey) pendingDominantColorRequests.set(cacheKey, colorPromise)
|
||||
|
||||
return colorPromise
|
||||
}
|
||||
|
||||
// 提取主要颜色
|
||||
/** 提取主色并在失败时解析调用方 fallback,保持既有调用合同。 */
|
||||
export async function getDominantColor(
|
||||
image: HTMLImageElement | undefined | null,
|
||||
options: DominantColorOptions = {},
|
||||
): Promise<string> {
|
||||
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
||||
|
||||
if (!image) return fallback
|
||||
|
||||
const cacheKey = getImageCacheKey(image)
|
||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||
if (cachedColor) return cachedColor
|
||||
|
||||
const colorPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||
return rgbStringToHex(dominantColor)
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn('Failed to extract dominant color:', error)
|
||||
return fallback
|
||||
})
|
||||
|
||||
return rememberDominantColor(cacheKey, colorPromise)
|
||||
return (await extractDominantColor(image, options)) ?? fallback
|
||||
}
|
||||
|
||||
// 预加载图片
|
||||
|
||||
+13
-3
@@ -12,7 +12,11 @@ import { globalLoadingStateManager } from '@/utils/loadingStateManager'
|
||||
import { addBackgroundTimer, removeBackgroundTimer } from '@/utils/backgroundManager'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SharedDialogHost from '@/components/dialog/SharedDialogHost.vue'
|
||||
import { applyStoredThemeCustomizerAppearance, useEffectiveGlassSettings } from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
applyStoredThemeCustomizerAppearance,
|
||||
themeCustomizerPrimaryColors,
|
||||
useEffectiveGlassSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
applyStoredTransparencySettings,
|
||||
TRANSPARENCY_SETTINGS_CHANGED_EVENT,
|
||||
@@ -24,6 +28,7 @@ import { usePWA } from '@/composables/usePWA'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { applyDocumentThemeChrome, resolveThemeName } from '@/utils/themePalette'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { configureApexChartsTheme } from '@/utils/apexCharts'
|
||||
import { useGlobalOfflineStatus, type ConnectionFailureReason } from '@/composables/useOfflineStatus'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
@@ -118,6 +123,11 @@ function recordGlassLaunchTiming(stage: string, detail?: string) {
|
||||
// 生效主题
|
||||
const vuetifyTheme = useTheme()
|
||||
const { global: globalTheme } = vuetifyTheme
|
||||
const glassMaterialTintColor = computed(
|
||||
() =>
|
||||
normalizeThemeMaterialAccent(globalTheme.current.value.colors.primary)?.hex ??
|
||||
normalizeThemeMaterialAccent(themeCustomizerPrimaryColors[0].value)!.hex,
|
||||
)
|
||||
let themeValue = localStorage.getItem('theme') || 'auto'
|
||||
let resumeThemeSyncTimer: number | null = null
|
||||
globalTheme.name.value = resolveInitialThemeName(themeValue)
|
||||
@@ -1191,7 +1201,7 @@ onUnmounted(() => {
|
||||
:transmission-strength="opticalTransmissionStrength"
|
||||
:translation-strength="opticalTranslationStrength"
|
||||
:route-key="route.fullPath"
|
||||
:tint-color="globalTheme.current.value.colors.primary"
|
||||
:tint-color="glassMaterialTintColor"
|
||||
:transition-duration="BACKGROUND_CROSSFADE_DURATION_MS"
|
||||
:transition-started-at="backgroundCrossfadeStartedAt"
|
||||
:wallpaper-url="activeOpticalBackgroundImage"
|
||||
@@ -1314,7 +1324,7 @@ html[data-glass-appearance='tinted'] .background-container.is-glass-theme .backg
|
||||
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, 14%) 100%),
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--v-theme-primary), 3%);
|
||||
linear-gradient(rgba(6, 10, 19, 10%) 0%, rgba(6, 10, 19, 32%) 100%), rgba(var(--glass-material-accent-rgb), 3%);
|
||||
}
|
||||
|
||||
html[data-glass-appearance='frosted'] .background-container.is-glass-theme .background-image.active,
|
||||
|
||||
@@ -7,17 +7,22 @@ interface AceToken {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface WordListSyntaxMode {
|
||||
interface WordListMode {
|
||||
getTokenizer: () => {
|
||||
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
|
||||
}
|
||||
}
|
||||
|
||||
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
|
||||
const tokenizer = new WordListSyntaxMode().getTokenizer()
|
||||
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
|
||||
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
|
||||
const plainTokenizer = new WordListMode().getTokenizer()
|
||||
|
||||
function tokenize(line: string) {
|
||||
return tokenizer.getLineTokens(line, 'start').tokens
|
||||
return syntaxTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
function tokenizePlain(line: string) {
|
||||
return plainTokenizer.getLineTokens(line, 'start').tokens
|
||||
}
|
||||
|
||||
describe('word list syntax mode', () => {
|
||||
@@ -25,14 +30,103 @@ describe('word list syntax mode', () => {
|
||||
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
|
||||
})
|
||||
|
||||
it('separates replaced and replacement fields without parsing their content', () => {
|
||||
it('parses valid replacement parameters', () => {
|
||||
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名.*' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '123' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'tv' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks invalid replacement parameter keys and values', () => {
|
||||
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 ' },
|
||||
{ type: 'word_list_parameter_syntax', value: '{[' },
|
||||
{ type: 'invalid.word-list', value: 'unknown' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '1' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'tmdbid' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'abc' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'type' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'invalid.word-list', value: 'anime' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'g' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: 'group' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 's' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '2' },
|
||||
{ type: 'word_list_parameter_syntax', value: ';' },
|
||||
{ type: 'word_list_parameter_key', value: 'e' },
|
||||
{ type: 'word_list_parameter_syntax', value: '=' },
|
||||
{ type: 'word_list_parameter_value', value: '3' },
|
||||
{ type: 'word_list_parameter_syntax', value: ']}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks an unknown replacement parameter key as invalid', () => {
|
||||
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
|
||||
})
|
||||
|
||||
it('marks visible syntax for missing or malformed replacement parameters', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
|
||||
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
|
||||
})
|
||||
|
||||
it('marks the second semicolon when it creates an empty parameter', () => {
|
||||
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
|
||||
|
||||
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
|
||||
})
|
||||
|
||||
it('marks both sides of missing keys and values', () => {
|
||||
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
|
||||
.filter(token => token.type === 'invalid.word-list')
|
||||
.map(token => token.value)
|
||||
|
||||
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
|
||||
})
|
||||
|
||||
it('treats an unclosed parameter block as ordinary replacement text', () => {
|
||||
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
|
||||
{ type: 'word_list_replaced', value: '旧名' },
|
||||
{ type: 'keyword.operator.word-list', value: ' => ' },
|
||||
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts every supported replacement parameter type', () => {
|
||||
expect(
|
||||
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
|
||||
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
|
||||
})
|
||||
|
||||
it('accepts season and episode number ranges', () => {
|
||||
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'invalid.word-list' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('separates front, back, and episode offset fields', () => {
|
||||
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
|
||||
{ type: 'word_list_front', value: '第' },
|
||||
@@ -59,4 +153,24 @@ describe('word list syntax mode', () => {
|
||||
])
|
||||
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
|
||||
})
|
||||
|
||||
it('highlights comment lines', () => {
|
||||
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('word list plain mode', () => {
|
||||
it('does not highlight comment lines', () => {
|
||||
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
|
||||
})
|
||||
|
||||
it('does not highlight word list fields', () => {
|
||||
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
|
||||
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns no tokens for empty lines', () => {
|
||||
expect(tokenizePlain('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
+232
-94
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
|
||||
)
|
||||
}
|
||||
|
||||
interface WordListToken {
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
|
||||
const wordListUnsignedIntegerPattern = /^\d+$/
|
||||
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
|
||||
const wordListParameterTypes = {
|
||||
tmdbid: 'uint',
|
||||
doubanid: 'uint',
|
||||
bangumiid: 'uint',
|
||||
anilistid: 'uint',
|
||||
type: 'media-type',
|
||||
g: 'string',
|
||||
s: 'uint-or-range',
|
||||
e: 'uint-or-range',
|
||||
} as const
|
||||
|
||||
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
|
||||
if (!value) return
|
||||
|
||||
const previousToken = tokens.at(-1)
|
||||
if (previousToken?.type === type) previousToken.value += value
|
||||
else tokens.push({ type, value })
|
||||
}
|
||||
|
||||
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
|
||||
switch (wordListParameterTypes[key]) {
|
||||
case 'uint':
|
||||
return wordListUnsignedIntegerPattern.test(value)
|
||||
case 'uint-or-range':
|
||||
return wordListUnsignedIntegerOrRangePattern.test(value)
|
||||
case 'media-type':
|
||||
return value === 'movie' || value === 'tv'
|
||||
case 'string':
|
||||
return value.length > 0
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListParameters(parameters: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
const parameterList = parameters.split(';')
|
||||
|
||||
parameterList.forEach((parameter, index) => {
|
||||
const isEmptyParameter = parameter.length === 0
|
||||
if (index > 0) {
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
|
||||
';',
|
||||
)
|
||||
}
|
||||
if (isEmptyParameter) return
|
||||
|
||||
const equalsIndex = parameter.indexOf('=')
|
||||
if (equalsIndex === -1) {
|
||||
appendWordListToken(tokens, 'invalid.word-list', parameter)
|
||||
return
|
||||
}
|
||||
|
||||
const key = parameter.slice(0, equalsIndex)
|
||||
const value = parameter.slice(equalsIndex + 1)
|
||||
const hasKey = key.length > 0
|
||||
const hasValue = value.length > 0
|
||||
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
|
||||
|
||||
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
|
||||
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
|
||||
|
||||
const isValidValue =
|
||||
isKnownKey &&
|
||||
!value.includes('=') &&
|
||||
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
|
||||
appendWordListToken(
|
||||
tokens,
|
||||
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
|
||||
value,
|
||||
)
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
|
||||
const tokens: WordListToken[] = []
|
||||
let replacementStart = 0
|
||||
|
||||
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
|
||||
const parameterStart = match.index ?? 0
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
|
||||
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
|
||||
else {
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
|
||||
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
|
||||
}
|
||||
replacementStart = parameterStart + match[0].length
|
||||
}
|
||||
|
||||
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
|
||||
return tokens
|
||||
}
|
||||
|
||||
function splitWordListField(value: string, operator: string) {
|
||||
let operatorIndex = value.indexOf(operator)
|
||||
|
||||
while (operatorIndex !== -1) {
|
||||
const operatorEnd = operatorIndex + operator.length
|
||||
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
|
||||
let operatorStart = operatorIndex
|
||||
while (value[operatorStart - 1] === ' ') operatorStart -= 1
|
||||
|
||||
let operatorWithSpacesEnd = operatorEnd
|
||||
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
|
||||
|
||||
return {
|
||||
before: value.slice(0, operatorStart),
|
||||
operator: value.slice(operatorStart, operatorWithSpacesEnd),
|
||||
after: value.slice(operatorWithSpacesEnd),
|
||||
}
|
||||
}
|
||||
|
||||
operatorIndex = value.indexOf(operator, operatorEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function getWordListLineWhitespace(value: string) {
|
||||
const leadingSpaceLength = value.length - value.trimStart().length
|
||||
const trailingSpaceLength = value.length - value.trimEnd().length
|
||||
|
||||
return {
|
||||
leadingSpace: value.slice(0, leadingSpaceLength),
|
||||
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
|
||||
trailingSpace: value.slice(value.length - trailingSpaceLength),
|
||||
}
|
||||
}
|
||||
|
||||
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
if (!replacementField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(replacementField.after).forEach(token =>
|
||||
appendWordListToken(tokens, token.type, token.value),
|
||||
)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
|
||||
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
|
||||
const replacementField = splitWordListField(content, '=>')
|
||||
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
|
||||
const backField = frontField && splitWordListField(frontField.after, '<>')
|
||||
const offsetField = backField && splitWordListField(backField.after, '>>')
|
||||
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
|
||||
|
||||
const tokens: WordListToken[] = []
|
||||
appendWordListToken(tokens, 'text', leadingSpace)
|
||||
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
|
||||
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
|
||||
appendWordListToken(tokens, 'word_list_front', backField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
|
||||
appendWordListToken(tokens, 'word_list_back', offsetField.before)
|
||||
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
|
||||
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
|
||||
appendWordListToken(tokens, 'text', trailingSpace)
|
||||
return tokens
|
||||
}
|
||||
|
||||
function buildWordListRules(syntax: boolean) {
|
||||
if (!syntax) {
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'empty_line',
|
||||
regex: '^$',
|
||||
},
|
||||
{
|
||||
defaultToken: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeCombinedWordListLine,
|
||||
},
|
||||
{
|
||||
token: 'text',
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
onMatch: tokenizeWordListReplacementLine,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_block', 'text'],
|
||||
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function registerWordListMode() {
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_highlight_rules',
|
||||
@@ -538,15 +766,8 @@ function registerWordListMode() {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
],
|
||||
}
|
||||
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
|
||||
this.$rules = buildWordListRules(options?.syntax === true)
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
@@ -564,9 +785,10 @@ function registerWordListMode() {
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
const Mode = function (this: any, options?: { syntax?: boolean }) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListHighlightRules
|
||||
this.$highlightRuleConfig = options || {}
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
@@ -578,90 +800,6 @@ function registerWordListMode() {
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax_highlight_rules',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
|
||||
|
||||
const WordListSyntaxHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.word-list',
|
||||
regex: /^#.*/,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_replaced',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_replacement',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
|
||||
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: [
|
||||
'text',
|
||||
'word_list_front',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_back',
|
||||
'keyword.operator.word-list',
|
||||
'word_list_offset',
|
||||
'text',
|
||||
],
|
||||
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
|
||||
},
|
||||
{
|
||||
token: ['text', 'word_list_block', 'text'],
|
||||
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
|
||||
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
aceModule.define?.(
|
||||
'ace/mode/word_list_syntax',
|
||||
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
|
||||
(require: any, exports: any) => {
|
||||
const oop = require('../lib/oop')
|
||||
const TextMode = require('./text').Mode
|
||||
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
TextMode.call(this)
|
||||
this.HighlightRules = WordListSyntaxHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
|
||||
;(function (this: any) {
|
||||
this.$id = 'ace/mode/word_list_syntax'
|
||||
}).call(Mode.prototype)
|
||||
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
|
||||
|
||||
@@ -26,6 +26,11 @@ export const storageAttributes = [
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'alistgo',
|
||||
icon: 'mdi-server-network-outline',
|
||||
remote: true,
|
||||
},
|
||||
{
|
||||
type: 'smb',
|
||||
icon: 'mdi-folder-network-outline',
|
||||
|
||||
+6
-3
@@ -1084,6 +1084,11 @@ export interface User {
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
// 头像上传响应数据
|
||||
export interface AvatarUploadData {
|
||||
filename: string
|
||||
}
|
||||
|
||||
// 通行密钥
|
||||
export interface PassKey {
|
||||
id: number
|
||||
@@ -1889,9 +1894,7 @@ export interface RecognitionCacheItem {
|
||||
key: string
|
||||
// TMDB ID,0 表示未识别
|
||||
tmdb_id?: number
|
||||
// 豆瓣 ID,0 表示未识别
|
||||
douban_id?: string | number
|
||||
// 当前识别数据源对应的统一 ID
|
||||
// 识别缓存对应的字符串 ID
|
||||
recognition_id?: string
|
||||
// 识别后的标题
|
||||
title: string
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import AgentPetStage from './pet/AgentPetStage.vue'
|
||||
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
|
||||
import { useAgentPetMachine } from './pet/useAgentPetMachine'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
|
||||
interface AgentAssistantEntryBubble {
|
||||
id: string
|
||||
@@ -46,6 +47,8 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
@@ -163,6 +166,7 @@ const fabPositionStyle = computed(() => {
|
||||
...fabPointerStyle.value,
|
||||
'--agent-assistant-fab-x': `${position.x}px`,
|
||||
'--agent-assistant-fab-y': `${position.y}px`,
|
||||
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
|
||||
}
|
||||
})
|
||||
const fabBubblePlacement = ref<FabBubblePlacement>('top')
|
||||
@@ -1012,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
|
||||
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
|
||||
if (!props.active || !bubble.text) return
|
||||
|
||||
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
|
||||
if (existingIndex >= 0) {
|
||||
fabBubbles.value[existingIndex] = bubble
|
||||
setFabDocked(false)
|
||||
nextTick(scheduleFabBubblePositionUpdate)
|
||||
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
|
||||
return
|
||||
}
|
||||
|
||||
const hadBubbles = hasFabBubbles.value
|
||||
const wasDocked = fabDocked.value
|
||||
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
|
||||
@@ -1059,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
|
||||
showBubble({
|
||||
id: 'assistant-preview',
|
||||
kind: 'assistant',
|
||||
text: value,
|
||||
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1570,9 +1583,6 @@ defineExpose({
|
||||
.agent-assistant-fab {
|
||||
position: fixed;
|
||||
|
||||
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast(2500)。 */
|
||||
z-index: 2600;
|
||||
|
||||
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
|
||||
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
|
||||
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { getCurrentLocale } from '@/plugins/i18n'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
import AgentMarkdownContent from './AgentMarkdownContent.vue'
|
||||
|
||||
type AgentMessageRole = 'user' | 'assistant'
|
||||
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
|
||||
@@ -19,6 +19,21 @@ interface AgentToolCall {
|
||||
status: 'running' | 'done'
|
||||
}
|
||||
|
||||
interface AgentMessageTextSegment {
|
||||
type: 'text'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface AgentMessageToolSegment {
|
||||
type: 'tool'
|
||||
toolIndex: number
|
||||
}
|
||||
|
||||
type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment
|
||||
|
||||
type AgentRenderableMessageSegment =
|
||||
(AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }
|
||||
|
||||
interface AgentMessageAttachment {
|
||||
kind: AgentAttachmentKind
|
||||
url: string
|
||||
@@ -65,6 +80,7 @@ interface AgentChatMessage {
|
||||
createdAt: number
|
||||
status: AgentMessageStatus
|
||||
tools: AgentToolCall[]
|
||||
segments: AgentMessageSegment[]
|
||||
attachments: AgentMessageAttachment[]
|
||||
choices: AgentChoiceCard[]
|
||||
choice_selection?: AgentChoiceSelection
|
||||
@@ -198,6 +214,7 @@ const messages = ref<AgentChatMessage[]>([])
|
||||
const historySessions = ref<AgentSessionHistoryItem[]>([])
|
||||
const sessionId = ref('')
|
||||
const sending = ref(false)
|
||||
const isComposing = ref(false)
|
||||
const streamError = ref('')
|
||||
const historyMenuOpen = ref(false)
|
||||
const messageListRef = ref<HTMLElement | null>(null)
|
||||
@@ -223,25 +240,16 @@ let recordingChunks: BlobPart[] = []
|
||||
let messageScrollFrame: number | null = null
|
||||
let pendingMessageScrollToBottom = false
|
||||
let streamPersistTimer: number | null = null
|
||||
let streamPersistLastRunAt = 0
|
||||
let messageScrollerShouldFollow = true
|
||||
let streamDeltaFrame: number | null = null
|
||||
let pendingStreamDelta = ''
|
||||
let pendingStreamDeltaMessage: AgentChatMessage | null = null
|
||||
let userAbortRequested = false
|
||||
let streamRecoveryAbortRequested = false
|
||||
let streamRecoveryTimer: number | null = null
|
||||
let activeStreamStartedAt = 0
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: true,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
md.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
|
||||
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
|
||||
const canSend = computed(
|
||||
@@ -296,6 +304,7 @@ const isOpen = computed({
|
||||
})
|
||||
const drawerStyle = computed(() => ({
|
||||
'--agent-assistant-panel-width': drawerWidth.value,
|
||||
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
|
||||
}))
|
||||
|
||||
// 创建前端展示用的临时 ID。
|
||||
@@ -515,26 +524,57 @@ function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
|
||||
return sessionMessages
|
||||
}
|
||||
|
||||
// 规范化历史消息,补齐附件、工具和选择项等可选数组。
|
||||
// 规范化消息的有序片段;旧历史按原来的工具在前、文本在后布局回退。
|
||||
function normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
|
||||
const normalizedSegments: AgentMessageSegment[] = []
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(rawSegment => {
|
||||
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return
|
||||
|
||||
const segment = rawSegment as Record<string, unknown>
|
||||
if (segment.type === 'text' && typeof segment.content === 'string' && segment.content.trim()) {
|
||||
normalizedSegments.push({ type: 'text', content: segment.content })
|
||||
return
|
||||
}
|
||||
|
||||
const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
|
||||
if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
|
||||
normalizedSegments.push({ type: 'tool', toolIndex })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedSegments.length) return normalizedSegments
|
||||
|
||||
tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
|
||||
if (content.trim()) normalizedSegments.push({ type: 'text', content })
|
||||
return normalizedSegments
|
||||
}
|
||||
|
||||
// 规范化历史消息,补齐附件、工具、有序片段和选择项等可选数组。
|
||||
function normalizeStoredMessages(value: unknown) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
|
||||
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
|
||||
const role = message.role === 'assistant' ? 'assistant' : 'user'
|
||||
const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
|
||||
const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []
|
||||
|
||||
return {
|
||||
...message,
|
||||
id: stringifyChoiceField(message.id) || createId(role),
|
||||
role,
|
||||
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
|
||||
content,
|
||||
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
|
||||
status: normalizeMessageStatus(message.status),
|
||||
attachments: Array.isArray(message.attachments) ? message.attachments : [],
|
||||
choices: Array.isArray(message.choices)
|
||||
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
||||
: [],
|
||||
tools: Array.isArray(message.tools) ? message.tools : [],
|
||||
tools,
|
||||
segments: normalizeMessageSegments(message.segments, content, tools),
|
||||
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
|
||||
} as AgentChatMessage
|
||||
})
|
||||
@@ -811,7 +851,7 @@ function failStreamRecovery() {
|
||||
.find(message => message.role === 'assistant' && message.status === 'streaming')
|
||||
if (assistantMessage) {
|
||||
assistantMessage.status = 'error'
|
||||
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
|
||||
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
|
||||
markToolsDone(assistantMessage)
|
||||
refreshMessageList()
|
||||
} else {
|
||||
@@ -1056,12 +1096,6 @@ function persistState(options: { syncHistory?: boolean } = {}) {
|
||||
if (syncHistory) upsertCurrentSessionHistory()
|
||||
}
|
||||
|
||||
// 渲染助手消息中的 Markdown 文本。
|
||||
function renderMarkdown(value: string) {
|
||||
if (!value) return ''
|
||||
return md.render(value)
|
||||
}
|
||||
|
||||
// 拼接后端 API 地址。
|
||||
function resolveApiUrl(path: string) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
|
||||
@@ -1106,6 +1140,11 @@ function isMessageScrollerNearBottom() {
|
||||
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
|
||||
}
|
||||
|
||||
// 只在滚动事件中更新自动跟随意图,避免每个流式事件触发布局读取。
|
||||
function handleMessageScrollerScroll() {
|
||||
messageScrollerShouldFollow = isMessageScrollerNearBottom()
|
||||
}
|
||||
|
||||
// 合并滚动更新请求,降低流式输出时的布局测量频率。
|
||||
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||
const { toBottom = false } = options
|
||||
@@ -1125,6 +1164,7 @@ function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
|
||||
// 将消息列表滚动到底部。
|
||||
function scrollToBottom(options: { smooth?: boolean } = {}) {
|
||||
const { smooth = false } = options
|
||||
messageScrollerShouldFollow = true
|
||||
nextTick(() => {
|
||||
const scroller = getMessageScrollerElement()
|
||||
if (!scroller) return
|
||||
@@ -1168,13 +1208,17 @@ function clearMessageScrollFrame() {
|
||||
pendingMessageScrollToBottom = false
|
||||
}
|
||||
|
||||
// 延迟持久化流式消息,避免每个 token 都写入本地存储。
|
||||
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史。
|
||||
function scheduleStreamPersist() {
|
||||
clearStreamPersistTimer()
|
||||
if (streamPersistTimer !== null) return
|
||||
|
||||
const elapsed = Date.now() - streamPersistLastRunAt
|
||||
const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
|
||||
streamPersistTimer = window.setTimeout(() => {
|
||||
persistState()
|
||||
streamPersistTimer = null
|
||||
}, STREAM_STATE_PERSIST_DELAY)
|
||||
streamPersistLastRunAt = Date.now()
|
||||
persistState({ syncHistory: false })
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// 同步输入框高度,使多行输入不撑破底部布局。
|
||||
@@ -1210,6 +1254,7 @@ function addMessage(
|
||||
attachments,
|
||||
choices: [],
|
||||
tools: [],
|
||||
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
|
||||
choice_selection: choiceSelection,
|
||||
}
|
||||
messages.value.push(message)
|
||||
@@ -1225,6 +1270,45 @@ function normalizeToolMessage(message: string) {
|
||||
return message.replace(/^=>\s*/, '').trim()
|
||||
}
|
||||
|
||||
// 解析非啰嗦模式的工具汇总,供相邻工具状态按类别累计次数。
|
||||
function parseToolSummary(message: string) {
|
||||
const summaryMatch = message.match(/^((.+))$/)
|
||||
if (!summaryMatch) return null
|
||||
|
||||
const parts = summaryMatch[1].split(',').map(part => {
|
||||
const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/)
|
||||
if (!countMatch) return null
|
||||
return {
|
||||
prefix: countMatch[1],
|
||||
count: Number(countMatch[2]),
|
||||
suffix: countMatch[3],
|
||||
}
|
||||
})
|
||||
return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null
|
||||
}
|
||||
|
||||
// 仅合并相邻的非啰嗦工具汇总;正文或具体工具提示会自然终止当前聚合组。
|
||||
function mergeToolSummaries(currentMessage: string, nextMessage: string) {
|
||||
const currentParts = parseToolSummary(currentMessage)
|
||||
const nextParts = parseToolSummary(nextMessage)
|
||||
if (!currentParts || !nextParts) return null
|
||||
|
||||
const mergedParts = currentParts.map(part => ({ ...part }))
|
||||
const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index]))
|
||||
nextParts.forEach(part => {
|
||||
const key = `${part.prefix}\u0000${part.suffix}`
|
||||
const existingIndex = partIndexes.get(key)
|
||||
if (existingIndex === undefined) {
|
||||
partIndexes.set(key, mergedParts.length)
|
||||
mergedParts.push({ ...part })
|
||||
return
|
||||
}
|
||||
mergedParts[existingIndex].count += part.count
|
||||
})
|
||||
|
||||
return `(${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join(',')})`
|
||||
}
|
||||
|
||||
// 将当前消息里的运行中工具标记为完成。
|
||||
function markToolsDone(message: AgentChatMessage) {
|
||||
message.tools.forEach(tool => {
|
||||
@@ -1232,11 +1316,58 @@ function markToolsDone(message: AgentChatMessage) {
|
||||
})
|
||||
}
|
||||
|
||||
// 追加助手文本,并只合并紧邻的文本片段以保留工具事件边界。
|
||||
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
|
||||
if (!content) return
|
||||
|
||||
message.content += content
|
||||
const lastSegment = message.segments.at(-1)
|
||||
if (lastSegment?.type === 'text') {
|
||||
lastSegment.content += content
|
||||
} else if (content.trim()) {
|
||||
message.segments.push({ type: 'text', content })
|
||||
}
|
||||
}
|
||||
|
||||
// 替换助手文本但保留工具片段,用于无法继续流式处理时显示错误。
|
||||
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
|
||||
message.content = content
|
||||
message.segments = message.segments.filter(segment => segment.type === 'tool')
|
||||
if (content.trim()) message.segments.push({ type: 'text', content })
|
||||
}
|
||||
|
||||
// 按 SSE 事件顺序渲染文本与工具,只跳过无法产生可见内容的空白文本。
|
||||
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
|
||||
return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
|
||||
if (segment.type === 'text') {
|
||||
if (segment.content.trim()) renderableSegments.push({ ...segment, key: `text-${index}` })
|
||||
return renderableSegments
|
||||
}
|
||||
|
||||
const tool = message.tools[segment.toolIndex]
|
||||
if (tool) {
|
||||
const previousSegment = renderableSegments.at(-1)
|
||||
const mergedMessage =
|
||||
previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null
|
||||
if (previousSegment?.type === 'tool' && mergedMessage) {
|
||||
previousSegment.tool = {
|
||||
...previousSegment.tool,
|
||||
message: mergedMessage,
|
||||
status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done',
|
||||
}
|
||||
} else {
|
||||
renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
|
||||
}
|
||||
}
|
||||
return renderableSegments
|
||||
}, [])
|
||||
}
|
||||
|
||||
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
|
||||
function isEmptyAssistantMessage(message: AgentChatMessage) {
|
||||
return (
|
||||
message.role === 'assistant' &&
|
||||
!message.content &&
|
||||
!message.content.trim() &&
|
||||
message.attachments.length === 0 &&
|
||||
message.choices.length === 0 &&
|
||||
message.tools.length === 0
|
||||
@@ -1255,6 +1386,7 @@ function applyMessageUpdate(event: AgentStreamEvent) {
|
||||
message.content = typeof target?.content === 'string' ? target.content : ''
|
||||
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
|
||||
message.tools = Array.isArray(target?.tools) ? target.tools : []
|
||||
message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
|
||||
message.choices = Array.isArray(target?.choices)
|
||||
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
|
||||
: []
|
||||
@@ -1266,11 +1398,9 @@ function applyMessageUpdate(event: AgentStreamEvent) {
|
||||
|
||||
// 将单个 SSE 事件应用到正在流式输出的助手消息。
|
||||
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||
const shouldFollowBottom = isMessageScrollerNearBottom()
|
||||
|
||||
switch (event.type) {
|
||||
case 'delta':
|
||||
assistantMessage.content += event.content || ''
|
||||
appendAssistantTextSegment(assistantMessage, event.content || '')
|
||||
emit('assistant-preview', assistantMessage.content)
|
||||
break
|
||||
case 'tool':
|
||||
@@ -1280,6 +1410,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
message: normalizeToolMessage(event.message || ''),
|
||||
status: 'running',
|
||||
})
|
||||
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
|
||||
break
|
||||
case 'attachment':
|
||||
if (event.attachment?.url) {
|
||||
@@ -1306,7 +1437,9 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
case 'error':
|
||||
assistantMessage.status = 'error'
|
||||
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
|
||||
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
|
||||
if (!assistantMessage.content) {
|
||||
appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
|
||||
}
|
||||
emit('assistant-preview', assistantMessage.content)
|
||||
markToolsDone(assistantMessage)
|
||||
break
|
||||
@@ -1322,10 +1455,54 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
|
||||
|
||||
scheduleStreamPersist()
|
||||
nextTick(() => {
|
||||
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
|
||||
scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
|
||||
})
|
||||
}
|
||||
|
||||
// 将同一条助手消息的连续文本增量合并到一个动画帧,语义事件到来前会同步冲刷。
|
||||
function flushPendingStreamDelta() {
|
||||
if (streamDeltaFrame !== null) {
|
||||
window.cancelAnimationFrame(streamDeltaFrame)
|
||||
streamDeltaFrame = null
|
||||
}
|
||||
if (!pendingStreamDeltaMessage || !pendingStreamDelta) return
|
||||
|
||||
const assistantMessage = pendingStreamDeltaMessage
|
||||
const content = pendingStreamDelta
|
||||
pendingStreamDeltaMessage = null
|
||||
pendingStreamDelta = ''
|
||||
applyStreamEvent({ type: 'delta', content }, assistantMessage)
|
||||
}
|
||||
|
||||
function clearPendingStreamDelta() {
|
||||
if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
|
||||
streamDeltaFrame = null
|
||||
pendingStreamDeltaMessage = null
|
||||
pendingStreamDelta = ''
|
||||
}
|
||||
|
||||
function schedulePendingStreamDeltaFlush() {
|
||||
if (streamDeltaFrame !== null) return
|
||||
|
||||
streamDeltaFrame = window.requestAnimationFrame(() => {
|
||||
streamDeltaFrame = null
|
||||
flushPendingStreamDelta()
|
||||
})
|
||||
}
|
||||
|
||||
function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
|
||||
if (event.type !== 'delta') {
|
||||
flushPendingStreamDelta()
|
||||
applyStreamEvent(event, assistantMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
|
||||
pendingStreamDeltaMessage = assistantMessage
|
||||
pendingStreamDelta += event.content || ''
|
||||
schedulePendingStreamDeltaFlush()
|
||||
}
|
||||
|
||||
// 解析一个 SSE 数据块。
|
||||
function parseSseBlock(block: string) {
|
||||
const data = block
|
||||
@@ -1353,26 +1530,30 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
|
||||
const consumeEvent = (event: AgentStreamEvent | null) => {
|
||||
if (!event) return
|
||||
|
||||
applyStreamEvent(event, assistantMessage)
|
||||
queueStreamEvent(event, assistantMessage)
|
||||
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split(/\n\n/)
|
||||
buffer = blocks.pop() || ''
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split(/\r?\n\r?\n/)
|
||||
buffer = blocks.pop() || ''
|
||||
|
||||
for (const block of blocks) {
|
||||
consumeEvent(parseSseBlock(block))
|
||||
for (const block of blocks) {
|
||||
consumeEvent(parseSseBlock(block))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
consumeEvent(parseSseBlock(buffer))
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
consumeEvent(parseSseBlock(buffer))
|
||||
}
|
||||
} finally {
|
||||
flushPendingStreamDelta()
|
||||
}
|
||||
|
||||
return { receivedTerminalEvent }
|
||||
@@ -1641,7 +1822,7 @@ async function streamAgentMessage(
|
||||
}
|
||||
|
||||
assistantMessage.status = 'error'
|
||||
assistantMessage.content = error?.message || t('agentAssistant.error')
|
||||
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
|
||||
markToolsDone(assistantMessage)
|
||||
refreshMessageList()
|
||||
} finally {
|
||||
@@ -2052,11 +2233,19 @@ function handlePageShow() {
|
||||
|
||||
// 处理输入框回车发送。
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter' || event.shiftKey) return
|
||||
if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return
|
||||
event.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing.value = false
|
||||
}
|
||||
|
||||
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
|
||||
watch(drawerWidth, () => {
|
||||
if (isOpen.value) syncAgentAssistantOpenState(true)
|
||||
@@ -2084,6 +2273,7 @@ onScopeDispose(clearPendingAttachments)
|
||||
onScopeDispose(cancelVoiceRecording)
|
||||
onScopeDispose(clearMessageScrollFrame)
|
||||
onScopeDispose(clearStreamPersistTimer)
|
||||
onScopeDispose(clearPendingStreamDelta)
|
||||
onScopeDispose(clearStreamRecoveryTimer)
|
||||
onScopeDispose(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -2130,11 +2320,11 @@ onScopeDispose(() => {
|
||||
<VMenu
|
||||
v-model="historyMenuOpen"
|
||||
:close-on-content-click="false"
|
||||
content-class="agent-assistant-history-overlay"
|
||||
location="bottom end"
|
||||
offset="8"
|
||||
max-width="360"
|
||||
:z-index="2603"
|
||||
:style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
|
||||
:z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
|
||||
@@ -2224,6 +2414,7 @@ onScopeDispose(() => {
|
||||
ref="messageListRef"
|
||||
class="agent-assistant-messages"
|
||||
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
|
||||
@scroll.passive="handleMessageScrollerScroll"
|
||||
>
|
||||
<div class="agent-assistant-messages__content">
|
||||
<div v-if="!hasMessages" class="agent-assistant-empty">
|
||||
@@ -2245,31 +2436,41 @@ onScopeDispose(() => {
|
||||
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="message.tools.length" class="agent-assistant-tools">
|
||||
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
|
||||
<VIcon
|
||||
:icon="
|
||||
tool.status === 'running' && message.status === 'streaming'
|
||||
? 'line-md:loading-twotone-loop'
|
||||
: 'mdi-check-circle-outline'
|
||||
"
|
||||
size="16"
|
||||
<div
|
||||
v-if="message.role === 'assistant' && (message.tools.length || message.content.trim())"
|
||||
class="agent-assistant-segments"
|
||||
>
|
||||
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
|
||||
<AgentMarkdownContent
|
||||
v-if="segment.type === 'text'"
|
||||
:content="segment.content"
|
||||
:streaming="message.status === 'streaming'"
|
||||
/>
|
||||
<span>{{ tool.message }}</span>
|
||||
</div>
|
||||
<div v-else class="agent-assistant-tool">
|
||||
<VIcon
|
||||
:icon="
|
||||
segment.tool.status === 'running' && message.status === 'streaming'
|
||||
? 'line-md:loading-twotone-loop'
|
||||
: 'mdi-check-circle-outline'
|
||||
"
|
||||
size="16"
|
||||
/>
|
||||
<span>{{ segment.tool.message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="agent-assistant-message__bubble markdown-body"
|
||||
v-html="renderMarkdown(message.content)"
|
||||
<AgentMarkdownContent
|
||||
v-else-if="message.content"
|
||||
:content="message.content"
|
||||
:streaming="message.status === 'streaming'"
|
||||
/>
|
||||
|
||||
<div v-if="message.choices.length" class="agent-assistant-choices">
|
||||
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
|
||||
<div class="agent-assistant-choice__bubble">
|
||||
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
|
||||
<div class="agent-assistant-choice__prompt markdown-body" v-html="renderMarkdown(choice.prompt)" />
|
||||
<AgentMarkdownContent :content="choice.prompt" variant="choice" />
|
||||
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
|
||||
<VIcon icon="mdi-check-circle-outline" size="16" />
|
||||
<span>{{
|
||||
@@ -2363,6 +2564,7 @@ onScopeDispose(() => {
|
||||
<div
|
||||
v-if="
|
||||
!message.content &&
|
||||
!message.segments.length &&
|
||||
!message.attachments.length &&
|
||||
!message.choices.length &&
|
||||
message.status === 'streaming'
|
||||
@@ -2454,6 +2656,8 @@ onScopeDispose(() => {
|
||||
:placeholder="inputPlaceholder"
|
||||
@input="handleInputChange"
|
||||
@keydown="handleInputKeydown"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
<IconBtn
|
||||
class="agent-assistant-record agent-assistant-surface-btn"
|
||||
@@ -2488,21 +2692,12 @@ onScopeDispose(() => {
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.agent-assistant-history-overlay {
|
||||
z-index: 2603 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
/* stylelint-disable no-descending-specificity */
|
||||
|
||||
.agent-assistant-panel {
|
||||
position: fixed;
|
||||
|
||||
/* Agent 会话层保持高于入口(2600)和业务弹窗,同时低于自身弹出菜单。 */
|
||||
z-index: 2601;
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
|
||||
@@ -2941,11 +3136,14 @@ onScopeDispose(() => {
|
||||
background: var(--agent-assistant-assistant-bg);
|
||||
}
|
||||
|
||||
.agent-assistant-tools {
|
||||
.agent-assistant-segments {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
gap: 0.5rem;
|
||||
inline-size: min(100%, 34rem);
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-assistant-segments .agent-assistant-message__bubble {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.agent-assistant-tool {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AgentAssistantEntry from './AgentAssistantEntry.vue'
|
||||
import AgentAssistantPanel from './AgentAssistantPanel.vue'
|
||||
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
|
||||
|
||||
@@ -9,33 +10,73 @@ const panelOpen = ref(false)
|
||||
const thinking = ref(false)
|
||||
const entryRef = ref<AgentAssistantEntryRef | null>(null)
|
||||
const { allowsDecorativeMotion } = useAppActivityLifecycle()
|
||||
const { themeClasses } = useTheme()
|
||||
const ASSISTANT_PREVIEW_INTERVAL = 125
|
||||
let assistantPreviewTimer: number | null = null
|
||||
let assistantPreviewPendingValue = ''
|
||||
let assistantPreviewLastShownAt = 0
|
||||
let assistantPreviewHasShown = false
|
||||
|
||||
function clearAssistantPreviewTimer() {
|
||||
if (assistantPreviewTimer === null) return
|
||||
|
||||
window.clearTimeout(assistantPreviewTimer)
|
||||
assistantPreviewTimer = null
|
||||
}
|
||||
|
||||
function showPendingAssistantPreview() {
|
||||
assistantPreviewTimer = null
|
||||
if (panelOpen.value || !assistantPreviewPendingValue) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
|
||||
assistantPreviewLastShownAt = performance.now()
|
||||
assistantPreviewHasShown = true
|
||||
}
|
||||
|
||||
// 打开 Agent 面板并清空入口预览气泡。
|
||||
function openPanel() {
|
||||
panelOpen.value = true
|
||||
assistantPreviewPendingValue = ''
|
||||
clearAssistantPreviewTimer()
|
||||
entryRef.value?.clearBubbles()
|
||||
}
|
||||
|
||||
// 在面板关闭时展示助手回复预览。
|
||||
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局。
|
||||
function handleAssistantPreview(value: string) {
|
||||
if (panelOpen.value) return
|
||||
|
||||
entryRef.value?.showAssistantReplyPreview(value)
|
||||
assistantPreviewPendingValue = value
|
||||
const elapsed = performance.now() - assistantPreviewLastShownAt
|
||||
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
|
||||
clearAssistantPreviewTimer()
|
||||
showPendingAssistantPreview()
|
||||
return
|
||||
}
|
||||
|
||||
if (assistantPreviewTimer !== null) return
|
||||
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
onScopeDispose(clearAssistantPreviewTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AgentAssistantEntry
|
||||
ref="entryRef"
|
||||
:active="!panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
:thinking="thinking"
|
||||
@open="openPanel"
|
||||
/>
|
||||
<AgentAssistantPanel
|
||||
v-model="panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
@assistant-preview="handleAssistantPreview"
|
||||
@thinking-change="thinking = $event"
|
||||
/>
|
||||
<!-- 脱离 .v-application 的层叠上下文,确保弹窗打开时入口、消息气泡和面板仍在最上层。 -->
|
||||
<Teleport to="body">
|
||||
<div class="agent-assistant-layer" :class="themeClasses">
|
||||
<AgentAssistantEntry
|
||||
ref="entryRef"
|
||||
:active="!panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
:thinking="thinking"
|
||||
@open="openPanel"
|
||||
/>
|
||||
<AgentAssistantPanel
|
||||
v-model="panelOpen"
|
||||
:motion-active="allowsDecorativeMotion"
|
||||
@assistant-preview="handleAssistantPreview"
|
||||
@thinking-change="thinking = $event"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string
|
||||
streaming?: boolean
|
||||
variant?: 'choice' | 'message'
|
||||
}>(),
|
||||
{
|
||||
streaming: false,
|
||||
variant: 'message',
|
||||
},
|
||||
)
|
||||
|
||||
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
|
||||
|
||||
const renderedHtml = shallowRef('')
|
||||
let renderTimer: number | null = null
|
||||
let lastRenderedAt = 0
|
||||
let hasRendered = false
|
||||
|
||||
function clearRenderTimer() {
|
||||
if (renderTimer === null) return
|
||||
|
||||
window.clearTimeout(renderTimer)
|
||||
renderTimer = null
|
||||
}
|
||||
|
||||
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
|
||||
function renderContent(immediate = false) {
|
||||
const now = performance.now()
|
||||
const elapsed = now - lastRenderedAt
|
||||
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
|
||||
clearRenderTimer()
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = now
|
||||
hasRendered = true
|
||||
return
|
||||
}
|
||||
|
||||
if (renderTimer !== null) return
|
||||
renderTimer = window.setTimeout(() => {
|
||||
renderTimer = null
|
||||
renderedHtml.value = renderAgentMarkdown(props.content)
|
||||
lastRenderedAt = performance.now()
|
||||
hasRendered = true
|
||||
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
() => renderContent(!props.streaming),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.streaming,
|
||||
streaming => {
|
||||
if (!streaming) renderContent(true)
|
||||
},
|
||||
)
|
||||
|
||||
onScopeDispose(clearRenderTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="renderedHtml"
|
||||
class="markdown-body"
|
||||
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
</template>
|
||||
@@ -68,4 +68,44 @@ describe('AgentAssistantEntry lifecycle motion', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('updates an existing assistant preview without recreating its resize observer', async () => {
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
const resizeObserverConstructor = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor() {
|
||||
resizeObserverConstructor()
|
||||
}
|
||||
|
||||
observe = observe
|
||||
disconnect = disconnect
|
||||
},
|
||||
)
|
||||
const wrapper = shallowMount(AgentAssistantEntry, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentPetStage: true,
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
active: true,
|
||||
motionActive: true,
|
||||
},
|
||||
})
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第一段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.vm.showAssistantReplyPreview('第二段')
|
||||
await nextTick()
|
||||
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,6 +27,12 @@ interface MockServerSession {
|
||||
messages: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
const agentMarkdownContentStub = {
|
||||
props: ['content', 'variant'],
|
||||
template:
|
||||
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
|
||||
}
|
||||
|
||||
// 构造符合 Agent 标准响应包装的 fetch 返回值。
|
||||
function createAgentResponse(data: unknown) {
|
||||
return {
|
||||
@@ -111,6 +117,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
@@ -203,6 +210,7 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
@@ -237,4 +245,238 @@ describe('AgentAssistantPanel stream recovery', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not send when Enter confirms an IME composition', async () => {
|
||||
const fetchMock = vi.fn(async () => createAgentResponse([]))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await flushPromises()
|
||||
fetchMock.mockClear()
|
||||
await textarea.setValue('搜索电影')
|
||||
await textarea.trigger('compositionstart')
|
||||
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
|
||||
await textarea.trigger('compositionend')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
expect(fetchMock).toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders interleaved assistant text and tool events in their SSE order', async () => {
|
||||
const serverSessionId = 'web-agent:ordered-segments'
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: serverSessionId },
|
||||
{ type: 'delta', content: '先检查服务器。' },
|
||||
{ type: 'tool', message: '(执行了 1 条命令)' },
|
||||
{ type: 'delta', content: '检查完成,没有发现错误。' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
|
||||
return createAgentResponse([])
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('检查服务器')
|
||||
await textarea.trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(3)
|
||||
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[0].text()).toContain('先检查服务器。')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
|
||||
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
|
||||
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
|
||||
|
||||
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
|
||||
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
|
||||
expect(savedMessages.at(-1)).toMatchObject({
|
||||
content: '先检查服务器。检查完成,没有发现错误。',
|
||||
segments: [
|
||||
{ type: 'text', content: '先检查服务器。' },
|
||||
{ type: 'tool', toolIndex: 0 },
|
||||
{ type: 'text', content: '检查完成,没有发现错误。' },
|
||||
],
|
||||
})
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
|
||||
{ type: 'delta', content: '最终结论' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'delta', content: '\n\n' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('检查配置')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(2)
|
||||
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[0].text()).toBe('最终结论')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:tool-groups' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'tool', message: '(查看了 1 个目录)' },
|
||||
{ type: 'tool', message: '(查询了 1 次数据)' },
|
||||
{ type: 'delta', content: '继续分析。' },
|
||||
{ type: 'tool', message: '(读取了 1 个文件)' },
|
||||
{ type: 'tool', message: '(读取了 1 个文件)' },
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('分析插件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
|
||||
expect(renderedSegments).toHaveLength(3)
|
||||
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
|
||||
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
|
||||
expect(renderedSegments[1].text()).toBe('继续分析。')
|
||||
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
|
||||
const streamEvents = [
|
||||
{ type: 'start', session_id: 'web-agent:coalesced' },
|
||||
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
|
||||
{ type: 'done' },
|
||||
]
|
||||
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
|
||||
return new Response(streamBody, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
}
|
||||
return createAgentResponse([])
|
||||
}),
|
||||
)
|
||||
|
||||
const wrapper = shallowMount(AgentAssistantPanel, {
|
||||
props: { modelValue: true },
|
||||
global: {
|
||||
stubs: {
|
||||
AgentMarkdownContent: agentMarkdownContentStub,
|
||||
IconBtn: { template: '<button><slot /></button>' },
|
||||
PerfectScrollbar: { template: '<div><slot /></div>' },
|
||||
VIcon: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await wrapper.find('textarea').setValue('测试突发事件')
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
|
||||
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
|
||||
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
|
||||
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
|
||||
|
||||
vi.mock('vuetify', () => ({
|
||||
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useAppActivityLifecycle', () => ({
|
||||
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
|
||||
}))
|
||||
|
||||
describe('AgentAssistantWidget layering', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body
|
||||
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
|
||||
.forEach(element => element.remove())
|
||||
})
|
||||
|
||||
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
|
||||
const host = document.createElement('div')
|
||||
host.className = 'agent-assistant-test-host v-application'
|
||||
document.body.append(host)
|
||||
|
||||
const wrapper = mount(AgentAssistantWidget, {
|
||||
attachTo: host,
|
||||
global: {
|
||||
stubs: {
|
||||
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
|
||||
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
|
||||
|
||||
expect(layer).not.toBeNull()
|
||||
expect(host.contains(layer)).toBe(false)
|
||||
expect(layer).toHaveClass('v-theme--test')
|
||||
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
|
||||
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('reserves the highest CSS stacking levels in assistant display order', () => {
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
|
||||
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
|
||||
})
|
||||
|
||||
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
|
||||
vi.useFakeTimers()
|
||||
const showAssistantReplyPreview = vi.fn()
|
||||
const entryStub = defineComponent({
|
||||
setup(_props, { expose }) {
|
||||
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
|
||||
return () => h('div', { 'data-agent-assistant-entry': '' })
|
||||
},
|
||||
})
|
||||
const panelStub = defineComponent({
|
||||
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
|
||||
setup() {
|
||||
return () => h('div', { 'data-agent-assistant-panel': '' })
|
||||
},
|
||||
})
|
||||
const wrapper = mount(AgentAssistantWidget, {
|
||||
global: {
|
||||
stubs: {
|
||||
AgentAssistantEntry: entryStub,
|
||||
AgentAssistantPanel: panelStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
const panel = wrapper.findComponent(panelStub)
|
||||
|
||||
panel.vm.$emit('assistant-preview', '第一段')
|
||||
panel.vm.$emit('assistant-preview', '第二段')
|
||||
panel.vm.$emit('assistant-preview', '最终预览')
|
||||
await nextTick()
|
||||
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(125)
|
||||
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
|
||||
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
|
||||
|
||||
describe('AgentMarkdownContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('throttles streaming Markdown and renders the final content immediately', async () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '**开始**', streaming: true },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).toContain('<strong>开始</strong>')
|
||||
await wrapper.setProps({ content: '**开始继续**' })
|
||||
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(96)
|
||||
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
|
||||
|
||||
await wrapper.setProps({ content: '**最终结果**', streaming: false })
|
||||
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
|
||||
})
|
||||
|
||||
it('escapes raw HTML from Agent output', () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '<img src=x onerror="alert(1)">' },
|
||||
})
|
||||
|
||||
expect(wrapper.html()).not.toContain('<img')
|
||||
expect(wrapper.text()).toContain('<img src=x onerror=')
|
||||
})
|
||||
|
||||
it('does not render a bubble for whitespace-only Markdown', () => {
|
||||
const wrapper = mount(AgentMarkdownContent, {
|
||||
props: { content: '\n\n' },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.agent-assistant-message__bubble').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
+11
-29
@@ -11,9 +11,9 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
|
||||
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
|
||||
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
|
||||
type RecognitionCacheSource = 'tmdb' | 'douban'
|
||||
|
||||
const MOBILE_CACHE_PAGE_SIZE = 20
|
||||
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
@@ -22,18 +22,8 @@ const $toast = useToast()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
|
||||
const isMobile = computed(() => display.smAndDown.value)
|
||||
const recognitionSource = computed<RecognitionCacheSource>(() =>
|
||||
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
|
||||
)
|
||||
const recognitionSourceName = computed(() =>
|
||||
recognitionSource.value === 'douban'
|
||||
? t('setting.cache.recognitionSource.douban')
|
||||
: t('setting.cache.recognitionSource.themoviedb'),
|
||||
)
|
||||
const recognitionIdLabel = computed(() =>
|
||||
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
|
||||
)
|
||||
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
|
||||
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
|
||||
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
|
||||
const recognitionFilterPlaceholder = computed(() =>
|
||||
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
|
||||
)
|
||||
@@ -106,12 +96,12 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
|
||||
done(mobileHasMore.value ? 'ok' : 'empty')
|
||||
}
|
||||
|
||||
/** 加载当前识别数据源对应的缓存列表。 */
|
||||
/** 加载 TMDB 主识别缓存列表。 */
|
||||
async function loadCacheData(showSuccess = false) {
|
||||
const requestId = ++cacheLoadRequestId
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
|
||||
if (requestId !== cacheLoadRequestId) return
|
||||
const responseData = response.data ?? {
|
||||
count: 0,
|
||||
@@ -139,7 +129,7 @@ async function loadCacheData(showSuccess = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空当前识别数据源的全部识别缓存。 */
|
||||
/** 清空全部 TMDB 主识别缓存。 */
|
||||
async function clearAllCache() {
|
||||
const confirmed = await createConfirm({
|
||||
type: 'warn',
|
||||
@@ -150,7 +140,7 @@ async function clearAllCache() {
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
|
||||
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
$toast.success(response.message || t('setting.cache.clearSuccess'))
|
||||
await loadCacheData()
|
||||
@@ -163,10 +153,10 @@ async function clearAllCache() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求当前识别数据源接口删除指定识别缓存。 */
|
||||
/** 请求 TMDB 接口删除指定识别缓存。 */
|
||||
async function deleteCacheItem(key: string) {
|
||||
const response = (await api.delete(
|
||||
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
|
||||
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
|
||||
)) as unknown as ApiResponse
|
||||
if (!response.success) throw new Error(response.message)
|
||||
}
|
||||
@@ -217,10 +207,9 @@ function getPosterUrl(item: RecognitionCacheItem): string {
|
||||
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 获取当前识别数据源对应的媒体 ID。 */
|
||||
/** 获取识别缓存对应的 TMDB ID。 */
|
||||
function getRecognitionId(item: RecognitionCacheItem): string {
|
||||
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
|
||||
return recognitionId ? String(recognitionId) : ''
|
||||
return item.tmdb_id ? String(item.tmdb_id) : ''
|
||||
}
|
||||
|
||||
/** 判断识别缓存条目是否包含有效媒体 ID。 */
|
||||
@@ -260,13 +249,6 @@ onMounted(() => {
|
||||
watch([searchFilter, statusFilter], () => {
|
||||
resetMobilePagination()
|
||||
})
|
||||
|
||||
watch(recognitionSource, () => {
|
||||
searchFilter.value = ''
|
||||
statusFilter.value = 'all'
|
||||
selectedItems.value = []
|
||||
void loadCacheData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -22,13 +22,13 @@ vi.mock('vue-toastification', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
async function renderRecognitionCachePanel() {
|
||||
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
|
||||
return renderWithProviders(RecognitionCachePanel, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: {
|
||||
GLOBAL_IMAGE_CACHE: false,
|
||||
RECOGNIZE_SOURCE: 'themoviedb',
|
||||
RECOGNIZE_SOURCE: recognitionSource,
|
||||
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
|
||||
},
|
||||
},
|
||||
@@ -79,4 +79,21 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
|
||||
|
||||
expect(screen.queryByText('共享识别')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
data: {
|
||||
count: 0,
|
||||
recognized: 0,
|
||||
unrecognized: 0,
|
||||
shared_recognized: 0,
|
||||
shared_recognize_enabled: false,
|
||||
data: [],
|
||||
},
|
||||
})
|
||||
|
||||
await renderRecognitionCachePanel('douban')
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const STORAGE_ACCENT_COLOR_MAP = {
|
||||
u115: '#17B26A',
|
||||
rclone: '#6675FF',
|
||||
alist: '#12B8D7',
|
||||
alistgo: '#1BA0D8',
|
||||
smb: '#3B82F6',
|
||||
}
|
||||
|
||||
|
||||
@@ -174,24 +174,6 @@ async function deleteDownload() {
|
||||
</div>
|
||||
|
||||
<VCardText class="downloading-card__body">
|
||||
<div class="downloading-card__chips">
|
||||
<VChip
|
||||
v-if="mediaTypeText"
|
||||
:prepend-icon="mediaTypeIcon"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ mediaTypeText }}
|
||||
</VChip>
|
||||
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||
{{ sourceSiteText }}
|
||||
</VChip>
|
||||
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||
{{ sizeText }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="downloading-card__heading">
|
||||
<div class="downloading-card__title" :title="mediaTitle">
|
||||
<span>{{ mediaTitle }}</span>
|
||||
@@ -202,6 +184,18 @@ async function deleteDownload() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="downloading-card__chips">
|
||||
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" size="x-small" variant="tonal">
|
||||
{{ mediaTypeText }}
|
||||
</VChip>
|
||||
<VChip prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||
{{ sizeText }}
|
||||
</VChip>
|
||||
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||
{{ sourceSiteText }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div v-if="progressValue > 0" class="downloading-card__progress">
|
||||
<div class="downloading-card__progress-label">
|
||||
<span>
|
||||
@@ -323,12 +317,15 @@ async function deleteDownload() {
|
||||
.downloading-card__chips {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip) {
|
||||
max-inline-size: calc(50% - 0.2rem);
|
||||
flex: 0 1 auto;
|
||||
min-inline-size: 0;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip__content) {
|
||||
@@ -499,14 +496,6 @@ async function deleteDownload() {
|
||||
padding-inline: 0.65rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip) {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip:first-child:last-child) {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.downloading-card__actions :deep(.v-btn) {
|
||||
block-size: 2.25rem;
|
||||
inline-size: 2.25rem;
|
||||
|
||||
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
|
||||
const imageRequestRevision = ref(0)
|
||||
|
||||
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
|
||||
const hasCompletedPosterReveal = ref(false)
|
||||
const POSTER_REVEAL_FALLBACK_MS = 400
|
||||
let posterRevealFallbackTimer: number | null = null
|
||||
|
||||
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
|
||||
function resetPosterRevealState() {
|
||||
hasCompletedPosterReveal.value = false
|
||||
if (posterRevealFallbackTimer === null) return
|
||||
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
|
||||
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
|
||||
function completePosterReveal(revision: number, usesFallback: boolean) {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
if (posterRevealFallbackTimer !== null) {
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
hasCompletedPosterReveal.value = true
|
||||
}
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
@@ -399,6 +427,54 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
const hasLoadedRealPoster = computed(
|
||||
() =>
|
||||
Boolean(props.media?.poster_path) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
|
||||
)
|
||||
|
||||
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
|
||||
const imageRequest = computed(() => {
|
||||
const revision = imageRequestRevision.value
|
||||
const src = getImgUrl.value
|
||||
const usesFallback = imageLoadError.value || !props.media?.poster_path
|
||||
|
||||
return {
|
||||
handleError: () => {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = true
|
||||
imageRequestRevision.value += 1
|
||||
},
|
||||
handleLoad: () => {
|
||||
if (revision !== imageRequestRevision.value) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = true
|
||||
if (!usesFallback) {
|
||||
posterRevealFallbackTimer = window.setTimeout(
|
||||
() => completePosterReveal(revision, usesFallback),
|
||||
POSTER_REVEAL_FALLBACK_MS,
|
||||
)
|
||||
}
|
||||
},
|
||||
handleReveal: (event: TransitionEvent) => {
|
||||
if (
|
||||
event.propertyName !== 'opacity' ||
|
||||
!(event.target instanceof Element) ||
|
||||
!event.target.matches('.v-img__img')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
completePosterReveal(revision, usesFallback)
|
||||
},
|
||||
key: revision,
|
||||
src,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
@@ -425,13 +501,21 @@ watch(isSubscribed, subscribed => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
[() => props.media, () => props.media?.poster_path],
|
||||
([media], [previousMedia]) => {
|
||||
imageRequestRevision.value += 1
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = false
|
||||
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
|
||||
if (media === previousMedia) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -445,6 +529,7 @@ onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPosterRevealState()
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
@@ -461,6 +546,7 @@ onBeforeUnmount(() => {
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:ripple="false"
|
||||
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
@@ -470,12 +556,14 @@ onBeforeUnmount(() => {
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<VImg
|
||||
:key="imageRequest.key"
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
:src="imageRequest.src"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@load="imageRequest.handleLoad"
|
||||
@error="imageRequest.handleError"
|
||||
@transitionend="imageRequest.handleReveal"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -35,11 +35,7 @@ const $toast = useToast()
|
||||
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
@@ -71,9 +67,12 @@ function closeInstallProgress() {
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
await updateAccentColor()
|
||||
}
|
||||
|
||||
function imageFailed() {
|
||||
imageLoadError.value = true
|
||||
resetAccentColor()
|
||||
}
|
||||
|
||||
// 计算图标路径
|
||||
@@ -237,7 +236,7 @@ onUnmounted(() => {
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:style="accentStyle"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
@@ -280,7 +279,7 @@ onUnmounted(() => {
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
@error="imageFailed"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -52,11 +52,7 @@ const cardRatingSummary = computed(() =>
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
@@ -108,9 +104,12 @@ watch(
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
await updateAccentColor()
|
||||
}
|
||||
|
||||
function imageFailed() {
|
||||
imageLoadError.value = true
|
||||
resetAccentColor()
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
@@ -646,7 +645,7 @@ watch(
|
||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
||||
:style="accentStyle"
|
||||
:ripple="!props.sortable"
|
||||
>
|
||||
<div class="plugin-card__banner flex-grow">
|
||||
@@ -677,7 +676,7 @@ watch(
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@load="imageLoaded"
|
||||
@error="imageLoadError = true"
|
||||
@error="imageFailed"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import alipan_png from '@images/misc/alipan.webp'
|
||||
import u115_png from '@images/misc/u115.png'
|
||||
import rclone_png from '@images/misc/rclone.png'
|
||||
import alist_png from '@images/misc/openlist.svg'
|
||||
import alistgo_png from '@images/misc/alist.svg'
|
||||
import custom_png from '@images/misc/database.png'
|
||||
import smb_png from '@images/misc/smb.png'
|
||||
import api from '@/api'
|
||||
@@ -58,6 +59,7 @@ function openStorageDialog() {
|
||||
u115: U115AuthDialog,
|
||||
rclone: RcloneConfigDialog,
|
||||
alist: AlistConfigDialog,
|
||||
alistgo: AlistConfigDialog,
|
||||
smb: SmbConfigDialog,
|
||||
}
|
||||
|
||||
@@ -69,7 +71,9 @@ function openStorageDialog() {
|
||||
const dialog = dialogMap[props.storage.type] || StorageCustomConfigDialog
|
||||
const dialogProps = dialog === StorageCustomConfigDialog
|
||||
? { storage: props.storage }
|
||||
: { conf: props.storage.config || {} }
|
||||
: dialog === AlistConfigDialog
|
||||
? { conf: props.storage.config || {}, type: props.storage.type }
|
||||
: { conf: props.storage.config || {} }
|
||||
|
||||
openSharedDialog(
|
||||
dialog,
|
||||
@@ -94,6 +98,8 @@ const getIcon = computed(() => {
|
||||
return rclone_png
|
||||
case 'alist':
|
||||
return alist_png
|
||||
case 'alistgo':
|
||||
return alistgo_png
|
||||
case 'smb':
|
||||
return smb_png
|
||||
default:
|
||||
|
||||
@@ -140,10 +140,16 @@ describe('DownloadingCard display and pause state', () => {
|
||||
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
|
||||
it('keeps torrent size visible while resolving explicit site names and tracker hostnames', async () => {
|
||||
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
|
||||
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
|
||||
const heading = container.querySelector('.downloading-card__heading')!
|
||||
const chipContainer = container.querySelector('.downloading-card__chips')!
|
||||
const chips = [...chipContainer.querySelectorAll('.v-chip')]
|
||||
|
||||
expect(heading.nextElementSibling).toBe(chipContainer)
|
||||
expect(chips.map(chip => chip.textContent?.trim())).toEqual(['电视剧', '1.00 KB', 'M-Team'])
|
||||
expect(chips.every(chip => !chip.classList.contains('text-primary'))).toBe(true)
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
|
||||
@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, reactive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -89,6 +89,50 @@ interface RenderCardOptions {
|
||||
superUser?: boolean
|
||||
}
|
||||
|
||||
interface ControlledImageRequest {
|
||||
/** 模拟当前 VImg 请求失败。 */
|
||||
fail: () => void
|
||||
/** 模拟当前 VImg 请求成功。 */
|
||||
load: () => void
|
||||
/** 模拟当前 VImg 的 opacity 淡入完成。 */
|
||||
reveal: () => void
|
||||
/** 当前 VImg 实例发起的图片地址。 */
|
||||
src: string
|
||||
}
|
||||
|
||||
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
|
||||
function createControlledImageStub(requests: ControlledImageRequest[]) {
|
||||
return defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
setup(props, { emit, slots }) {
|
||||
const src = props.src ?? ''
|
||||
const imageElement = ref<HTMLImageElement | null>(null)
|
||||
const request = {
|
||||
fail: () => emit('error', src),
|
||||
load: () => emit('load', src),
|
||||
reveal: () => {
|
||||
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
|
||||
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
|
||||
imageElement.value?.dispatchEvent(event)
|
||||
},
|
||||
src,
|
||||
}
|
||||
requests.push(request)
|
||||
|
||||
return () =>
|
||||
h('div', { 'data-src': src }, [
|
||||
h('img', {
|
||||
ref: imageElement,
|
||||
class: 'v-img__img',
|
||||
}),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
|
||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
return renderWithProviders(MediaCard, {
|
||||
@@ -414,17 +458,22 @@ describe('MediaCard', () => {
|
||||
expect(dialogProps.selected).toEqual([])
|
||||
})
|
||||
|
||||
it('loads matching TV seasons before opening the subscription dialog', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
|
||||
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
|
||||
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
|
||||
const subscribeListRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
]),
|
||||
subscribeListHandler(
|
||||
[
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
],
|
||||
200,
|
||||
subscribeListRequest,
|
||||
),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
||||
),
|
||||
@@ -443,6 +492,19 @@ describe('MediaCard', () => {
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||
|
||||
media.poster_path = '/original/updated.jpg'
|
||||
mocks.openSharedDialog.mockClear()
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(updatedDialogProps).toMatchObject({
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||
@@ -533,35 +595,84 @@ describe('MediaCard', () => {
|
||||
type: '电视剧',
|
||||
vote_average: 8.6,
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-src': props.src }, [
|
||||
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
|
||||
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const VImgStub = createControlledImageStub(requests)
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
requests[0].load()
|
||||
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
requests[0].reveal()
|
||||
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||
expect(container).toHaveTextContent('TV')
|
||||
expect(container).toHaveTextContent('8.6')
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
|
||||
requests[0].fail()
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
||||
)
|
||||
await waitFor(() => expect(requests.some(request => request.src.includes('no-image'))).toBe(true))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
const fallbackRequest = requests.find(request => request.src.includes('no-image'))
|
||||
fallbackRequest?.load()
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
fallbackRequest?.reveal()
|
||||
await Promise.resolve()
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
})
|
||||
|
||||
it('keeps placeholder-only cards inside the renderer after the image loads', async () => {
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||
})
|
||||
|
||||
requests[0].load()
|
||||
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('ring-1'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
})
|
||||
|
||||
it('ignores a previous poster load after the card is reused for another media item', async () => {
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
|
||||
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
|
||||
const { container, rerender } = await renderWithProviders(MediaCard, {
|
||||
props: { media: mediaA, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||
})
|
||||
|
||||
requests[0].load()
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
await rerender({ media: mediaB, width: '9rem' })
|
||||
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
requests[0].reveal()
|
||||
await Promise.resolve()
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
|
||||
currentRequest?.load()
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
currentRequest?.reveal()
|
||||
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||
})
|
||||
|
||||
it('renders the AniList source badge after the poster loads', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -31,8 +32,8 @@ vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
@@ -47,12 +48,13 @@ const plugin: Plugin = {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
template:
|
||||
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||
})
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
@@ -232,7 +234,20 @@ describe('PluginAppCard rating badge', () => {
|
||||
const image = screen.getByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
mocks.accentFromImage.mockResolvedValueOnce('#654321')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#654321')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -34,8 +35,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
@@ -55,12 +56,13 @@ const plugin: Plugin = {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
template:
|
||||
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||
})
|
||||
|
||||
describe('PluginCard lifecycle actions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
@@ -362,6 +364,7 @@ describe('PluginCard lifecycle actions', () => {
|
||||
})
|
||||
|
||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||
mocks.accentFromImage.mockResolvedValueOnce(undefined).mockResolvedValueOnce('#123456')
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||
@@ -372,7 +375,19 @@ describe('PluginCard lifecycle actions', () => {
|
||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
await fireEvent.contextMenu(image)
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||
await fireEvent.contextMenu(image)
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||
).toBe('')
|
||||
await fireEvent.click(authorImage)
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
|
||||
@@ -25,8 +25,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: mocks.openSharedDialog,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
|
||||
@@ -299,7 +299,6 @@ onMounted(() => {
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
|
||||
@@ -241,7 +241,6 @@ onMounted(() => {
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
:rules="[numberValidator]"
|
||||
|
||||
@@ -15,6 +15,10 @@ const props = defineProps({
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'alist',
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
@@ -29,7 +33,7 @@ async function handleDone() {
|
||||
// 重置配置
|
||||
async function handleReset() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('/storage/reset/alist')
|
||||
const result: { [key: string]: any } = await api.get(`/storage/reset/${props.type}`)
|
||||
if (result.success) {
|
||||
// 重置成功
|
||||
handleDone()
|
||||
@@ -62,7 +66,7 @@ const sourceItems = [
|
||||
// 保存alist设置
|
||||
async function savaAlistConfig() {
|
||||
try {
|
||||
await api.post(`storage/save/alist`, props.conf)
|
||||
await api.post(`storage/save/${props.type}`, props.conf)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
@@ -78,7 +82,7 @@ async function savaAlistConfig() {
|
||||
<VIcon icon="mdi-cog-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('dialog.alistConfig.title') }}
|
||||
{{ t(`dialog.${props.type}Config.title`) }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
@@ -87,8 +91,8 @@ async function savaAlistConfig() {
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="props.conf.url"
|
||||
:hint="t('dialog.alistConfig.serverUrl')"
|
||||
:label="t('dialog.alistConfig.serverUrl')"
|
||||
:hint="t(`dialog.${props.type}Config.serverUrl`)"
|
||||
:label="t(`dialog.${props.type}Config.serverUrl`)"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
|
||||
@@ -151,7 +151,7 @@ async function registerPassKey() {
|
||||
} else if (error.message?.includes('start failed')) {
|
||||
$toast.error(t('login.passkeyLoginStartFailed'))
|
||||
} else if (error.response) {
|
||||
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
$toast.error(error.response.data?.message || error.response.data?.detail || t('profile.passkeyRegisterFailed'))
|
||||
} else {
|
||||
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const repoText = ref('')
|
||||
const newRepoUrl = ref('')
|
||||
const editingIndex = ref<number | null>(null)
|
||||
const editingUrl = ref('')
|
||||
const syncingWiki = ref(false)
|
||||
const syncingSources = ref(false)
|
||||
|
||||
const emit = defineEmits(['save', 'close'])
|
||||
|
||||
@@ -139,10 +139,10 @@ async function saveHandle() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
|
||||
async function syncWikiRepos() {
|
||||
/** 同步公开插件源清单并写入配置。 */
|
||||
async function syncPluginSources() {
|
||||
try {
|
||||
syncingWiki.value = true
|
||||
syncingSources.value = true
|
||||
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
|
||||
|
||||
if (result.success) {
|
||||
@@ -164,7 +164,7 @@ async function syncWikiRepos() {
|
||||
console.log(error)
|
||||
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
|
||||
} finally {
|
||||
syncingWiki.value = false
|
||||
syncingSources.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,11 +483,11 @@ onMounted(() => {
|
||||
color="success"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-cloud-sync-outline"
|
||||
:loading="syncingWiki"
|
||||
:disabled="syncingWiki"
|
||||
@click="syncWikiRepos"
|
||||
:loading="syncingSources"
|
||||
:disabled="syncingSources"
|
||||
@click="syncPluginSources"
|
||||
>
|
||||
{{ t('dialog.pluginMarketSetting.syncWiki') }}
|
||||
{{ t('dialog.pluginMarketSetting.syncSources') }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
@@ -506,6 +506,8 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.plugin-market-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -570,9 +572,12 @@ onMounted(() => {
|
||||
.plugin-market-mode-switch {
|
||||
display: inline-flex;
|
||||
padding: 0.125rem;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-control-radius);
|
||||
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
@@ -582,7 +587,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: transparent;
|
||||
block-size: 2.25rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
@@ -594,7 +599,7 @@ onMounted(() => {
|
||||
color 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.07);
|
||||
background: var(--app-grouped-list-hover-background);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
@@ -604,7 +609,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
background: var(--app-grouped-list-active-background);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
@@ -624,13 +629,23 @@ onMounted(() => {
|
||||
|
||||
.plugin-market-list-wrap {
|
||||
flex: 1;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-grouped-list-radius);
|
||||
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
min-block-size: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.plugin-market-repo-list {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: inherit !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.plugin-market-repo-item {
|
||||
@@ -681,7 +696,12 @@ onMounted(() => {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
border: var(--app-grouped-list-border);
|
||||
border-radius: var(--app-grouped-list-radius);
|
||||
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
min-block-size: 0;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
|
||||
@@ -1514,7 +1514,6 @@ onUnmounted(() => {
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="transferForm.media_id"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:disabled="transferForm.type_name === ''"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
|
||||
@@ -147,7 +147,6 @@ watch(mediaSource, () => {
|
||||
<VCol cols="12" md="4">
|
||||
<VTextField
|
||||
v-model="mediaId"
|
||||
class="app-responsive-input--keep-append-action"
|
||||
:disabled="mediaType === ''"
|
||||
:label="mediaIdLabel"
|
||||
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||
|
||||
@@ -69,10 +69,9 @@ async function updateSiteCookie() {
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
const detail = error?.response?.data?.detail
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
(typeof detail === 'string' ? detail : error?.message) ||
|
||||
(typeof error?.response?.data?.detail === 'string' ? error.response.data.detail : error?.message) ||
|
||||
t('dialog.siteCookieUpdate.requestFailed')
|
||||
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { cwd } from 'node:process'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
|
||||
|
||||
function getStyleRule(selector: string) {
|
||||
const ruleStart = dialogSource.indexOf(`${selector} {`)
|
||||
const ruleEnd = dialogSource.indexOf('\n}', ruleStart)
|
||||
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||
|
||||
return dialogSource.slice(ruleStart, ruleEnd)
|
||||
}
|
||||
|
||||
describe('PluginMarketSettingDialog theme surfaces', () => {
|
||||
it('uses shared theme tokens for the view switch and editor containers', () => {
|
||||
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
|
||||
const listWrapRule = getStyleRule('.plugin-market-list-wrap')
|
||||
const textareaRule = getStyleRule('.plugin-market-textarea-field')
|
||||
|
||||
expect(modeSwitchRule).toContain('border: var(--app-grouped-list-border)')
|
||||
expect(modeSwitchRule).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
|
||||
expect(modeSwitchRule).toContain('background: var(--app-grouped-list-background)')
|
||||
expect(dialogSource).toContain('background: var(--app-grouped-list-hover-background)')
|
||||
expect(dialogSource).toContain('background: var(--app-grouped-list-active-background)')
|
||||
expect(listWrapRule).toContain('border-radius: var(--app-grouped-list-radius)')
|
||||
expect(listWrapRule).toContain('background: var(--app-grouped-list-background)')
|
||||
expect(textareaRule).toContain('background: var(--app-grouped-list-background)')
|
||||
})
|
||||
|
||||
it('uses plugin-source wording instead of exposing the Wiki implementation detail', () => {
|
||||
expect(dialogSource).toContain("t('dialog.pluginMarketSetting.syncSources')")
|
||||
expect(dialogSource).not.toContain("t('dialog.pluginMarketSetting.syncWiki')")
|
||||
})
|
||||
})
|
||||
@@ -220,7 +220,9 @@ describe('SiteImportDialog', () => {
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
const resultCloseButton = screen.getByText('关闭').closest('button')
|
||||
expect(resultCloseButton).not.toBeNull()
|
||||
await fireEvent.click(resultCloseButton!)
|
||||
expect(events.update).toHaveBeenCalledWith(false)
|
||||
expect(events.importSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -148,6 +148,15 @@ describe('SiteUserDataDialog projections', () => {
|
||||
expect((history.options.xaxis as { categories: string[] }).categories).toEqual(['2026-07-17', '2026-07-19'])
|
||||
expect((history.options.theme as { mode: string }).mode).toBe('light')
|
||||
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
|
||||
expect((history.options.tooltip as { x: { formatter: (value: string) => string } }).x.formatter('2026-07-19')).toBe(
|
||||
new Date('2026-07-19').toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
)
|
||||
expect((history.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(1234)).toBe(
|
||||
`${(1234).toLocaleString()} GB`,
|
||||
)
|
||||
expect(
|
||||
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
|
||||
).toBe(
|
||||
@@ -174,6 +183,9 @@ describe('SiteUserDataDialog projections', () => {
|
||||
expect((seeding.options.tooltip as { x: { formatter: (value: number) => string } }).x.formatter(1234)).toBe(
|
||||
`数量:${(1234).toLocaleString()}`,
|
||||
)
|
||||
expect((seeding.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(2048)).toBe(
|
||||
`${(2048).toLocaleString()} GB`,
|
||||
)
|
||||
expect((seeding.options.xaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1.6)).toBe(
|
||||
'2',
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = defineProps<{
|
||||
translationStrength: number
|
||||
/** 路由变化标识,用于在页面内容稳定后重新发现高价值表面。 */
|
||||
routeKey: string
|
||||
/** 当前主题主色,用于同步色调材质的光学高光。 */
|
||||
/** 由用户主色派生的大面积玻璃材料色,用于同步色调材质的光学高光。 */
|
||||
tintColor: string
|
||||
/** 外层壁纸交叉淡化的时长,shader 使用同一时钟混合双纹理。 */
|
||||
transitionDuration: number
|
||||
|
||||
@@ -126,7 +126,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('GlassOpticalLayer', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', () => {
|
||||
it('uses exactly two visible presentation contexts with one interaction source', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
setRendererState.mockClear()
|
||||
@@ -159,6 +159,7 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(rendererCalls.every(options => options.wallpaperSourceCache === wallpaperSourceCache)).toBe(true)
|
||||
expect(rendererCalls.every(options => options.syncDocumentState === false)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#8D51F9', '#8D51F9'])
|
||||
expect(rendererCalls[0].pageMotion).toBeUndefined()
|
||||
expect(rendererCalls[1].pageMotion).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -167,6 +168,9 @@ describe('GlassOpticalLayer', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
await wrapper.setProps({ tintColor: '#00A6B8' })
|
||||
expect(rendererCalls.map(options => (options.tintColor as () => string)())).toEqual(['#00A6B8', '#00A6B8'])
|
||||
|
||||
wrapper.unmount()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCardAccentRgbFromImage, useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getDominantColor: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
getDominantColor: mocks.getDominantColor,
|
||||
}))
|
||||
|
||||
describe('useCardAccentColor compatibility', () => {
|
||||
beforeEach(() => {
|
||||
mocks.getDominantColor.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['#FFB400', '255, 180, 0'],
|
||||
['#56CA00', '86, 202, 0'],
|
||||
])('preserves the caller fallback %s', async (fallback, expectedRgb) => {
|
||||
mocks.getDominantColor.mockResolvedValue(fallback)
|
||||
|
||||
await expect(getCardAccentRgbFromImage(null, fallback)).resolves.toBe(expectedRgb)
|
||||
expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback })
|
||||
})
|
||||
|
||||
it('keeps the composable fallback scoped to its caller', async () => {
|
||||
mocks.getDominantColor.mockResolvedValue('#8D51F9')
|
||||
const accent = useCardAccentColor('#8D51F9')
|
||||
accent.imageRef.value = { $el: document.createElement('div') }
|
||||
|
||||
await accent.updateAccentColor()
|
||||
|
||||
expect(accent.accentRgb.value).toBe('141, 81, 249')
|
||||
expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback: '#8D51F9' })
|
||||
})
|
||||
})
|
||||
@@ -148,6 +148,28 @@ function createTouchList(points: Array<{ clientX: number; clientY: number; ident
|
||||
}) as unknown as TouchList
|
||||
}
|
||||
|
||||
/** 构造浏览器在 detached 子树交付前保留的 child-list 移除记录。 */
|
||||
function createRemovalRecord(target: Element, removedNodes: Element[]): MutationRecord {
|
||||
const toNodeList = (nodes: Node[]) =>
|
||||
Object.assign(nodes, {
|
||||
item(index: number) {
|
||||
return nodes[index] ?? null
|
||||
},
|
||||
}) as unknown as NodeList
|
||||
|
||||
return {
|
||||
addedNodes: toNodeList([]),
|
||||
attributeName: null,
|
||||
attributeNamespace: null,
|
||||
nextSibling: null,
|
||||
oldValue: null,
|
||||
previousSibling: null,
|
||||
removedNodes: toNodeList(removedNodes),
|
||||
target,
|
||||
type: 'childList',
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchTouchEvent(
|
||||
type: 'touchcancel' | 'touchend' | 'touchmove' | 'touchstart',
|
||||
touches: Array<{ clientX: number; clientY: number; identifier: number }>,
|
||||
@@ -454,6 +476,24 @@ describe('glass optical surface discovery', () => {
|
||||
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
|
||||
})
|
||||
|
||||
it('excludes direct surfaces and descendants even when a child requests dynamic mode', () => {
|
||||
const direct = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
|
||||
direct.dataset.glassOpticalMode = 'excluded'
|
||||
const excludedContainer = document.createElement('section')
|
||||
excludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||
const overridden = document.createElement('article')
|
||||
overridden.className = 'app-hover-lift-card'
|
||||
overridden.dataset.glassOpticalMode = 'dynamic'
|
||||
setOpticalSurfaceBounds(overridden, { height: 220, width: 150, x: 200, y: 96 })
|
||||
excludedContainer.append(overridden)
|
||||
document.body.append(excludedContainer)
|
||||
|
||||
expect(containsGlassOpticalSurface(direct)).toBe(false)
|
||||
expect(containsGlassOpticalSurface(excludedContainer)).toBe(false)
|
||||
expect(resolveGlassOpticalSurfaceMode(overridden)).toBe('dynamic')
|
||||
expect(collectGlassOpticalRects(390, 844, 'clear')).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers the shared interactive card contract used across routes', () => {
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 220, width: 150, x: 24, y: 96 })
|
||||
surface.style.borderTopLeftRadius = '20px'
|
||||
@@ -1998,6 +2038,458 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps a parent material static when all nested interaction clips are excluded', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const pageContent = document.createElement('main')
|
||||
pageContent.className = 'app-wrapper layout-page-content'
|
||||
const outerSurface = document.createElement('section')
|
||||
outerSurface.className = 'v-card'
|
||||
setOpticalSurfaceBounds(outerSurface, { height: 420, width: 900, x: 40, y: 80 })
|
||||
const nestedCards = [80, 400].map(x => {
|
||||
const nestedCard = document.createElement('article')
|
||||
nestedCard.className = 'app-hover-lift-card'
|
||||
nestedCard.dataset.glassOpticalMode = 'excluded'
|
||||
setOpticalSurfaceBounds(nestedCard, { height: 160, width: 280, x, y: 140 })
|
||||
outerSurface.append(nestedCard)
|
||||
|
||||
return nestedCard
|
||||
})
|
||||
pageContent.append(outerSurface)
|
||||
document.body.append(pageContent)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||
const getUniforms = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uInteractionRectCount: { value: number }
|
||||
uRectCount: { value: number }
|
||||
uSurfaceDynamics: { value: number[] }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
return scene.children[0].material.uniforms
|
||||
}
|
||||
|
||||
expect(getUniforms().uRectCount.value).toBe(1)
|
||||
expect(getUniforms().uInteractionRectCount.value).toBe(0)
|
||||
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(0)
|
||||
|
||||
nestedCards[0].removeAttribute('data-glass-optical-mode')
|
||||
await vi.waitFor(() => expect(getUniforms().uInteractionRectCount.value).toBe(1))
|
||||
expect(getUniforms().uSurfaceDynamics.value[0]).toBe(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('refreshes excluded interaction clip membership across direct and nested mutations', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const pageContent = document.createElement('main')
|
||||
pageContent.className = 'app-wrapper layout-page-content'
|
||||
const surfaces = [40, 520].map(x => {
|
||||
const surface = document.createElement('section')
|
||||
surface.className = 'v-card'
|
||||
setOpticalSurfaceBounds(surface, { height: 420, width: 400, x, y: 80 })
|
||||
pageContent.append(surface)
|
||||
|
||||
return surface
|
||||
})
|
||||
document.body.append(pageContent)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||
const getInteractionState = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uInteractionRectCount: { value: number }
|
||||
uInteractionRects: { value: Array<{ toArray: () => number[] }> }
|
||||
uRectCount: { value: number }
|
||||
uSurfaceDynamics: { value: number[] }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
|
||||
return {
|
||||
interactionCount: uniforms.uInteractionRectCount.value,
|
||||
interactionXs: uniforms.uInteractionRects.value
|
||||
.slice(0, uniforms.uInteractionRectCount.value)
|
||||
.map(rect => rect.toArray()[0])
|
||||
.sort((left, right) => left - right),
|
||||
surfaceCount: uniforms.uRectCount.value,
|
||||
surfaceDynamics: uniforms.uSurfaceDynamics.value.slice(0, 2).sort((left, right) => left - right),
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 2,
|
||||
interactionXs: [40 / 1200, 520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [1, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
const excludedClip = document.createElement('article')
|
||||
excludedClip.className = 'app-hover-lift-card'
|
||||
excludedClip.dataset.glassOpticalMode = 'excluded'
|
||||
surfaces[0].append(excludedClip)
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 1,
|
||||
interactionXs: [520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [0, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
surfaces[1].append(excludedClip)
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 1,
|
||||
interactionXs: [40 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [0, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
excludedClip.remove()
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 2,
|
||||
interactionXs: [40 / 1200, 520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [1, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
const excludedContainer = document.createElement('div')
|
||||
excludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||
surfaces[0].append(excludedContainer)
|
||||
const nestedExcludedClip = document.createElement('article')
|
||||
nestedExcludedClip.className = 'app-hover-lift-card'
|
||||
excludedContainer.append(nestedExcludedClip)
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 1,
|
||||
interactionXs: [520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [0, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
nestedExcludedClip.remove()
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 2,
|
||||
interactionXs: [40 / 1200, 520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [1, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
excludedContainer.append(nestedExcludedClip)
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 1,
|
||||
interactionXs: [520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [0, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
nestedExcludedClip.remove()
|
||||
excludedContainer.remove()
|
||||
await vi.waitFor(() =>
|
||||
expect(getInteractionState()).toEqual({
|
||||
interactionCount: 2,
|
||||
interactionXs: [40 / 1200, 520 / 1200],
|
||||
surfaceCount: 2,
|
||||
surfaceDynamics: [1, 1],
|
||||
}),
|
||||
)
|
||||
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('propagates a managed owner through deeply nested removals in one observer batch', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const mutationObservers: Array<MutationObserver & { trigger: (records: MutationRecord[]) => void }> = []
|
||||
class MutationObserverMock implements MutationObserver {
|
||||
constructor(private readonly callback: MutationCallback) {
|
||||
mutationObservers.push(this)
|
||||
}
|
||||
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
trigger(records: MutationRecord[]) {
|
||||
this.callback(records, this)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('MutationObserver', MutationObserverMock)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const pageContent = document.createElement('main')
|
||||
pageContent.className = 'app-wrapper layout-page-content'
|
||||
const surface = document.createElement('section')
|
||||
surface.className = 'v-card'
|
||||
setOpticalSurfaceBounds(surface, { height: 420, width: 900, x: 40, y: 80 })
|
||||
const outerExcludedContainer = document.createElement('div')
|
||||
outerExcludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||
const innerExcludedContainer = document.createElement('div')
|
||||
innerExcludedContainer.dataset.glassOpticalMode = 'excluded'
|
||||
const deeplyNestedClip = document.createElement('article')
|
||||
deeplyNestedClip.className = 'app-hover-lift-card'
|
||||
innerExcludedContainer.append(deeplyNestedClip)
|
||||
outerExcludedContainer.append(innerExcludedContainer)
|
||||
surface.append(outerExcludedContainer)
|
||||
pageContent.append(surface)
|
||||
document.body.append(pageContent)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||
const getInteractionState = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uInteractionRectCount: { value: number }
|
||||
uSurfaceDynamics: { value: number[] }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
|
||||
return {
|
||||
interactionCount: uniforms.uInteractionRectCount.value,
|
||||
surfaceDynamics: uniforms.uSurfaceDynamics.value[0],
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 0, surfaceDynamics: 0 }))
|
||||
|
||||
outerExcludedContainer.remove()
|
||||
innerExcludedContainer.remove()
|
||||
deeplyNestedClip.remove()
|
||||
mutationObservers[0].trigger([
|
||||
createRemovalRecord(surface, [outerExcludedContainer]),
|
||||
createRemovalRecord(outerExcludedContainer, [innerExcludedContainer]),
|
||||
createRemovalRecord(innerExcludedContainer, [deeplyNestedClip]),
|
||||
])
|
||||
|
||||
await vi.waitFor(() => expect(getInteractionState()).toEqual({ interactionCount: 1, surfaceDynamics: 1 }))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('removes and restores an active surface and interaction clip when exclusion changes', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const appWrapper = document.createElement('main')
|
||||
appWrapper.className = 'app-wrapper'
|
||||
const surface = document.createElement('article')
|
||||
surface.className = 'app-hover-lift-card'
|
||||
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
|
||||
appWrapper.append(surface)
|
||||
document.body.append(appWrapper)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalled())
|
||||
const getCounts = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uInteractionRectCount: { value: number }
|
||||
uRectCount: { value: number }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
|
||||
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
|
||||
}
|
||||
|
||||
expect(getCounts()).toEqual([1, 1])
|
||||
|
||||
surface.dataset.glassOpticalMode = 'excluded'
|
||||
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
|
||||
|
||||
surface.removeAttribute('data-glass-optical-mode')
|
||||
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores child-list churn inside an excluded surface after removed nodes lose their ancestor', async () => {
|
||||
const appWrapper = document.createElement('main')
|
||||
appWrapper.className = 'app-wrapper'
|
||||
const surface = document.createElement('article')
|
||||
surface.className = 'app-hover-lift-card'
|
||||
surface.dataset.glassOpticalMode = 'excluded'
|
||||
const imageContent = document.createElement('div')
|
||||
surface.append(imageContent)
|
||||
appWrapper.append(surface)
|
||||
document.body.append(appWrapper)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsActive: ref(false),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
|
||||
imageContent.remove()
|
||||
await nextTick()
|
||||
await new Promise(resolve => requestAnimationFrame(resolve))
|
||||
|
||||
expect(querySelectorAll).not.toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('removes and restores a managed surface when it moves across an excluded boundary', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const appWrapper = document.createElement('main')
|
||||
appWrapper.className = 'app-wrapper'
|
||||
const eligibleParent = document.createElement('section')
|
||||
const excludedParent = document.createElement('section')
|
||||
excludedParent.dataset.glassOpticalMode = 'excluded'
|
||||
const surface = document.createElement('article')
|
||||
surface.className = 'app-hover-lift-card'
|
||||
setOpticalSurfaceBounds(surface, { height: 180, width: 360, x: 100, y: 140 })
|
||||
eligibleParent.append(surface)
|
||||
appWrapper.append(eligibleParent, excludedParent)
|
||||
document.body.append(appWrapper)
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/search'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const getCounts = () => {
|
||||
const scene = render.mock.calls.at(-1)?.[0] as unknown as {
|
||||
children: Array<{
|
||||
material: {
|
||||
uniforms: {
|
||||
uInteractionRectCount: { value: number }
|
||||
uRectCount: { value: number }
|
||||
}
|
||||
}
|
||||
}>
|
||||
}
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
|
||||
return [uniforms.uRectCount.value, uniforms.uInteractionRectCount.value]
|
||||
}
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||
const querySelectorAll = vi.spyOn(document, 'querySelectorAll')
|
||||
excludedParent.append(surface)
|
||||
expect(surface.closest('[data-glass-optical-mode="excluded"]')).toBe(excludedParent)
|
||||
await vi.waitFor(() => expect(querySelectorAll).toHaveBeenCalled())
|
||||
await vi.waitFor(() => expect(getCounts()).toEqual([0, 0]))
|
||||
|
||||
eligibleParent.append(surface)
|
||||
await vi.waitFor(() => expect(getCounts()).toEqual([1, 1]))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps an explicit optical boundary as the interaction clip without allocating nested slots', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
extractDominantColor: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: mocks.extractDominantColor,
|
||||
}))
|
||||
|
||||
function createImageHost() {
|
||||
const host = document.createElement('div')
|
||||
host.append(document.createElement('img'))
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(resolvePromise => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('usePluginCardAccent', () => {
|
||||
beforeEach(() => {
|
||||
mocks.extractDominantColor.mockReset()
|
||||
})
|
||||
|
||||
it('publishes a normalized nullable accent for the plugin card CSS variable', async () => {
|
||||
mocks.extractDominantColor.mockResolvedValue('#ff0000')
|
||||
const accent = usePluginCardAccent()
|
||||
accent.imageRef.value = { $el: createImageHost() }
|
||||
|
||||
await accent.updateAccentColor()
|
||||
|
||||
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#ff0000')?.rgb)
|
||||
expect(accent.accentStyle.value).toEqual({ '--plugin-card-accent-rgb': accent.accentRgb.value })
|
||||
})
|
||||
|
||||
it('leaves the accent unset when extraction fails and can clear a previous value', async () => {
|
||||
const accent = usePluginCardAccent()
|
||||
accent.imageRef.value = { $el: createImageHost() }
|
||||
mocks.extractDominantColor.mockResolvedValueOnce('#00ff00')
|
||||
await accent.updateAccentColor()
|
||||
expect(accent.accentRgb.value).toBeDefined()
|
||||
|
||||
accent.resetAccentColor()
|
||||
expect(accent.accentRgb.value).toBeUndefined()
|
||||
expect(accent.accentStyle.value).toBeUndefined()
|
||||
|
||||
mocks.extractDominantColor.mockResolvedValueOnce(undefined)
|
||||
await accent.updateAccentColor()
|
||||
expect(accent.accentRgb.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores an older extraction that resolves after the current logo', async () => {
|
||||
const older = deferred<string | undefined>()
|
||||
const current = deferred<string | undefined>()
|
||||
mocks.extractDominantColor.mockReturnValueOnce(older.promise).mockReturnValueOnce(current.promise)
|
||||
const accent = usePluginCardAccent()
|
||||
accent.imageRef.value = { $el: createImageHost() }
|
||||
|
||||
const olderUpdate = accent.updateAccentColor()
|
||||
const currentUpdate = accent.updateAccentColor()
|
||||
current.resolve('#00ff00')
|
||||
await currentUpdate
|
||||
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb)
|
||||
|
||||
older.resolve('#ff0000')
|
||||
await olderUpdate
|
||||
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb)
|
||||
})
|
||||
|
||||
it('keeps the CSS fallback after reset when an extraction resolves late', async () => {
|
||||
const pending = deferred<string | undefined>()
|
||||
mocks.extractDominantColor.mockReturnValueOnce(pending.promise)
|
||||
const accent = usePluginCardAccent()
|
||||
accent.imageRef.value = { $el: createImageHost() }
|
||||
|
||||
const update = accent.updateAccentColor()
|
||||
accent.resetAccentColor()
|
||||
pending.resolve('#ff0000')
|
||||
await update
|
||||
|
||||
expect(accent.accentRgb.value).toBeUndefined()
|
||||
expect(accent.accentStyle.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useEffectiveGlassSettings,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import vuetify from '@/plugins/vuetify'
|
||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -210,6 +211,49 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-surface-density'))).toBeCloseTo(0.72)
|
||||
expect(Number(document.body.style.getPropertyValue('--glass-tint-density'))).toBeCloseTo(0.65)
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
|
||||
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
|
||||
normalizeThemeMaterialAccent(settings.primaryColor)?.rgb,
|
||||
)
|
||||
expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
|
||||
normalizeThemeMaterialAccent(settings.primaryColor)?.rgb,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the user primary color while publishing its material tone', async () => {
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
|
||||
await customizer.setPrimaryColor('#00BCD4')
|
||||
|
||||
expect(customizer.settings.value.primaryColor).toBe('#00BCD4')
|
||||
expect(vuetify.theme.current.value.colors.primary).toBe('#00BCD4')
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
|
||||
normalizeThemeMaterialAccent('#00BCD4')?.rgb,
|
||||
)
|
||||
expect(document.body.style.getPropertyValue('--glass-material-accent-rgb')).toBe(
|
||||
normalizeThemeMaterialAccent('#00BCD4')?.rgb,
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps overlay clarity synchronized across preview, cancel, and commit', () => {
|
||||
persistPartialThemeCustomizerSettings({ glassTransparencyStrength: 50 })
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
|
||||
|
||||
previewGlassSettings({ glassTransparencyStrength: 100 })
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
|
||||
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('5px')
|
||||
|
||||
cancelGlassPreview()
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
|
||||
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('6.7px')
|
||||
|
||||
previewGlassSettings({ glassTransparencyStrength: 20 })
|
||||
commitGlassPreview()
|
||||
expect(readThemeCustomizerSettings().glassTransparencyStrength).toBe(20)
|
||||
expect(document.documentElement.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
|
||||
expect(document.body.style.getPropertyValue('--glass-overlay-clarity-blur')).toBe('7.8px')
|
||||
})
|
||||
|
||||
it('previews glass settings without persisting them', () => {
|
||||
|
||||
@@ -442,8 +442,19 @@ const SURFACE_SELECTORS = [
|
||||
const SURFACE_SELECTOR_QUERY = SURFACE_SELECTORS.map(({ selector }) => selector).join(',')
|
||||
const INTERACTION_CLIP_SELECTOR = '.app-hover-lift-card'
|
||||
const OPTICAL_BOUNDARY_SELECTOR = '[data-glass-optical-boundary]'
|
||||
const OPTICAL_EXCLUSION_SELECTOR = '[data-glass-optical-mode="excluded"]'
|
||||
const INTERACTION_CLIP_OVERSCAN_PX = 96
|
||||
|
||||
/** 排除合同覆盖整个子树;后代不能用 dynamic 声明重新加入 renderer。 */
|
||||
function isGlassOpticalElementExcluded(element: Element) {
|
||||
return Boolean(element.closest(OPTICAL_EXCLUSION_SELECTOR))
|
||||
}
|
||||
|
||||
/** overlay 与显式排除子树都不参与壁纸光学表面或交互裁剪。 */
|
||||
function isGlassOpticalElementEligible(element: Element) {
|
||||
return !element.closest('.v-overlay') && !isGlassOpticalElementExcluded(element)
|
||||
}
|
||||
|
||||
/** 登录卡片随文档弹性合成,其余固定表面继续使用 viewport 坐标。 */
|
||||
function getSurfacePresentationSpace(
|
||||
selector: (typeof SURFACE_SELECTORS)[number]['selector'],
|
||||
@@ -455,9 +466,16 @@ function getSurfacePresentationSpace(
|
||||
/** 判断新增或移除的 DOM 子树是否会改变光学表面集合。 */
|
||||
export function containsGlassOpticalSurface(node: Node) {
|
||||
if (!(node instanceof Element)) return false
|
||||
if (node.matches(SURFACE_SELECTOR_QUERY) && !node.closest('.v-overlay')) return true
|
||||
if (node.matches(SURFACE_SELECTOR_QUERY) && isGlassOpticalElementEligible(node)) return true
|
||||
|
||||
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(element => !element.closest('.v-overlay'))
|
||||
return Array.from(node.querySelectorAll(SURFACE_SELECTOR_QUERY)).some(isGlassOpticalElementEligible)
|
||||
}
|
||||
|
||||
/** 判断 DOM 子树是否包含会约束父表面动态输出的交互裁剪。 */
|
||||
function containsGlassInteractionClip(node: Node) {
|
||||
if (!(node instanceof Element)) return false
|
||||
|
||||
return node.matches(INTERACTION_CLIP_SELECTOR) || Boolean(node.querySelector(INTERACTION_CLIP_SELECTOR))
|
||||
}
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
@@ -1100,7 +1118,7 @@ function collectGlassOpticalSurfaceDescriptors(
|
||||
for (const element of document.querySelectorAll<HTMLElement>(selector)) {
|
||||
if (seen.has(element)) continue
|
||||
seen.add(element)
|
||||
if (element.closest('.v-overlay')) continue
|
||||
if (!isGlassOpticalElementEligible(element)) continue
|
||||
collectedElements?.push(element)
|
||||
|
||||
const bounds = element.getBoundingClientRect()
|
||||
@@ -1291,8 +1309,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
let surfaceRegistry: GlassOpticalSurfaceDescriptor[] = []
|
||||
let availableSurfaces: GlassOpticalSurfaceDescriptor[] = []
|
||||
let surfaceSlots: GlassOpticalSurfaceSlot<HTMLElement>[] = []
|
||||
let interactionClips: GlassOpticalSurfaceDescriptor[] = []
|
||||
let interactionClips: GlassInteractionClipDescriptor[] = []
|
||||
let interactionClipRegistry: GlassInteractionClipDescriptor[] = []
|
||||
let interactionClipConstrainedOwners = new Set<HTMLElement>()
|
||||
let interactionClipMembershipDirty = true
|
||||
let activeSurface: HTMLElement | null = null
|
||||
let activeInteractionClip: HTMLElement | null = null
|
||||
@@ -1869,6 +1888,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const uniformRadii = resources.uniforms.uRadii.value
|
||||
const uniformWeights = resources.uniforms.uSurfaceWeights.value
|
||||
const uniformDynamics = resources.uniforms.uSurfaceDynamics.value
|
||||
const ownersWithVisibleInteractionClips = new Set(interactionClips.map(clip => clip.owner))
|
||||
const transitionWeights = outgoingSurface
|
||||
? getGlassOpticalSurfaceTransitionWeights(timestamp - surfaceTransitionStartedAt, SURFACE_TRANSITION_DURATION_MS)
|
||||
: { incoming: 1, outgoing: 0 }
|
||||
@@ -1894,7 +1914,9 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
? 1
|
||||
: 0
|
||||
uniformWeights[index] = surfaceWeight * pagePresentationWeight
|
||||
uniformDynamics[index] = slot?.mode === 'static-material' ? 0 : 1
|
||||
const nestedInteractionAvailable =
|
||||
!slot || !interactionClipConstrainedOwners.has(slot.key) || ownersWithVisibleInteractionClips.has(slot.key)
|
||||
uniformDynamics[index] = slot?.mode === 'static-material' || !nestedInteractionAvailable ? 0 : 1
|
||||
}
|
||||
|
||||
resources.uniforms.uRectCount.value = normalized.length
|
||||
@@ -1907,13 +1929,21 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
function refreshInteractionClipRegistry() {
|
||||
const seen = new Set<HTMLElement>()
|
||||
const candidates: GlassInteractionClipDescriptor[] = []
|
||||
const constrainedOwners = new Set<HTMLElement>()
|
||||
const append = (
|
||||
element: HTMLElement,
|
||||
owner: HTMLElement,
|
||||
mode: GlassOpticalSurfaceMode,
|
||||
committedRect?: GlassOpticalRect,
|
||||
) => {
|
||||
if (seen.has(element) || !element.isConnected || resolveGlassOpticalSurfaceMode(element) !== mode) return
|
||||
if (
|
||||
seen.has(element) ||
|
||||
!element.isConnected ||
|
||||
!isGlassOpticalElementEligible(element) ||
|
||||
resolveGlassOpticalSurfaceMode(element) !== mode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = committedRect ?? getElementPresentationRect(element)
|
||||
|
||||
@@ -1935,6 +1965,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (surfaceIsClip) append(surface.key, surface.key, mode, surface.rect)
|
||||
const nestedClips = [...surface.key.querySelectorAll<HTMLElement>(INTERACTION_CLIP_SELECTOR)]
|
||||
if (nestedClips.length > 0) {
|
||||
constrainedOwners.add(surface.key)
|
||||
nestedClips.forEach(clip => append(clip, surface.key, mode))
|
||||
} else if (!surfaceIsClip) {
|
||||
append(surface.key, surface.key, mode, surface.rect)
|
||||
@@ -1942,6 +1973,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
}
|
||||
|
||||
interactionClipRegistry = candidates
|
||||
interactionClipConstrainedOwners = constrainedOwners
|
||||
interactionClipMembershipDirty = false
|
||||
}
|
||||
|
||||
@@ -1952,6 +1984,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (
|
||||
!clip.key.isConnected ||
|
||||
!clip.owner.isConnected ||
|
||||
!isGlassOpticalElementEligible(clip.key) ||
|
||||
!isGlassOpticalElementEligible(clip.owner) ||
|
||||
resolveGlassOpticalSurfaceMode(clip.key) !== clip.mode ||
|
||||
!committedSurfaces.has(clip.owner)
|
||||
) {
|
||||
@@ -2030,6 +2064,8 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
activeInteractionClip = null
|
||||
}
|
||||
if (outgoingSurface && !availableKeys.has(outgoingSurface)) outgoingSurface = null
|
||||
const interactionClipKeys = new Set(interactionClipRegistry.map(clip => clip.key))
|
||||
if (activeInteractionClip && !interactionClipKeys.has(activeInteractionClip)) activeInteractionClip = null
|
||||
const maxCount = viewportWidth <= 600 ? GLASS_OPTICAL_MAX_SURFACES_MOBILE : GLASS_OPTICAL_MAX_SURFACES_DESKTOP
|
||||
surfaceSlots = reconcileGlassOpticalSurfaceSlots(
|
||||
surfaceSlots,
|
||||
@@ -2242,7 +2278,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
const surface = target.matches(SURFACE_SELECTOR_QUERY)
|
||||
? target
|
||||
: target.closest<HTMLElement>(SURFACE_SELECTOR_QUERY)
|
||||
if (!surface) return null
|
||||
if (!surface || !isGlassOpticalElementEligible(surface)) return null
|
||||
|
||||
return SURFACE_SELECTORS.some(
|
||||
({ selector, space }) =>
|
||||
@@ -2482,13 +2518,16 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
function findInteractionTarget(x: number, y: number) {
|
||||
if (availableSurfaces.length === 0) updateSurfaceUniforms()
|
||||
|
||||
const surface = availableSurfaces.find(candidate => rectContainsPoint(candidate.rect, x, y))
|
||||
const surface = availableSurfaces.find(
|
||||
candidate => isGlassOpticalElementEligible(candidate.key) && rectContainsPoint(candidate.rect, x, y),
|
||||
)
|
||||
if (!surface) return null
|
||||
|
||||
const matchingClips = interactionClipRegistry
|
||||
.filter(
|
||||
candidate =>
|
||||
candidate.owner === surface.key &&
|
||||
isGlassOpticalElementEligible(candidate.key) &&
|
||||
isInteractionClipRenderable(candidate.rect) &&
|
||||
rectContainsPoint(candidate.rect, x, y),
|
||||
)
|
||||
@@ -2689,6 +2728,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
clientY: number,
|
||||
timestamp: number,
|
||||
velocityOverride?: { x: number; y: number },
|
||||
target?: EventTarget | null,
|
||||
) {
|
||||
if (
|
||||
!hasDynamicCapability() ||
|
||||
@@ -2696,6 +2736,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (target instanceof Element && isGlassOpticalElementExcluded(target)) return
|
||||
|
||||
const viewportWidth = Math.max(window.innerWidth, 1)
|
||||
const viewportHeight = Math.max(window.innerHeight, 1)
|
||||
@@ -2803,7 +2844,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') return
|
||||
|
||||
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now())
|
||||
applyInteraction(event.clientX, event.clientY, event.timeStamp || performance.now(), undefined, event.target)
|
||||
}
|
||||
|
||||
function findTouch(touches: TouchList, identifier: number | null) {
|
||||
@@ -2818,6 +2859,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
/** 被动跟踪真实触点;不阻止页面滚动,也不恢复按压放大语义。 */
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
if (activeTouchIdentifier !== null) return
|
||||
if (event.target instanceof Element && isGlassOpticalElementExcluded(event.target)) return
|
||||
|
||||
const touch = findTouch(event.changedTouches, null)
|
||||
if (!touch) return
|
||||
@@ -2854,7 +2896,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
if (!touch) return
|
||||
|
||||
const timestamp = event.timeStamp || performance.now()
|
||||
applyInteraction(touch.clientX, touch.clientY, timestamp)
|
||||
applyInteraction(touch.clientX, touch.clientY, timestamp, undefined, event.target)
|
||||
}
|
||||
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
@@ -3105,11 +3147,72 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
|
||||
/** 只让会改变目标表面集合或圆角几何的 DOM 变更触发重扫。 */
|
||||
function mutationTouchesOpticalSurface(mutations: MutationRecord[]) {
|
||||
return mutations.some(
|
||||
mutation =>
|
||||
mutation.type === 'attributes' ||
|
||||
[...mutation.addedNodes, ...mutation.removedNodes].some(containsGlassOpticalSurface),
|
||||
)
|
||||
const removalRecords = mutations.flatMap(mutation => {
|
||||
if (mutation.type !== 'childList' || mutation.removedNodes.length === 0 || !(mutation.target instanceof Element))
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
removedElements: [...mutation.removedNodes].filter((node): node is Element => node instanceof Element),
|
||||
target: mutation.target,
|
||||
},
|
||||
]
|
||||
})
|
||||
const managedRemovalTargets = new Set<Element>()
|
||||
const removedElementsFromManagedSurfaces = new Set<Element>()
|
||||
let removalGraphExpanded = removalRecords.length > 0
|
||||
|
||||
// MutationRecord 保留节点身份,但回调时的最终 DOM 已丢失中间祖先关系,需要沿同批次移除边传递 owner。
|
||||
while (removalGraphExpanded) {
|
||||
removalGraphExpanded = false
|
||||
|
||||
for (const record of removalRecords) {
|
||||
const belongsToManagedSurface =
|
||||
managedRemovalTargets.has(record.target) ||
|
||||
surfaceRegistry.some(surface => surface.key === record.target || surface.key.contains(record.target)) ||
|
||||
[...removedElementsFromManagedSurfaces].some(
|
||||
element => element === record.target || element.contains(record.target),
|
||||
)
|
||||
if (!belongsToManagedSurface) continue
|
||||
|
||||
if (!managedRemovalTargets.has(record.target)) {
|
||||
managedRemovalTargets.add(record.target)
|
||||
removalGraphExpanded = true
|
||||
}
|
||||
for (const element of record.removedElements) {
|
||||
if (removedElementsFromManagedSurfaces.has(element)) continue
|
||||
|
||||
removedElementsFromManagedSurfaces.add(element)
|
||||
removalGraphExpanded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mutations.some(mutation => {
|
||||
if (mutation.type === 'attributes') return true
|
||||
|
||||
const removedManagedSurface = [...mutation.removedNodes].some(
|
||||
node =>
|
||||
node instanceof Element &&
|
||||
(surfaceRegistry.some(surface => surface.key === node || node.contains(surface.key)) ||
|
||||
interactionClipRegistry.some(clip => clip.key === node || node.contains(clip.key))),
|
||||
)
|
||||
if (removedManagedSurface) return true
|
||||
|
||||
const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes]
|
||||
|
||||
// 新增节点按提交后的祖先资格判断;排除子树内部的图片 DOM 变化不需要重扫。
|
||||
if (mutation.target instanceof Element && !isGlassOpticalElementEligible(mutation.target)) {
|
||||
const belongsToManagedSurface = surfaceRegistry.some(
|
||||
surface => surface.key === mutation.target || surface.key.contains(mutation.target),
|
||||
)
|
||||
const removedFromManagedSurface = managedRemovalTargets.has(mutation.target)
|
||||
|
||||
return (belongsToManagedSurface || removedFromManagedSurface) && changedNodes.some(containsGlassInteractionClip)
|
||||
}
|
||||
|
||||
return changedNodes.some(node => containsGlassOpticalSurface(node) || containsGlassInteractionClip(node))
|
||||
})
|
||||
}
|
||||
|
||||
function setupObservers() {
|
||||
@@ -3262,6 +3365,7 @@ export function useGlassOpticalRenderer(options: UseGlassOpticalRendererOptions)
|
||||
surfaceSlots = []
|
||||
interactionClips = []
|
||||
interactionClipRegistry = []
|
||||
interactionClipConstrainedOwners = new Set<HTMLElement>()
|
||||
interactionClipMembershipDirty = true
|
||||
activeSurface = null
|
||||
activeInteractionClip = null
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { extractDominantColor } from '@/@core/utils/image'
|
||||
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||
|
||||
/** 管理插件卡 Logo 的可选品牌强调色;无有效提色时由 CSS 环境色接管。 */
|
||||
export function usePluginCardAccent() {
|
||||
const accentRgb = ref<string>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
const accentStyle = computed(() => (accentRgb.value ? { '--plugin-card-accent-rgb': accentRgb.value } : undefined))
|
||||
let requestGeneration = 0
|
||||
|
||||
async function updateAccentColor() {
|
||||
const generation = ++requestGeneration
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement | undefined
|
||||
const dominantColor = await extractDominantColor(imageElement)
|
||||
if (generation !== requestGeneration) return
|
||||
|
||||
accentRgb.value = dominantColor ? normalizePluginAccentColor(dominantColor)?.rgb : undefined
|
||||
}
|
||||
|
||||
function resetAccentColor() {
|
||||
requestGeneration += 1
|
||||
accentRgb.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
accentRgb,
|
||||
accentStyle,
|
||||
imageRef,
|
||||
resetAccentColor,
|
||||
updateAccentColor,
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GLASS_OPTICAL_STRENGTH_MAX,
|
||||
getGlassCssFrostBlur,
|
||||
getGlassMaterialResponse,
|
||||
getGlassOverlayClarityBlur,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalPresetKey,
|
||||
getGlassOpticalPresetParameters,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
type GlassOpticalPreset,
|
||||
type GlassOpticalPresetOverrides,
|
||||
} from '@/utils/glassOptics'
|
||||
import { normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { themeManager } from '@/utils/themeManager'
|
||||
import { syncThemeFavicon } from '@/utils/themePalette'
|
||||
|
||||
@@ -470,6 +472,7 @@ export function applyThemeCustomizerRootSettings(
|
||||
| 'glassTransmissionStrength'
|
||||
| 'glassTransparencyStrength'
|
||||
| 'layout'
|
||||
| 'primaryColor'
|
||||
| 'radius'
|
||||
| 'semiDarkMenu'
|
||||
| 'shadow'
|
||||
@@ -480,6 +483,9 @@ export function applyThemeCustomizerRootSettings(
|
||||
|
||||
const materialResponse = getGlassMaterialResponse(settings.glassAppearance, settings.glassTransparencyStrength)
|
||||
const frostBlur = getGlassCssFrostBlur(settings.glassTransparencyStrength)
|
||||
const overlayClarityBlur = getGlassOverlayClarityBlur(settings.glassTransparencyStrength)
|
||||
const materialAccent =
|
||||
normalizeThemeMaterialAccent(settings.primaryColor) ?? normalizeThemeMaterialAccent(defaultPrimaryColor)!
|
||||
const applyGlassResponse = (element: HTMLElement) => {
|
||||
element.style.setProperty('--glass-background-visibility', String(materialResponse.backgroundVisibility))
|
||||
element.style.setProperty('--glass-frost-blur-scale', String(materialResponse.frostBlurScale))
|
||||
@@ -488,6 +494,8 @@ export function applyThemeCustomizerRootSettings(
|
||||
element.style.setProperty('--glass-tint-density', String(materialResponse.tintDensity))
|
||||
element.style.setProperty('--glass-blur-surface', `${frostBlur.surface}px`)
|
||||
element.style.setProperty('--glass-blur-raised', `${frostBlur.raised}px`)
|
||||
element.style.setProperty('--glass-overlay-clarity-blur', `${overlayClarityBlur}px`)
|
||||
element.style.setProperty('--glass-material-accent-rgb', materialAccent.rgb)
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-glass-appearance', settings.glassAppearance)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Agent 助手需要覆盖应用内所有普通浮层,因此从 CSS 32 位层级上限向下预留内部顺序。
|
||||
const MAX_CSS_Z_INDEX = 2_147_483_647
|
||||
|
||||
export const AGENT_ASSISTANT_LAYER_Z_INDEX = {
|
||||
entry: MAX_CSS_Z_INDEX - 2,
|
||||
panel: MAX_CSS_Z_INDEX - 1,
|
||||
overlay: MAX_CSS_Z_INDEX,
|
||||
} as const
|
||||
@@ -4,7 +4,12 @@ import { useDisplay } from 'vuetify'
|
||||
import { NavMenu } from '@/@layouts/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
|
||||
import {
|
||||
buildUserPermissionContext,
|
||||
filterItemsByPermission,
|
||||
filterMenusByPermission,
|
||||
hasItemPermission,
|
||||
} from '@/utils/permission'
|
||||
import { useLaunchLoading } from '@/composables/useLaunchLoading'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
@@ -327,8 +332,7 @@ 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 {
|
||||
|
||||
+11
-6
@@ -782,7 +782,7 @@ export default {
|
||||
emptyTitle: 'What should we handle today?',
|
||||
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
|
||||
placeholder: 'Ask MoviePilot, Type / for commands',
|
||||
processingPlaceholder: 'MoviePilot is working, please wait...',
|
||||
processingPlaceholder: 'Processing...',
|
||||
commandLoading: 'Loading commands...',
|
||||
commandLoadFailed: 'Failed to load commands',
|
||||
stop: 'Stop generating',
|
||||
@@ -1401,6 +1401,7 @@ export default {
|
||||
u115: '115 Cloud',
|
||||
rclone: 'RClone',
|
||||
alist: 'OpenList',
|
||||
alistgo: 'AList',
|
||||
smb: 'SMB Network Share',
|
||||
custom: 'Custom',
|
||||
},
|
||||
@@ -2033,8 +2034,8 @@ export default {
|
||||
tmdbLocale: 'TMDB Metadata Language',
|
||||
tmdbLocalePlaceholder: 'en',
|
||||
tmdbLocaleHint: 'Customize TheMovieDb metadata language',
|
||||
metaCacheExpire: 'Media Metadata Cache Expiration Time',
|
||||
metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',
|
||||
metaCacheExpire: 'Per-item Media Metadata Cache TTL',
|
||||
metaCacheExpireHint: 'Independent TTL for each recognition metadata item; use the built-in default when set to 0',
|
||||
metaCacheExpireRequired: 'Please enter metadata cache time',
|
||||
metaCacheExpireMin: 'Metadata cache time must be greater than or equal to 0',
|
||||
scrapFollowTmdb: 'Follow TMDB Recognition',
|
||||
@@ -2916,6 +2917,10 @@ export default {
|
||||
complete: 'Complete',
|
||||
reset: 'Reset',
|
||||
},
|
||||
alistgoConfig: {
|
||||
title: 'AList Configuration',
|
||||
serverUrl: 'AList server address',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB Network Share Configuration',
|
||||
host: 'SMB Server Address',
|
||||
@@ -3061,9 +3066,9 @@ export default {
|
||||
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
|
||||
invalidTextIgnored: '{count} invalid URLs ignored',
|
||||
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
|
||||
syncWiki: 'Sync Wiki',
|
||||
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
|
||||
syncFailed: 'Failed to sync Wiki: {message}!',
|
||||
syncSources: 'Sync Plugin Sources',
|
||||
syncSuccess: 'Plugin sources synced. {added} added, {total} total.',
|
||||
syncFailed: 'Failed to sync plugin sources: {message}!',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
saveSuccess: 'Plugin repository saved successfully',
|
||||
|
||||
+11
-6
@@ -771,7 +771,7 @@ export default {
|
||||
emptyTitle: '今天想处理什么?',
|
||||
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
|
||||
placeholder: '询问 MoviePilot,输入 / 使用命令',
|
||||
processingPlaceholder: '智能体正在处理,请稍候...',
|
||||
processingPlaceholder: '处理中...',
|
||||
commandLoading: '正在加载命令...',
|
||||
commandLoadFailed: '命令列表加载失败',
|
||||
stop: '停止生成',
|
||||
@@ -1391,6 +1391,7 @@ export default {
|
||||
u115: '115网盘',
|
||||
rclone: 'RClone',
|
||||
alist: 'OpenList',
|
||||
alistgo: 'AList',
|
||||
smb: 'SMB网络共享',
|
||||
custom: '自定义',
|
||||
},
|
||||
@@ -2008,8 +2009,8 @@ export default {
|
||||
tmdbLocale: 'TMDB 元数据语言',
|
||||
tmdbLocalePlaceholder: 'zh',
|
||||
tmdbLocaleHint: '自定义 TheMovieDb 元数据语言',
|
||||
metaCacheExpire: '媒体元数据缓存过期时间',
|
||||
metaCacheExpireHint: '识别元数据本地缓存时间,为 0 时使用内置默认值',
|
||||
metaCacheExpire: '单条媒体元数据缓存有效期',
|
||||
metaCacheExpireHint: '每条识别元数据的独立有效期,为 0 时使用内置默认值',
|
||||
metaCacheExpireRequired: '请输入元数据缓存时间',
|
||||
metaCacheExpireMin: '元数据缓存时间必须大于等于0',
|
||||
scrapFollowTmdb: '跟随TMDB识别整理',
|
||||
@@ -2861,6 +2862,10 @@ export default {
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
alistgoConfig: {
|
||||
title: 'AList配置',
|
||||
serverUrl: 'AList服务地址',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB网络共享配置',
|
||||
host: 'SMB服务器地址',
|
||||
@@ -3006,9 +3011,9 @@ export default {
|
||||
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
|
||||
invalidTextIgnored: '已忽略 {count} 个无效地址',
|
||||
duplicateTextIgnored: '重复地址会在保存时自动去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
|
||||
syncFailed: '同步 Wiki 失败:{message}!',
|
||||
syncSources: '同步插件源',
|
||||
syncSuccess: '已同步插件源,新增 {added} 个,共 {total} 个',
|
||||
syncFailed: '同步插件源失败:{message}!',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
saveSuccess: '插件仓库保存成功',
|
||||
|
||||
+11
-6
@@ -771,7 +771,7 @@ export default {
|
||||
emptyTitle: '今天想處理什麼?',
|
||||
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
|
||||
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
|
||||
processingPlaceholder: '智能體正在處理,請稍候...',
|
||||
processingPlaceholder: '處理中...',
|
||||
commandLoading: '正在載入命令...',
|
||||
commandLoadFailed: '命令列表載入失敗',
|
||||
stop: '停止生成',
|
||||
@@ -1389,6 +1389,7 @@ export default {
|
||||
u115: '115網盤',
|
||||
rclone: 'RClone',
|
||||
alist: 'OpenList',
|
||||
alistgo: 'AList',
|
||||
smb: 'SMB網路共享',
|
||||
custom: '自定義',
|
||||
},
|
||||
@@ -2007,8 +2008,8 @@ export default {
|
||||
tmdbLocale: 'TMDB 元數據語言',
|
||||
tmdbLocalePlaceholder: 'zh',
|
||||
tmdbLocaleHint: '自定義 TheMovieDb 元數據語言',
|
||||
metaCacheExpire: '媒體元數據緩存過期時間',
|
||||
metaCacheExpireHint: '識別元數據本地緩存時間,為 0 時使用內置默認值',
|
||||
metaCacheExpire: '單筆媒體元數據緩存有效期',
|
||||
metaCacheExpireHint: '每筆識別元數據的獨立有效期,為 0 時使用內置默認值',
|
||||
metaCacheExpireRequired: '請輸入元數據緩存時間',
|
||||
metaCacheExpireMin: '元數據緩存時間必須大於等於0',
|
||||
scrapFollowTmdb: '跟隨TMDB識別整理',
|
||||
@@ -2860,6 +2861,10 @@ export default {
|
||||
complete: '完成',
|
||||
reset: '重置',
|
||||
},
|
||||
alistgoConfig: {
|
||||
title: 'AList配置',
|
||||
serverUrl: 'AList服務地址',
|
||||
},
|
||||
smbConfig: {
|
||||
title: 'SMB網路共享配置',
|
||||
host: 'SMB伺服器地址',
|
||||
@@ -3005,9 +3010,9 @@ export default {
|
||||
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
|
||||
invalidTextIgnored: '已忽略 {count} 個無效地址',
|
||||
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
|
||||
syncWiki: '同步 Wiki',
|
||||
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
|
||||
syncFailed: '同步 Wiki 失敗:{message}!',
|
||||
syncSources: '同步插件源',
|
||||
syncSuccess: '已同步插件源,新增 {added} 個,共 {total} 個',
|
||||
syncFailed: '同步插件源失敗:{message}!',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
saveSuccess: '插件倉庫儲存成功',
|
||||
|
||||
@@ -126,9 +126,9 @@ onMounted(async () => {
|
||||
|
||||
.settings-section-card {
|
||||
overflow: hidden;
|
||||
border: var(--app-surface-border);
|
||||
backdrop-filter: blur(10px);
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
border: var(--app-grouped-list-border);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background-color: var(--app-grouped-list-background);
|
||||
box-shadow: var(--app-surface-shadow);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
|
||||
+10
-2
@@ -131,6 +131,8 @@ interface PluginAuthPayload {
|
||||
}
|
||||
|
||||
interface ApiErrorPayload {
|
||||
message?: unknown
|
||||
message_i18n?: unknown
|
||||
detail?: unknown
|
||||
mfa_methods?: unknown
|
||||
}
|
||||
@@ -278,9 +280,9 @@ async function exchangePluginAuthTicket(ticket: string) {
|
||||
} catch (error: unknown) {
|
||||
console.error('插件认证票据兑换失败:', error)
|
||||
const apiError = asApiError(error)
|
||||
const detail = apiError.response?.data?.detail
|
||||
const message = apiError.response?.data?.message || apiError.response?.data?.detail
|
||||
pluginAuthError.value =
|
||||
(typeof detail === 'string' ? detail : undefined) || getErrorMessage(error) || t('login.authFailure')
|
||||
(typeof message === 'string' ? message : undefined) || getErrorMessage(error) || t('login.authFailure')
|
||||
} finally {
|
||||
pluginAuthLoading.value = false
|
||||
}
|
||||
@@ -619,6 +621,12 @@ function setLoginError(error: unknown) {
|
||||
return
|
||||
}
|
||||
|
||||
const message = apiError.response.data?.message
|
||||
if (typeof message === 'string' && message) {
|
||||
errorMessage.value = message
|
||||
return
|
||||
}
|
||||
|
||||
switch (apiError.response.status) {
|
||||
case 401:
|
||||
errorMessage.value = t('login.authFailure')
|
||||
|
||||
@@ -1686,8 +1686,10 @@ onUnmounted(() => {
|
||||
|
||||
.search-progress-card {
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), rgb(var(--v-theme-surface));
|
||||
border: var(--app-grouped-list-border);
|
||||
backdrop-filter: var(--app-grouped-list-backdrop-filter);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08), transparent 42%), var(--app-grouped-list-background);
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,8 @@ describe('glass overlay material styles', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('calc(0.1 + var(--glass-surface-density, 0.62) * 0.22)')
|
||||
expect(styles).toContain('--glass-overlay-blur: 3px')
|
||||
expect(styles.match(/--glass-overlay-blur:\s*var\(--glass-overlay-clarity-blur, 6px\)/g)).toHaveLength(2)
|
||||
expect(styles).toContain('--glass-overlay-saturate: 115%')
|
||||
expect(styles).toContain('--glass-overlay-blur: 12px')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 120%')
|
||||
expect(styles).toContain('--glass-overlay-blur: min(var(--glass-blur-raised), 36px)')
|
||||
expect(styles).toContain('--glass-overlay-saturate: 135%')
|
||||
@@ -22,6 +21,70 @@ describe('glass overlay material styles', () => {
|
||||
expect(styles).not.toContain('background: rgba(3, 7, 18, 62%)')
|
||||
})
|
||||
|
||||
it('protects ordinary clear and tinted content without changing raised or frosted materials', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2))')
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.62) * 0.23))')
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%')
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%')
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36))')
|
||||
expect(styles).toContain('rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%')
|
||||
expect(styles).toContain('rgba(255, 255, 255, calc(0.035 + var(--glass-surface-density, 0.86) * 0.075))')
|
||||
expect(styles).toContain('rgba(255, 255, 255, calc(0.03 + var(--glass-surface-density, 0.86) * 0.07))')
|
||||
expect(styles).toContain('rgba(255, 255, 255, calc(0.045 + var(--glass-surface-density, 0.86) * 0.09))')
|
||||
})
|
||||
|
||||
it('uses the derived theme tone only for tinted material surfaces', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const appStyles = readFileSync(resolve(cwd(), 'src/App.vue'), 'utf8')
|
||||
const tintedRule = styles.match(/&\[data-glass-appearance='tinted'\]\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u)
|
||||
?.groups?.declarations
|
||||
const wallpaperTintStart = appStyles.indexOf(
|
||||
"html[data-glass-appearance='tinted'] .background-container.is-glass-theme .background-image.active::after,",
|
||||
)
|
||||
const wallpaperTintEnd = appStyles.indexOf("html[data-glass-appearance='frosted']", wallpaperTintStart)
|
||||
const wallpaperTintRule = appStyles.slice(wallpaperTintStart, wallpaperTintEnd)
|
||||
const loginRule = styles.match(
|
||||
/html\[data-theme='glass'\]\[data-glass-appearance='tinted'\] body\[data-theme='glass'\]\s*\{(?<declarations>[\s\S]*?)\n\}/u,
|
||||
)?.groups?.declarations
|
||||
const workflowRule = styles.match(
|
||||
/&\[data-glass-appearance='tinted'\] \.workflow-task-card\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u,
|
||||
)?.groups?.declarations
|
||||
const getPropertyValue = (rule: string | undefined, token: string) =>
|
||||
rule?.match(new RegExp(`${token}:\\s*(?<value>[\\s\\S]*?);`))?.groups?.value
|
||||
|
||||
expect(tintedRule).toBeDefined()
|
||||
for (const token of [
|
||||
'--glass-surface',
|
||||
'--glass-surface-soft',
|
||||
'--glass-surface-raised',
|
||||
'--glass-overlay-surface',
|
||||
]) {
|
||||
expect(getPropertyValue(tintedRule, token)).toContain('var(--glass-material-accent-rgb)')
|
||||
}
|
||||
for (const token of [
|
||||
'--glass-control',
|
||||
'--glass-control-prominent',
|
||||
'--glass-control-prominent-focus',
|
||||
'--glass-border',
|
||||
'--glass-border-raised',
|
||||
'--glass-border-hover',
|
||||
'--glass-highlight',
|
||||
'--glass-sheen',
|
||||
]) {
|
||||
expect(getPropertyValue(tintedRule, token)).toContain('var(--v-theme-primary)')
|
||||
}
|
||||
expect(wallpaperTintStart).toBeGreaterThanOrEqual(0)
|
||||
expect(wallpaperTintEnd).toBeGreaterThan(wallpaperTintStart)
|
||||
expect(wallpaperTintRule).toContain('rgba(var(--glass-material-accent-rgb), 3%)')
|
||||
expect(wallpaperTintRule).not.toContain('var(--v-theme-primary)')
|
||||
expect(loginRule).toMatch(/\.login-card__surface[\s\S]*?var\(--glass-material-accent-rgb\)/)
|
||||
expect(loginRule).toMatch(/\.native-login-field[\s\S]*?var\(--v-theme-primary\)/)
|
||||
expect(workflowRule).toContain('var(--workflow-status-rgb)')
|
||||
expect(workflowRule).toContain('var(--v-theme-primary)')
|
||||
})
|
||||
|
||||
it('renders colored chips as shadowless glass without flattening their variants', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
@@ -86,6 +149,17 @@ describe('glass overlay material styles', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shared theme foreground token for confirm dialog actions', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const dialog = readFileSync(resolve(cwd(), 'src/@core/components/ConfirmDialog.vue'), 'utf8')
|
||||
|
||||
expect(dialog).toContain('app-confirm-dialog-actions')
|
||||
expect(styles).toContain('--app-confirm-dialog-action-color: rgb(var(--v-theme-on-primary))')
|
||||
expect(styles).toMatch(
|
||||
/\.app-confirm-dialog-actions \.v-btn\s*\{[\s\S]*?color:\s*var\(--app-confirm-dialog-action-color\)\s*!important;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps Chromium frosted fixed shells on the stable wallpaper backplate', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const backplate = readFileSync(resolve(cwd(), 'src/components/theme/GlassFixedShellBackplate.vue'), 'utf8')
|
||||
@@ -139,6 +213,23 @@ describe('glass overlay material styles', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses the popup menu material for compact FAB buttons', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const ruleStart = styles.indexOf('.compact-fab .v-btn {')
|
||||
const ruleEnd = styles.indexOf('\n }', ruleStart)
|
||||
const rule = styles.slice(ruleStart, ruleEnd)
|
||||
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(rule).toContain('border: 1px solid var(--glass-border-raised) !important')
|
||||
expect(rule).toContain('backdrop-filter: var(--glass-overlay-backdrop-filter) !important')
|
||||
expect(rule).toContain('background-color: var(--glass-overlay-surface) !important')
|
||||
expect(rule).toContain('background-image: var(--glass-sheen) !important')
|
||||
expect(rule).toContain('box-shadow: var(--glass-shadow-raised) !important')
|
||||
expect(styles).toMatch(
|
||||
/\.compact-fab \.v-btn:hover\s*\{\s*background-color:\s*var\(--glass-overlay-surface\)\s*!important;/,
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
@@ -188,14 +279,12 @@ describe('glass overlay material styles', () => {
|
||||
/\[data-glass-scroll-presentation='native'\][\s\S]*?\.glass-optical-layer--scroll\s*\{\s*opacity:\s*0\s*!important;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\[data-glass-renderer-state='ready'\][\s\S]*?\.app-hover-lift-card:not\(\.media-card--image-loaded\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||
/\[data-glass-renderer-state='ready'\][\s\S]*?:is\([\s\S]*?\.app-hover-lift-card[\s\S]*?\):not\(\[data-glass-optical-mode='excluded'\]\):not\(\[data-glass-optical-mode='excluded'\] \*\)[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.layout-wrapper:not\(\.layout-fixed-shell-backplate-active\) \.layout-vertical-nav::before,[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.settings-section-card\.app-grouped-list\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-native-surface-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
expect(styles).not.toContain('.settings-section-card.app-grouped-list')
|
||||
expect(styles).toMatch(
|
||||
/\.file-browser-toolbar\.v-toolbar\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-surface-backdrop-filter\)\s*!important;/,
|
||||
)
|
||||
@@ -204,6 +293,26 @@ describe('glass overlay material styles', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the audited content surfaces on the shared grouped-list material contract', () => {
|
||||
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||
const transparentStyles = readFileSync(resolve(cwd(), 'src/styles/themes/transparent.scss'), 'utf8')
|
||||
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const resourcePage = readFileSync(resolve(cwd(), 'src/pages/resource.vue'), 'utf8')
|
||||
const appCenterPage = readFileSync(resolve(cwd(), 'src/pages/appcenter.vue'), 'utf8')
|
||||
|
||||
expect(commonStyles).toContain('--app-grouped-list-backdrop-filter: none')
|
||||
expect(transparentStyles).toContain('--app-grouped-list-backdrop-filter: blur(var(--transparent-blur))')
|
||||
expect(transparentStyles.match(/--app-grouped-list-backdrop-filter:\s*none/g)).toHaveLength(3)
|
||||
expect(glassStyles).toContain('--app-grouped-list-backdrop-filter: var(--glass-surface-backdrop-filter)')
|
||||
|
||||
for (const page of [resourcePage, appCenterPage]) {
|
||||
expect(page).toContain('border: var(--app-grouped-list-border)')
|
||||
expect(page).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
|
||||
expect(page).toContain('var(--app-grouped-list-background)')
|
||||
expect(page).not.toContain('backdrop-filter: blur(10px)')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps frosted route opacity static while preserving its short movement', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cwd } from 'node:process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('plugin card accent styles', () => {
|
||||
it('inherits the icon-derived accent instead of shadowing it on the banner', () => {
|
||||
it('uses a nullable icon accent before the dynamic material and theme fallbacks', () => {
|
||||
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||
const ruleStart = commonStyles.indexOf('.plugin-card__banner')
|
||||
const ruleEnd = commonStyles.indexOf('.grid-downloading-card', ruleStart)
|
||||
@@ -12,7 +12,21 @@ describe('plugin card accent styles', () => {
|
||||
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||
expect(bannerRule).not.toMatch(/--plugin-card-accent-rgb\s*:/)
|
||||
expect(bannerRule).toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
|
||||
expect(bannerRule).toContain('--plugin-card-effective-accent-rgb: var(')
|
||||
expect(bannerRule).toContain('--plugin-card-accent-rgb,')
|
||||
expect(bannerRule).toContain('var(--glass-material-accent-rgb, var(--v-theme-primary))')
|
||||
expect(bannerRule).not.toContain('40, 169, 225')
|
||||
})
|
||||
|
||||
it('limits tinted theme mixing to six percent', () => {
|
||||
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const tintedBannerRule = glassStyles.match(
|
||||
/&\[data-glass-appearance='tinted'\] \.plugin-card__banner\s*\{(?<declarations>[\s\S]*?)\n {2}\}/u,
|
||||
)?.groups?.declarations
|
||||
|
||||
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)/g)).toHaveLength(9)
|
||||
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)[\s\S]*?\) 94%/g)).toHaveLength(2)
|
||||
expect(tintedBannerRule?.match(/rgba\(var\(--glass-material-accent-rgb\)[\s\S]*?\)\s*\)/g)).toHaveLength(2)
|
||||
expect(glassStyles).not.toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('mobile responsive input actions', () => {
|
||||
it('keeps functional trailing controls without restoring decorative icons', () => {
|
||||
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||
|
||||
expect(commonStyles).toMatch(
|
||||
/\.app-responsive-input__native \.v-field__clearable,\s*\.app-responsive-input__native\s+\.v-field__append-inner:has\([\s\S]*?\)\s*\{\s*display: inline-flex;/,
|
||||
)
|
||||
expect(commonStyles).toContain("[role='button']:not(")
|
||||
expect(commonStyles).toContain('.v-select__menu-icon')
|
||||
expect(commonStyles).toContain('.v-autocomplete__menu-icon')
|
||||
expect(commonStyles).toContain('.v-combobox__menu-icon')
|
||||
expect(commonStyles).not.toContain('app-responsive-input--keep-append-action')
|
||||
})
|
||||
})
|
||||
+14
-6
@@ -543,16 +543,20 @@ html[data-theme-radius='extra'] {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
// 移动录入行只保留值本身,统一移除字段内部的装饰与操作图标。
|
||||
// 移动录入行隐藏字段内部的装饰图标,避免挤压右栏中的实际内容。
|
||||
.app-responsive-input__native .v-field__prepend-inner,
|
||||
.app-responsive-input__native .v-field__append-inner,
|
||||
.app-responsive-input__native .v-field__clearable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 少数字段的内嵌图标承担了唯一的交互入口(如按名称搜索媒体编号),
|
||||
// 不是纯装饰,移动端也需要保留可点击。
|
||||
.app-responsive-input--keep-append-action .app-responsive-input__native .v-field__append-inner {
|
||||
// 清除控件与具备按钮语义的末端操作仍需可用;下拉箭头仅重复整字段操作,不单独展示。
|
||||
.app-responsive-input__native .v-field__clearable,
|
||||
.app-responsive-input__native
|
||||
.v-field__append-inner:has(
|
||||
button,
|
||||
[role='button']:not(.v-select__menu-icon, .v-autocomplete__menu-icon, .v-combobox__menu-icon)
|
||||
) {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@@ -1346,10 +1350,14 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
||||
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
||||
transition: background-image 0.2s ease;
|
||||
|
||||
--plugin-card-effective-accent-rgb: var(
|
||||
--plugin-card-accent-rgb,
|
||||
var(--glass-material-accent-rgb, var(--v-theme-primary))
|
||||
);
|
||||
--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, 40, 169, 225)) 0%,
|
||||
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
|
||||
rgb(var(--plugin-card-effective-accent-rgb)) 0%,
|
||||
rgb(var(--plugin-card-effective-accent-rgb)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
// 材质只覆盖统一表面 token;质量档只替换光学层,不改变业务组件契约。
|
||||
html[data-theme='glass'] {
|
||||
--glass-surface: rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.62) * 0.3));
|
||||
--glass-surface-soft: rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.62) * 0.32));
|
||||
--glass-surface: rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.62) * 0.2));
|
||||
--glass-surface-soft: rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.62) * 0.23));
|
||||
--glass-surface-raised: rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.62) * 0.36));
|
||||
--glass-control: rgba(11, 19, 34, 52%);
|
||||
--glass-control-prominent: rgba(255, 255, 255, 7%);
|
||||
@@ -54,7 +54,7 @@ html[data-theme='glass'] {
|
||||
--glass-navbar-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
--glass-navbar-scrolled-backdrop-filter: blur(3px) saturate(115%);
|
||||
--glass-overlay-surface: rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.22));
|
||||
--glass-overlay-blur: 3px;
|
||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||
--glass-overlay-saturate: 115%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 30%);
|
||||
--glass-overlay-backdrop-filter: blur(var(--glass-overlay-blur)) saturate(var(--glass-overlay-saturate));
|
||||
@@ -106,6 +106,7 @@ html[data-theme='glass'] {
|
||||
--app-card-light-border-opacity: 0.15;
|
||||
--app-card-light-border: 1px solid var(--glass-border);
|
||||
--app-overlay-border: 1px solid var(--glass-border);
|
||||
--app-confirm-dialog-action-color: rgb(var(--v-theme-on-primary));
|
||||
--app-grouped-list-background: var(--glass-surface);
|
||||
--app-grouped-list-backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
--app-grouped-list-border: 1px solid var(--glass-border);
|
||||
@@ -129,18 +130,18 @@ html[data-theme='glass'] {
|
||||
&[data-glass-appearance='tinted'] {
|
||||
--glass-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.02 + var(--glass-surface-density, 0.72) * 0.3)) 88%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.28))
|
||||
rgba(11, 19, 34, calc(0.12 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.28))
|
||||
);
|
||||
--glass-surface-soft: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.04 + var(--glass-surface-density, 0.72) * 0.32)) 89%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.26))
|
||||
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.72) * 0.23)) 89%,
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.26))
|
||||
);
|
||||
--glass-surface-raised: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.07 + var(--glass-surface-density, 0.72) * 0.36)) 84%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.32))
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.32))
|
||||
);
|
||||
--glass-control: color-mix(in srgb, rgba(11, 19, 34, 52%) 88%, rgba(var(--v-theme-primary), 28%));
|
||||
--glass-control-prominent: color-mix(in srgb, rgba(255, 255, 255, 7%) 82%, rgba(var(--v-theme-primary), 24%));
|
||||
@@ -184,9 +185,9 @@ html[data-theme='glass'] {
|
||||
--glass-overlay-surface: color-mix(
|
||||
in srgb,
|
||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.2)) 88%,
|
||||
rgba(var(--v-theme-primary), calc(var(--glass-tint-density, 0.65) * 0.22))
|
||||
rgba(var(--glass-material-accent-rgb), calc(var(--glass-tint-density, 0.65) * 0.22))
|
||||
);
|
||||
--glass-overlay-blur: 12px;
|
||||
--glass-overlay-blur: var(--glass-overlay-clarity-blur, 6px);
|
||||
--glass-overlay-saturate: 120%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 32%);
|
||||
--glass-chip-backdrop-filter: blur(10px) saturate(165%) brightness(var(--glass-transmission-brightness));
|
||||
@@ -385,8 +386,8 @@ html[data-theme='glass'] {
|
||||
background-color: var(--glass-surface) !important;
|
||||
}
|
||||
|
||||
// 海报完成绘制后已完全遮住卡片底面,释放不可见的实时背景采样。
|
||||
.media-card.media-card--image-loaded {
|
||||
// 只有成功覆盖的真实海报才释放底面采样;占位图与失败态仍保留完整材料。
|
||||
.media-card[data-glass-optical-mode='excluded'] {
|
||||
-webkit-backdrop-filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
@@ -603,7 +604,6 @@ html[data-theme='glass'] {
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.compact-fab .v-btn,
|
||||
.global-action-buttons .v-btn {
|
||||
border: 1px solid var(--glass-border) !important;
|
||||
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
|
||||
@@ -613,6 +613,16 @@ html[data-theme='glass'] {
|
||||
box-shadow: var(--glass-control-shadow) !important;
|
||||
}
|
||||
|
||||
// 聚合 FAB 与弹出菜单使用同一磨砂材质,展开后保持稳定底色和背景采样。
|
||||
.compact-fab .v-btn {
|
||||
border: 1px solid var(--glass-border-raised) !important;
|
||||
-webkit-backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
|
||||
background-color: var(--glass-overlay-surface) !important;
|
||||
background-image: var(--glass-sheen) !important;
|
||||
box-shadow: var(--glass-shadow-raised) !important;
|
||||
}
|
||||
|
||||
.v-overlay__scrim {
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
@@ -799,6 +809,11 @@ html[data-theme='glass'] {
|
||||
box-shadow var(--glass-motion);
|
||||
}
|
||||
|
||||
// 语义色按钮在半透明玻璃表面上统一使用主题的高对比前景色,避免继承深色 on-color。
|
||||
.app-confirm-dialog-actions .v-btn {
|
||||
color: var(--app-confirm-dialog-action-color) !important;
|
||||
}
|
||||
|
||||
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
|
||||
border: 1px solid var(--glass-border);
|
||||
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
|
||||
@@ -997,23 +1012,17 @@ html[data-theme='glass'] {
|
||||
.workflow-share-card {
|
||||
--workflow-share-glass-start-opacity: calc(0.18 + var(--glass-tint-density, 0.65) * 0.38);
|
||||
--workflow-share-glass-end-opacity: calc(0.24 + var(--glass-tint-density, 0.65) * 0.42);
|
||||
--workflow-share-glass-scrim:
|
||||
linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
|
||||
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
|
||||
);
|
||||
--workflow-share-glass-scrim: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.1 + var(--glass-surface-density, 0.62) * 0.1)),
|
||||
rgba(11, 19, 34, calc(0.2 + var(--glass-surface-density, 0.62) * 0.14))
|
||||
);
|
||||
|
||||
background-color: var(--glass-surface) !important;
|
||||
background-image:
|
||||
var(--glass-sheen),
|
||||
var(--workflow-share-glass-scrim),
|
||||
var(--glass-sheen), var(--workflow-share-glass-scrim),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(
|
||||
var(--workflow-share-gradient-start-rgb, 74, 85, 104),
|
||||
var(--workflow-share-glass-start-opacity)
|
||||
)
|
||||
0%,
|
||||
rgba(var(--workflow-share-gradient-start-rgb, 74, 85, 104), var(--workflow-share-glass-start-opacity)) 0%,
|
||||
rgba(var(--workflow-share-gradient-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
|
||||
) !important;
|
||||
}
|
||||
@@ -1028,9 +1037,9 @@ html[data-theme='glass'] {
|
||||
);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1056,9 +1065,9 @@ html[data-theme='glass'] {
|
||||
);
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1080,11 +1089,10 @@ html[data-theme='glass'] {
|
||||
&[data-glass-appearance='frosted'] .workflow-share-card {
|
||||
--workflow-share-glass-start-opacity: calc(0.12 + var(--glass-tint-density, 0.65) * 0.28);
|
||||
--workflow-share-glass-end-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.34);
|
||||
--workflow-share-glass-scrim:
|
||||
linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
|
||||
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
|
||||
);
|
||||
--workflow-share-glass-scrim: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.06 + var(--glass-surface-density, 0.86) * 0.07)),
|
||||
rgba(11, 19, 34, calc(0.13 + var(--glass-surface-density, 0.86) * 0.1))
|
||||
);
|
||||
}
|
||||
|
||||
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
||||
@@ -1093,17 +1101,17 @@ html[data-theme='glass'] {
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 74%,
|
||||
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 94%,
|
||||
rgba(var(--glass-material-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
|
||||
)
|
||||
0%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 74%,
|
||||
rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 94%,
|
||||
rgba(var(--glass-material-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
|
||||
)
|
||||
58%,
|
||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
||||
rgba(var(--plugin-card-effective-accent-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1135,11 +1143,10 @@ html[data-theme='glass'] {
|
||||
&[data-glass-appearance='tinted'] .workflow-share-card {
|
||||
--workflow-share-glass-start-opacity: calc(0.16 + var(--glass-tint-density, 0.65) * 0.32);
|
||||
--workflow-share-glass-end-opacity: calc(0.21 + var(--glass-tint-density, 0.65) * 0.38);
|
||||
--workflow-share-glass-scrim:
|
||||
linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
|
||||
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
|
||||
);
|
||||
--workflow-share-glass-scrim: linear-gradient(
|
||||
rgba(11, 19, 34, calc(0.09 + var(--glass-surface-density, 0.72) * 0.09)),
|
||||
rgba(11, 19, 34, calc(0.18 + var(--glass-surface-density, 0.72) * 0.13))
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
||||
@@ -1180,12 +1187,6 @@ html[data-theme='glass'] {
|
||||
}
|
||||
}
|
||||
|
||||
// “更多”应用列表的分组卡片存在局部固定模糊,需要在完整小屏区间跟随玻璃材质。
|
||||
.settings-section-card.app-grouped-list {
|
||||
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
}
|
||||
|
||||
// 文件地址栏与下方内容卡属于同一工作表面,不使用更强的 raised 材质。
|
||||
.file-browser-toolbar.v-toolbar {
|
||||
-webkit-backdrop-filter: var(--glass-surface-backdrop-filter) !important;
|
||||
@@ -1320,6 +1321,11 @@ html[data-theme='glass'] {
|
||||
background-color: rgba(var(--v-theme-error), 42%) !important;
|
||||
}
|
||||
|
||||
// FAB 悬浮时保持弹出菜单材质,仅通过描边和阴影反馈交互,避免背景进一步透明。
|
||||
.compact-fab .v-btn:hover {
|
||||
background-color: var(--glass-overlay-surface) !important;
|
||||
}
|
||||
|
||||
.v-card--link:hover {
|
||||
border-color: var(--glass-border-hover) !important;
|
||||
box-shadow: var(--glass-shadow-hover) !important;
|
||||
@@ -1494,13 +1500,19 @@ html[data-glass-appearance='frosted']:is(
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动表面始终由原生 backdrop 持有壁纸基底;GPU 层只叠加局部动态折射。
|
||||
// 滚动表面由原生 backdrop 持有壁纸基底;完整内容覆盖的排除子树不再采样。
|
||||
html:is([data-glass-quality='balanced'], [data-glass-quality='high'])[data-glass-renderer-state='ready']
|
||||
body[data-theme='glass'] {
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > :first-child > .v-card,
|
||||
[data-glass-optical-surface],
|
||||
.app-hover-lift-card:not(.media-card--image-loaded) {
|
||||
:is(
|
||||
.dashboard-grid-item-content > .dashboard-grid-auto-size > .dashboard-grid-content-measure > .v-card,
|
||||
.dashboard-grid-item-content
|
||||
> .dashboard-grid-auto-size
|
||||
> .dashboard-grid-content-measure
|
||||
> :first-child
|
||||
> .v-card,
|
||||
[data-glass-optical-surface],
|
||||
.app-hover-lift-card
|
||||
):not([data-glass-optical-mode='excluded']):not([data-glass-optical-mode='excluded'] *) {
|
||||
-webkit-backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-native-surface-backdrop-filter) !important;
|
||||
}
|
||||
@@ -1561,7 +1573,7 @@ html[data-theme='glass'][data-glass-appearance='tinted'] body[data-theme='glass'
|
||||
.login-card__surface {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.1), transparent 36%),
|
||||
linear-gradient(rgba(var(--v-theme-primary), 0.08), rgba(var(--v-theme-primary), 0.03)),
|
||||
linear-gradient(rgba(var(--glass-material-accent-rgb), 0.08), rgba(var(--glass-material-accent-rgb), 0.03)),
|
||||
linear-gradient(rgba(7, 14, 25, 0.16), rgba(7, 14, 25, 0.3)) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { normalizePluginAccentColor, normalizeThemeMaterialAccent } from '@/utils/glassColor'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface OklchColor {
|
||||
lightness: number
|
||||
chroma: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number) {
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function hexToOklch(color: string): OklchColor {
|
||||
const channels = [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map(channel =>
|
||||
srgbToLinear(Number.parseInt(channel, 16) / 255),
|
||||
)
|
||||
const [red, green, blue] = channels
|
||||
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue)
|
||||
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue)
|
||||
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue)
|
||||
const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
|
||||
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
|
||||
const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
||||
|
||||
return { lightness, chroma: Math.hypot(a, b), hue: Math.atan2(b, a) }
|
||||
}
|
||||
|
||||
function hueDistanceDegrees(first: number, second: number) {
|
||||
const radians = Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)))
|
||||
|
||||
return (radians * 180) / Math.PI
|
||||
}
|
||||
|
||||
describe('normalizePluginAccentColor', () => {
|
||||
it.each(['#ff0000', '#00ff00', '#0000ff', '#00ffff', '#ff00ff', '#f5c400'])(
|
||||
'keeps chromatic %s within the plugin accent contract',
|
||||
sourceHex => {
|
||||
const normalized = normalizePluginAccentColor(sourceHex)
|
||||
|
||||
expect(normalized).toBeDefined()
|
||||
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
|
||||
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.18) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['#000000', '#ffffff', '#7d7d7d'])('keeps neutral %s neutral without inventing chroma', sourceHex => {
|
||||
const normalized = normalizePluginAccentColor(sourceHex)
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||
expect(output.chroma).toBeLessThanOrEqual(source.chroma + 0.003)
|
||||
})
|
||||
|
||||
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
|
||||
expect(normalizePluginAccentColor('#12ABef')).toEqual(normalizePluginAccentColor('#12abef'))
|
||||
expect(normalizePluginAccentColor('#fff')).toBeUndefined()
|
||||
expect(normalizePluginAccentColor('12abef')).toBeUndefined()
|
||||
expect(normalizePluginAccentColor('#gg0000')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeThemeMaterialAccent', () => {
|
||||
it.each([
|
||||
'#8D51F9',
|
||||
'#3F51B5',
|
||||
'#1976D2',
|
||||
'#00BCD4',
|
||||
'#009688',
|
||||
'#4CAF50',
|
||||
'#FFB400',
|
||||
'#FF9800',
|
||||
'#FF4C51',
|
||||
'#E91E63',
|
||||
'#16B1FF',
|
||||
'#607D8B',
|
||||
])('keeps preset %s within the material tone contract', sourceHex => {
|
||||
const normalized = normalizeThemeMaterialAccent(sourceHex)
|
||||
|
||||
expect(normalized).toBeDefined()
|
||||
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
|
||||
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.725)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
|
||||
it.each(['#000000', '#ffffff', '#7d7d7d', '#ffff00', '#00ffff', '#ff0000'])(
|
||||
'keeps extreme %s in gamut without inventing chroma',
|
||||
sourceHex => {
|
||||
const normalized = normalizeThemeMaterialAccent(sourceHex)
|
||||
const source = hexToOklch(sourceHex)
|
||||
const output = hexToOklch(normalized!.hex)
|
||||
|
||||
expect(output.lightness).toBeGreaterThanOrEqual(0.555)
|
||||
expect(output.lightness).toBeLessThanOrEqual(0.725)
|
||||
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.14) + 0.005)
|
||||
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
|
||||
expect(normalizeThemeMaterialAccent('#12ABef')).toEqual(normalizeThemeMaterialAccent('#12abef'))
|
||||
expect(normalizeThemeMaterialAccent('#fff')).toBeUndefined()
|
||||
expect(normalizeThemeMaterialAccent('12abef')).toBeUndefined()
|
||||
expect(normalizeThemeMaterialAccent('#gg0000')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getGlassCssFrostBlur,
|
||||
getGlassCoverScale,
|
||||
getGlassMaterialResponse,
|
||||
getGlassOverlayClarityBlur,
|
||||
getGlassOpticalCssTransmissionBrightness,
|
||||
getGlassOpticalDecay,
|
||||
getGlassOpticalBufferSize,
|
||||
@@ -269,6 +270,34 @@ describe('glass optics geometry', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('derives a continuous overlay clarity floor from transparency anchors', () => {
|
||||
const anchors = [
|
||||
[0, 8.9],
|
||||
[20, 7.8],
|
||||
[50, 6.7],
|
||||
[70, 5.8],
|
||||
[85, 5.4],
|
||||
[100, 5],
|
||||
] as const
|
||||
|
||||
for (const [transparency, blur] of anchors) {
|
||||
expect(getGlassOverlayClarityBlur(transparency)).toBe(blur)
|
||||
}
|
||||
|
||||
expect(getGlassOverlayClarityBlur(-1)).toBe(8.9)
|
||||
expect(getGlassOverlayClarityBlur(101)).toBe(5)
|
||||
expect(getGlassOverlayClarityBlur(Number.NaN)).toBe(6.7)
|
||||
|
||||
const samples = Array.from({ length: 101 }, (_, value) => getGlassOverlayClarityBlur(value))
|
||||
expect(samples.every((sample, index) => index === 0 || sample <= samples[index - 1])).toBe(true)
|
||||
|
||||
for (const anchor of [20, 50, 70, 85]) {
|
||||
const blur = getGlassOverlayClarityBlur(anchor)
|
||||
expect(Math.abs(getGlassOverlayClarityBlur(anchor - 1) - blur)).toBeLessThan(0.02)
|
||||
expect(Math.abs(getGlassOverlayClarityBlur(anchor + 1) - blur)).toBeLessThan(0.03)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns preset copies so previews cannot mutate the shared matrix', () => {
|
||||
const first = getGlassOpticalPresetParameters('tinted', 'high', 'glide')
|
||||
first.translation = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import mdLinkAttributes from 'markdown-it-link-attributes'
|
||||
|
||||
const agentMarkdown = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
})
|
||||
|
||||
agentMarkdown.use(mdLinkAttributes, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
},
|
||||
})
|
||||
|
||||
// Agent 内容来自模型和工具输出,禁用原始 HTML 后统一转换为可展示 Markdown。
|
||||
export function renderAgentMarkdown(content: string) {
|
||||
return content ? agentMarkdown.render(content) : ''
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
interface OklabColor {
|
||||
lightness: number
|
||||
a: number
|
||||
b: number
|
||||
}
|
||||
|
||||
interface OklchColor {
|
||||
lightness: number
|
||||
chroma: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
export interface GlassAccentColor {
|
||||
/** 可传给颜色输入或 WebGL 的六位十六进制色值。 */
|
||||
hex: string
|
||||
/** 可直接用于 `rgb()` / `rgba()` CSS 变量的通道值。 */
|
||||
rgb: string
|
||||
}
|
||||
|
||||
const PLUGIN_ACCENT_MIN_LIGHTNESS = 0.48
|
||||
const PLUGIN_ACCENT_MAX_LIGHTNESS = 0.76
|
||||
const PLUGIN_ACCENT_MAX_CHROMA = 0.18
|
||||
const THEME_MATERIAL_MIN_LIGHTNESS = 0.56
|
||||
const THEME_MATERIAL_MAX_LIGHTNESS = 0.72
|
||||
const THEME_MATERIAL_MAX_CHROMA = 0.14
|
||||
const NEUTRAL_CHROMA_THRESHOLD = 0.02
|
||||
const GAMUT_SEARCH_ITERATIONS = 24
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function parseHexColor(color: string) {
|
||||
if (!/^#[0-9a-f]{6}$/i.test(color)) return undefined
|
||||
|
||||
return [
|
||||
Number.parseInt(color.slice(1, 3), 16) / 255,
|
||||
Number.parseInt(color.slice(3, 5), 16) / 255,
|
||||
Number.parseInt(color.slice(5, 7), 16) / 255,
|
||||
] as const
|
||||
}
|
||||
|
||||
function srgbToLinear(channel: number) {
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
function linearToSrgb(channel: number) {
|
||||
return channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055
|
||||
}
|
||||
|
||||
function srgbToOklab([red, green, blue]: readonly [number, number, number]): OklabColor {
|
||||
const linearRed = srgbToLinear(red)
|
||||
const linearGreen = srgbToLinear(green)
|
||||
const linearBlue = srgbToLinear(blue)
|
||||
const l = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||
const m = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||
const s = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||
|
||||
return {
|
||||
lightness: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
}
|
||||
}
|
||||
|
||||
function oklabToLinearSrgb({ lightness, a, b }: OklabColor) {
|
||||
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
||||
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
||||
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
|
||||
|
||||
return [
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||
] as const
|
||||
}
|
||||
|
||||
function oklabToOklch({ lightness, a, b }: OklabColor): OklchColor {
|
||||
return {
|
||||
lightness,
|
||||
chroma: Math.hypot(a, b),
|
||||
hue: Math.atan2(b, a),
|
||||
}
|
||||
}
|
||||
|
||||
function oklchToOklab({ lightness, chroma, hue }: OklchColor): OklabColor {
|
||||
return {
|
||||
lightness,
|
||||
a: chroma * Math.cos(hue),
|
||||
b: chroma * Math.sin(hue),
|
||||
}
|
||||
}
|
||||
|
||||
function isInSrgbGamut(color: OklchColor) {
|
||||
return oklabToLinearSrgb(oklchToOklab(color)).every(channel => channel >= 0 && channel <= 1)
|
||||
}
|
||||
|
||||
function mapChromaToSrgb(color: OklchColor): OklchColor {
|
||||
if (isInSrgbGamut(color)) return color
|
||||
|
||||
let lowerChroma = 0
|
||||
let upperChroma = color.chroma
|
||||
for (let iteration = 0; iteration < GAMUT_SEARCH_ITERATIONS; iteration += 1) {
|
||||
const candidateChroma = (lowerChroma + upperChroma) / 2
|
||||
if (isInSrgbGamut({ ...color, chroma: candidateChroma })) lowerChroma = candidateChroma
|
||||
else upperChroma = candidateChroma
|
||||
}
|
||||
|
||||
return { ...color, chroma: lowerChroma }
|
||||
}
|
||||
|
||||
function formatAccentColor(color: OklchColor): GlassAccentColor {
|
||||
const channels = oklabToLinearSrgb(oklchToOklab(color)).map(channel =>
|
||||
Math.round(clamp(linearToSrgb(channel), 0, 1) * 255),
|
||||
) as [number, number, number]
|
||||
|
||||
return {
|
||||
hex: `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`,
|
||||
rgb: channels.join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccentColor(color: string, minLightness: number, maxLightness: number, maxChroma: number) {
|
||||
const srgb = parseHexColor(color)
|
||||
if (!srgb) return undefined
|
||||
|
||||
const source = oklabToOklch(srgbToOklab(srgb))
|
||||
const chroma = source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, maxChroma)
|
||||
const normalized = mapChromaToSrgb({
|
||||
lightness: clamp(source.lightness, minLightness, maxLightness),
|
||||
chroma,
|
||||
hue: source.hue,
|
||||
})
|
||||
|
||||
return formatAccentColor(normalized)
|
||||
}
|
||||
|
||||
/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */
|
||||
export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined {
|
||||
return normalizeAccentColor(color, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS, PLUGIN_ACCENT_MAX_CHROMA)
|
||||
}
|
||||
|
||||
/** 派生大面积色调玻璃使用的材料色,不改变用户选择的真实主色。 */
|
||||
export function normalizeThemeMaterialAccent(color: string): GlassAccentColor | undefined {
|
||||
return normalizeAccentColor(
|
||||
color,
|
||||
THEME_MATERIAL_MIN_LIGHTNESS,
|
||||
THEME_MATERIAL_MAX_LIGHTNESS,
|
||||
THEME_MATERIAL_MAX_CHROMA,
|
||||
)
|
||||
}
|
||||
@@ -250,6 +250,7 @@ const GLASS_SURFACE_DENSITY: Record<GlassAppearance, readonly number[]> = {
|
||||
const GLASS_TINT_DENSITY = [1, 0.9, 0.65, 0.48, 0.36, 0.28] as const
|
||||
const GLASS_FROST_DENSITY = [1, 0.9, 0.7, 0.34, 0.12, 0.04] as const
|
||||
const GLASS_FROSTED_DENSITY = [1, 0.82, 0.55, 0.28, 0.1, 0.025] as const
|
||||
const GLASS_OVERLAY_CLARITY_BLUR = [8.9, 7.8, 6.7, 5.8, 5.4, 5] as const
|
||||
|
||||
/** 在相邻业务锚点之间使用零斜率边界插值,避免滑杆经过锚点时出现视觉折线。 */
|
||||
function interpolateGlassResponse(value: unknown, anchors: readonly number[]) {
|
||||
@@ -292,6 +293,11 @@ export function getGlassCssFrostBlur(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 透明与色调浮层保留独立模糊下限,避免高通透度使临时内容直接暴露在复杂壁纸上。 */
|
||||
export function getGlassOverlayClarityBlur(value: unknown) {
|
||||
return interpolateGlassResponse(value, GLASS_OVERLAY_CLARITY_BLUR)
|
||||
}
|
||||
|
||||
/** 计算与 CSS `ease` 相同的交叉淡化进度,使 DOM 壁纸与 shader 双纹理保持同一时钟。 */
|
||||
export function getGlassWallpaperTransitionProgress(elapsed: number, duration: number) {
|
||||
if (duration <= 0 || elapsed >= duration) return 1
|
||||
|
||||
@@ -53,8 +53,8 @@ vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
vi.mock('@/@core/utils/image', () => ({
|
||||
extractDominantColor: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePWA', () => ({
|
||||
|
||||
@@ -4,6 +4,7 @@ import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { configureAceEditorPadding } from '@/utils/aceEditor'
|
||||
import type { Ace } from 'ace-builds'
|
||||
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
|
||||
@@ -52,7 +53,27 @@ const saving = ref(false)
|
||||
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
|
||||
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
|
||||
|
||||
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
|
||||
interface WordListModeConfig {
|
||||
path: 'ace/mode/word_list'
|
||||
syntax: boolean
|
||||
}
|
||||
|
||||
const aceEditor = shallowRef<Ace.Editor | null>(null)
|
||||
|
||||
function onAceInit(editor: Ace.Editor) {
|
||||
aceEditor.value = editor
|
||||
configureAceEditorPadding(editor)
|
||||
applyWordListSyntax()
|
||||
}
|
||||
|
||||
function applyWordListSyntax() {
|
||||
if (!aceEditor.value) return
|
||||
const mode: WordListModeConfig = {
|
||||
path: 'ace/mode/word_list',
|
||||
syntax: showSyntaxHighlighting.value,
|
||||
}
|
||||
aceEditor.value.session.setMode(mode as unknown as Ace.SyntaxMode)
|
||||
}
|
||||
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
|
||||
const textEditorOptions = computed(() => ({
|
||||
fontSize: 13.6,
|
||||
@@ -73,6 +94,8 @@ watch(showSyntaxHighlighting, value => {
|
||||
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
|
||||
})
|
||||
|
||||
watch(showSyntaxHighlighting, applyWordListSyntax)
|
||||
|
||||
const savedTextValues = reactive<Record<TextSectionKey, string>>({
|
||||
identifiers: '',
|
||||
releaseGroups: '',
|
||||
@@ -528,14 +551,14 @@ onMounted(() => {
|
||||
<VAceEditor
|
||||
v-if="activeSection === 'identifiers'"
|
||||
v-model:value="activeTextValue"
|
||||
:lang="textEditorLanguage"
|
||||
lang="word_list"
|
||||
:theme="textEditorTheme"
|
||||
:options="textEditorOptions"
|
||||
:placeholder="activeTextPlaceholder"
|
||||
:print-margin="false"
|
||||
wrap
|
||||
class="words-text-editor"
|
||||
@init="configureAceEditorPadding"
|
||||
@init="onAceInit"
|
||||
/>
|
||||
<VTextarea
|
||||
v-else
|
||||
@@ -924,6 +947,9 @@ onMounted(() => {
|
||||
--words-token-block: #af00db;
|
||||
--words-token-replaced: #001080;
|
||||
--words-token-replacement: #a31515;
|
||||
--words-token-parameter-syntax: #795e26;
|
||||
--words-token-parameter-key: #0451a5;
|
||||
--words-token-parameter-value: #098658;
|
||||
--words-token-front: #267f99;
|
||||
--words-token-back: #795e26;
|
||||
--words-token-offset: #098658;
|
||||
@@ -943,6 +969,9 @@ onMounted(() => {
|
||||
--words-token-block: #c586c0;
|
||||
--words-token-replaced: #9cdcfe;
|
||||
--words-token-replacement: #ce9178;
|
||||
--words-token-parameter-syntax: #dcdcaa;
|
||||
--words-token-parameter-key: #9cdcfe;
|
||||
--words-token-parameter-value: #b5cea8;
|
||||
--words-token-front: #4ec9b0;
|
||||
--words-token-back: #dcdcaa;
|
||||
--words-token-offset: #b5cea8;
|
||||
@@ -982,6 +1011,25 @@ onMounted(() => {
|
||||
color: var(--words-token-replacement);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_syntax) {
|
||||
color: var(--words-token-parameter-syntax);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_key) {
|
||||
color: var(--words-token-parameter-key);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_parameter_value) {
|
||||
color: var(--words-token-parameter-value);
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_invalid.ace_word-list) {
|
||||
color: #f44336 !important;
|
||||
background-color: transparent !important;
|
||||
text-decoration: underline wavy #f44336 !important;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.words-text-editor :deep(.ace_word_list_front) {
|
||||
color: var(--words-token-front);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
|
||||
apiPost: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
aceSetMode: vi.fn(),
|
||||
aceSetPadding: vi.fn(),
|
||||
aceSetScrollMargin: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
@@ -33,6 +36,13 @@ const AceEditorStub = defineComponent({
|
||||
options: { type: Object, default: () => ({}) },
|
||||
value: { type: String, default: '' },
|
||||
},
|
||||
emits: ['init', 'update:value'],
|
||||
mounted() {
|
||||
this.$emit('init', {
|
||||
session: { setMode: mocks.aceSetMode },
|
||||
renderer: { setPadding: mocks.aceSetPadding, setScrollMargin: mocks.aceSetScrollMargin },
|
||||
})
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
data-testid="words-ace-editor"
|
||||
@@ -61,6 +71,9 @@ describe('WordsView editor preferences', () => {
|
||||
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
|
||||
})
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
mocks.aceSetMode.mockClear()
|
||||
mocks.aceSetPadding.mockClear()
|
||||
mocks.aceSetScrollMargin.mockClear()
|
||||
})
|
||||
|
||||
it('keeps line numbers disabled when no preference is stored', async () => {
|
||||
@@ -74,6 +87,7 @@ describe('WordsView editor preferences', () => {
|
||||
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
|
||||
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: false })
|
||||
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
|
||||
})
|
||||
@@ -110,7 +124,8 @@ describe('WordsView editor preferences', () => {
|
||||
const editor = await screen.findByTestId('words-ace-editor')
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: true })
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
})
|
||||
|
||||
it('updates Ace options and persists the preference without changing content', async () => {
|
||||
@@ -139,7 +154,7 @@ describe('WordsView editor preferences', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
|
||||
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderWordsView()
|
||||
|
||||
@@ -149,7 +164,7 @@ describe('WordsView editor preferences', () => {
|
||||
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
|
||||
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: true })
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
|
||||
})
|
||||
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
|
||||
@@ -158,7 +173,7 @@ describe('WordsView editor preferences', () => {
|
||||
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editor).toHaveAttribute('data-lang', 'word_list')
|
||||
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: false })
|
||||
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user