mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 03:27:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0045ecf5d9 | ||
|
|
50fa751eb5 | ||
|
|
aa6dfd8569 | ||
|
|
b5ee946919 | ||
|
|
6a21bb55c3 | ||
|
|
737d464950 | ||
|
|
2c467335fa | ||
|
|
24f1580501 | ||
|
|
1129d63fcc | ||
|
|
b1f3d3eacb | ||
|
|
487e567d4c | ||
|
|
872d46132e | ||
|
|
c65b79f4b8 | ||
|
|
09bbeda108 | ||
|
|
afc95ad990 | ||
|
|
a545f88b3c | ||
|
|
084b950fec | ||
|
|
2728896a1e | ||
|
|
b4afba99bd | ||
|
|
df1e72e871 | ||
|
|
7e46428721 | ||
|
|
14a4d9dc9c | ||
|
|
3ac798ac5e | ||
|
|
e3ee714732 | ||
|
|
41d5f579f7 | ||
|
|
102abf7df9 | ||
|
|
47b9633a86 | ||
|
|
e2902adba8 | ||
|
|
cd993e3cf4 | ||
|
|
4097fd74dd | ||
|
|
37e645ed9c | ||
|
|
fa254499bc | ||
|
|
be8b20da8d | ||
|
|
cef44692a8 | ||
|
|
a627d63107 |
@@ -141,31 +141,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginAppCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"vue/no-v-text-v-html-on-component": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/cards/PluginMixedSortCard.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -252,7 +227,7 @@
|
||||
},
|
||||
"src/components/dialog/ForkWorkflowDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 4
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/dialog/MediaInfoDialog.vue": {
|
||||
@@ -270,17 +245,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/PluginMarketDetailDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-ignored-exceptions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dialog/PluginMarketSettingDialog.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.15.3",
|
||||
"version": "2.15.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
+46
-2
@@ -154,6 +154,7 @@ const globalSettingsStore = useGlobalSettingsStore()
|
||||
const backgroundImages = ref<string[]>([])
|
||||
const backgroundLayers = ref(createLoginBackgroundLayers())
|
||||
const backgroundDisplayImages = ref<Record<string, string>>({})
|
||||
const backgroundCorsReady = ref<Record<string, boolean>>({})
|
||||
const backgroundToneProfiles = ref<Record<string, GlassWallpaperToneProfile>>({})
|
||||
const activeImageIndex = ref(0)
|
||||
const previousImageIndex = ref<number | null>(null)
|
||||
@@ -302,13 +303,25 @@ function getBackgroundLayerStyle(layer: LoginBackgroundLayer) {
|
||||
const appearance = effectiveGlassSettings.value.glassAppearance
|
||||
const materialExposure = appearance === 'frosted' ? 0.82 : appearance === 'tinted' ? 0.85 : 0.86
|
||||
const displayUrl = isGlassTheme.value ? getPreparedBackgroundImage(layer.url) : layer.url
|
||||
const usesCorsImageElement = isGlassTheme.value && Object.hasOwn(backgroundDisplayImages.value, layer.url)
|
||||
|
||||
return {
|
||||
'backgroundImage': displayUrl ? `url(${displayUrl})` : undefined,
|
||||
'backgroundImage': !usesCorsImageElement && displayUrl ? `url(${displayUrl})` : undefined,
|
||||
'--glass-wallpaper-brightness': String(materialExposure * profile.exposure),
|
||||
}
|
||||
}
|
||||
|
||||
/** 玻璃可见层与 tone/WebGL 使用同一图片请求模式,避免 CSS 再创建无 Origin 的缓存变体。 */
|
||||
function getBackgroundLayerImageSource(layer: LoginBackgroundLayer) {
|
||||
if (!isGlassTheme.value || !Object.hasOwn(backgroundDisplayImages.value, layer.url)) return ''
|
||||
|
||||
return getPreparedBackgroundImage(layer.url)
|
||||
}
|
||||
|
||||
function getBackgroundLayerCrossOrigin(layer: LoginBackgroundLayer) {
|
||||
return backgroundCorsReady.value[layer.url] ? 'anonymous' : undefined
|
||||
}
|
||||
|
||||
const needsStableFixedBackdrop = isChromiumFixedShellBackplateBrowser()
|
||||
const fixedShellBackplateLayers = computed<readonly GlassFixedShellBackplateLayer[]>(() => {
|
||||
const hasWallpaper = renderedBackgroundLayers.value.some(layer => Boolean(layer.url))
|
||||
@@ -327,6 +340,8 @@ const fixedShellBackplateLayers = computed<readonly GlassFixedShellBackplateLaye
|
||||
|
||||
return renderedBackgroundLayers.value.map(layer => ({
|
||||
...layer,
|
||||
crossOrigin: getBackgroundLayerCrossOrigin(layer),
|
||||
src: getBackgroundLayerImageSource(layer),
|
||||
style: getBackgroundLayerStyle(layer),
|
||||
}))
|
||||
})
|
||||
@@ -711,6 +726,10 @@ async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
...backgroundDisplayImages.value,
|
||||
[imageUrl]: opticalUrl,
|
||||
}
|
||||
backgroundCorsReady.value = {
|
||||
...backgroundCorsReady.value,
|
||||
[imageUrl]: true,
|
||||
}
|
||||
recordGlassLaunchTiming('wallpaper-source-ready', imageUrl)
|
||||
return true
|
||||
}
|
||||
@@ -719,6 +738,10 @@ async function preloadBackgroundCandidate(imageUrl: string) {
|
||||
...backgroundDisplayImages.value,
|
||||
[imageUrl]: imageUrl,
|
||||
}
|
||||
backgroundCorsReady.value = {
|
||||
...backgroundCorsReady.value,
|
||||
[imageUrl]: false,
|
||||
}
|
||||
|
||||
const ready = await preloadImage(imageUrl)
|
||||
recordGlassLaunchTiming(ready ? 'wallpaper-source-ready' : 'wallpaper-source-failed', imageUrl)
|
||||
@@ -1137,7 +1160,17 @@ onUnmounted(() => {
|
||||
class="background-image"
|
||||
:class="layer.role"
|
||||
:style="getBackgroundLayerStyle(layer)"
|
||||
/>
|
||||
>
|
||||
<img
|
||||
v-if="getBackgroundLayerImageSource(layer)"
|
||||
class="background-image__source"
|
||||
:crossorigin="getBackgroundLayerCrossOrigin(layer)"
|
||||
:src="getBackgroundLayerImageSource(layer)"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<!-- 全局磨砂层 -->
|
||||
<div v-if="shouldRenderGlobalBlurLayer" class="global-blur-layer"></div>
|
||||
</div>
|
||||
@@ -1150,6 +1183,7 @@ onUnmounted(() => {
|
||||
v-if="shouldRenderGlassOpticalLayer"
|
||||
:appearance="effectiveGlassSettings.glassAppearance"
|
||||
:deformation-strength="opticalDeformationStrength"
|
||||
:dynamics-mode="effectiveGlassSettings.glassDynamicsMode"
|
||||
:flow-strength="opticalFlowStrength"
|
||||
:quality="opticalQuality === 'high' ? 'high' : 'balanced'"
|
||||
:reflection-strength="opticalReflectionStrength"
|
||||
@@ -1237,6 +1271,16 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.background-image__source {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.background-container.is-transparent-theme .background-image.active {
|
||||
opacity: var(--transparent-background-poster-opacity, 1);
|
||||
}
|
||||
|
||||
+3
-1
@@ -300,8 +300,10 @@ export interface DownloadHistory {
|
||||
seasons?: string
|
||||
// 集 Exx
|
||||
episodes?: string
|
||||
// 海报或背景图
|
||||
// 背景图
|
||||
image?: string
|
||||
// 海报
|
||||
poster?: string
|
||||
// 下载器 Hash
|
||||
download_hash?: string
|
||||
// 种子名称
|
||||
|
||||
@@ -2,32 +2,102 @@
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, DownloadingInfo } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 输入参数
|
||||
/** 卡片使用的下载任务信息,兼容接口已经返回但公共类型尚未声明的来源站点。 */
|
||||
interface DownloadingCardInfo extends DownloadingInfo {
|
||||
site_name?: string
|
||||
trackers?: string[]
|
||||
}
|
||||
|
||||
/** 正在下载任务卡片,负责展示任务状态并提供暂停、继续和删除操作。 */
|
||||
const props = defineProps({
|
||||
info: Object as PropType<DownloadingInfo>,
|
||||
info: Object as PropType<DownloadingCardInfo>,
|
||||
downloaderName: String,
|
||||
})
|
||||
|
||||
// 是否显示卡片
|
||||
const { t } = useI18n()
|
||||
|
||||
// 卡片在删除成功后就地隐藏,等待外层轮询同步任务列表。
|
||||
const cardState = ref(true)
|
||||
const pendingAction = ref<'delete' | 'toggle' | null>(null)
|
||||
const imageLoadError = ref(false)
|
||||
const media = computed(() => props.info?.media ?? {})
|
||||
|
||||
// 进度条
|
||||
function getPercentage() {
|
||||
return props.info?.progress ?? 0
|
||||
watch(
|
||||
() => media.value.poster,
|
||||
() => {
|
||||
imageLoadError.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const hasPosterImage = computed(() => Boolean(media.value.poster && !imageLoadError.value))
|
||||
|
||||
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
||||
|
||||
const episodeText = computed(() => {
|
||||
const recognizedEpisode = [media.value.season, media.value.episode].filter(Boolean).join(' ')
|
||||
return recognizedEpisode || props.info?.season_episode || ''
|
||||
})
|
||||
|
||||
const titleMetaText = computed(() => [props.info?.year?.trim(), episodeText.value].filter(Boolean).join(' · '))
|
||||
|
||||
const mediaTypeText = computed(() => {
|
||||
const type = String(media.value.type || '').trim()
|
||||
if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie')
|
||||
if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv')
|
||||
if (type) return type
|
||||
if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv')
|
||||
return media.value.title ? t('mediaType.movie') : ''
|
||||
})
|
||||
|
||||
const mediaTypeIcon = computed(() => {
|
||||
const type = String(media.value.type || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (type === '电影' || type === 'movie') return 'mdi-movie-outline'
|
||||
if (type === '电视剧' || type === 'tv') return 'mdi-television-classic'
|
||||
return 'mdi-play-box-outline'
|
||||
})
|
||||
|
||||
const progressValue = computed(() => {
|
||||
const progress = Number(props.info?.progress ?? 0)
|
||||
if (!Number.isFinite(progress)) return 0
|
||||
return Math.min(Math.max(progress, 0), 100)
|
||||
})
|
||||
|
||||
const progressText = computed(() => `${Math.round(progressValue.value)}%`)
|
||||
const sizeText = computed(() => formatFileSize(props.info?.size || 0))
|
||||
const remainingTimeText = computed(() => props.info?.left_time?.trim() || '--')
|
||||
|
||||
/** 从 Tracker 地址中仅提取可展示的主机名,避免暴露路径、查询参数或 passkey。 */
|
||||
function getTrackerHostname(tracker?: string) {
|
||||
if (!tracker) return ''
|
||||
try {
|
||||
return new URL(tracker).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 速度
|
||||
function getSpeedText() {
|
||||
return `${formatFileSize(props.info?.size || 0)} ↑ ${props.info?.upspeed}/s ↓ ${props.info?.dlspeed}/s ${
|
||||
props.info?.left_time
|
||||
}`
|
||||
const sourceSiteText = computed(() => {
|
||||
const siteName = String(props.info?.site_name || media.value.site_name || '').trim()
|
||||
if (siteName) return siteName
|
||||
return props.info?.trackers?.map(getTrackerHostname).find(Boolean) || ''
|
||||
})
|
||||
|
||||
/** 为下载器返回的速率补齐单位,并兼容已经包含每秒单位的值。 */
|
||||
function formatSpeed(speed?: string) {
|
||||
const value = speed?.trim() || '0 B'
|
||||
return /\/s$/i.test(value) ? value : `${value}/s`
|
||||
}
|
||||
|
||||
// 下载状态
|
||||
const downloadSpeedText = computed(() => formatSpeed(props.info?.dlspeed))
|
||||
const uploadSpeedText = computed(() => formatSpeed(props.info?.upspeed))
|
||||
|
||||
// 下载状态跟随轮询数据变化,操作成功时也会立即响应。
|
||||
const isDownloading = ref(props.info?.state === 'downloading')
|
||||
|
||||
// 监听props.info?.state的变化
|
||||
watch(
|
||||
() => props.info?.state,
|
||||
newValue => {
|
||||
@@ -35,9 +105,12 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
// 下载状态控制
|
||||
/** 暂停或继续当前任务,并防止请求完成前重复触发。 */
|
||||
async function toggleDownload() {
|
||||
if (pendingAction.value) return
|
||||
|
||||
const operation = isDownloading.value ? 'stop' : 'start'
|
||||
pendingAction.value = 'toggle'
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.get(`download/${operation}/${props.info?.hash}`, {
|
||||
params: {
|
||||
@@ -48,11 +121,16 @@ async function toggleDownload() {
|
||||
if (result.success) isDownloading.value = !isDownloading.value
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 删除下载任务
|
||||
/** 删除当前下载任务,并仅在业务请求成功后隐藏卡片。 */
|
||||
async function deleteDownload() {
|
||||
if (pendingAction.value) return
|
||||
|
||||
pendingAction.value = 'delete'
|
||||
try {
|
||||
const result: ApiResponse<unknown> = await api.delete(`download/${props.info?.hash}`, {
|
||||
params: { name: props.downloaderName },
|
||||
@@ -60,6 +138,8 @@ async function deleteDownload() {
|
||||
if (result.success) cardState.value = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
pendingAction.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -69,55 +149,124 @@ async function deleteDownload() {
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface flex flex-col h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
<div
|
||||
class="downloading-card-shell app-hover-lift-card h-full"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<template #image>
|
||||
<VImg :src="props.info?.media.image" class="downloading-card-image" aspect-ratio="2/3" cover position="top">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card h-full overflow-hidden"
|
||||
:class="{ 'downloading-card--no-image': !hasPosterImage }"
|
||||
>
|
||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||
<VImg
|
||||
:src="media.poster"
|
||||
class="downloading-card__image"
|
||||
cover
|
||||
position="center"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div class="downloading-card__poster-edge" />
|
||||
</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>
|
||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="absolute inset-0 outline-none downloading-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
||||
{{ props.info?.title || t('common.unknown') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<VCardTitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.media.title || props.info?.name }}
|
||||
{{
|
||||
props.info?.media.episode
|
||||
? `${props.info?.media.season} ${props.info?.media.episode}`
|
||||
: props.info?.season_episode
|
||||
}}
|
||||
</VCardTitle>
|
||||
<div v-if="progressValue > 0" class="downloading-card__progress">
|
||||
<div class="downloading-card__progress-label">
|
||||
<span>
|
||||
{{ isDownloading ? t('common.download') : t('common.pause') }}
|
||||
<span class="downloading-card__progress-separator">·</span>
|
||||
{{ remainingTimeText }}
|
||||
</span>
|
||||
<strong>{{ progressText }}</strong>
|
||||
</div>
|
||||
<VProgressLinear
|
||||
:aria-label="t('common.download')"
|
||||
:model-value="progressValue"
|
||||
:color="isDownloading ? 'success' : 'warning'"
|
||||
bg-color="surface-variant"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VCardSubtitle class="break-words whitespace-normal text-white">
|
||||
{{ props.info?.title }}
|
||||
</VCardSubtitle>
|
||||
<div class="downloading-card__footer">
|
||||
<div class="downloading-card__speeds">
|
||||
<div class="downloading-card__speed downloading-card__speed--download">
|
||||
<VIcon icon="mdi-arrow-down" size="16" />
|
||||
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
||||
</div>
|
||||
<div class="downloading-card__speed downloading-card__speed--upload">
|
||||
<VIcon icon="mdi-arrow-up" size="16" />
|
||||
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-subtitle-1 pt-3 pb-1 text-white">
|
||||
{{ getSpeedText() }}
|
||||
<VCardActions class="downloading-card__actions pa-0">
|
||||
<VBtn
|
||||
:aria-label="isDownloading ? t('common.pause') : t('common.download')"
|
||||
:disabled="pendingAction === 'delete'"
|
||||
icon
|
||||
:loading="pendingAction === 'toggle'"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="toggleDownload"
|
||||
>
|
||||
<VIcon :icon="isDownloading ? 'mdi-pause' : 'mdi-play'" />
|
||||
<VTooltip activator="parent" location="top">
|
||||
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
||||
</VTooltip>
|
||||
</VBtn>
|
||||
<VBtn
|
||||
:aria-label="t('common.delete')"
|
||||
:disabled="pendingAction === 'toggle'"
|
||||
:loading="pendingAction === 'delete'"
|
||||
color="error"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="deleteDownload"
|
||||
>
|
||||
<VIcon icon="mdi-trash-can-outline" />
|
||||
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCardText>
|
||||
|
||||
<VCardText v-if="getPercentage() > 0" class="text-white">
|
||||
<VProgressLinear :model-value="getPercentage()" bg-color="success" color="success" />
|
||||
</VCardText>
|
||||
|
||||
<VCardActions class="justify-space-between">
|
||||
<VBtn :icon="`${isDownloading ? 'mdi-pause' : 'mdi-play'}`" @click="toggleDownload" />
|
||||
<VBtn color="error" icon="mdi-trash-can-outline" @click="deleteDownload" />
|
||||
</VCardActions>
|
||||
</div>
|
||||
</VCard>
|
||||
</VCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
@@ -127,16 +276,245 @@ async function deleteDownload() {
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.downloading-card-hover-area {
|
||||
block-size: 100%;
|
||||
container-type: inline-size;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-image {
|
||||
block-size: 100%;
|
||||
.downloading-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.downloading-card-background {
|
||||
border-radius: inherit;
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
.downloading-card {
|
||||
display: grid;
|
||||
min-block-size: 12rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
grid-template-columns: 8rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__poster {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-block-size: 12rem;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.downloading-card__image,
|
||||
.downloading-card__image-loader {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card__poster-edge {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-surface), 0) 72%, rgba(var(--v-theme-surface), 0.38));
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.downloading-card__body {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.875rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__chips {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip) {
|
||||
max-inline-size: calc(50% - 0.2rem);
|
||||
}
|
||||
|
||||
.downloading-card__chips :deep(.v-chip__content) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__heading {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.downloading-card__title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.downloading-card__title-meta {
|
||||
margin-inline-start: 0.35rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__torrent-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-block-start: 0.25rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.downloading-card__progress {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.downloading-card__progress-label {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-end: 0.4rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.7rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__progress-label > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__progress-label strong {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.downloading-card__progress-separator {
|
||||
padding-inline: 0.12rem;
|
||||
}
|
||||
|
||||
.downloading-card__footer {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__speeds {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: 0.8rem;
|
||||
row-gap: 0.15rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed strong {
|
||||
overflow: hidden;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloading-card__speed--download .v-icon {
|
||||
color: rgb(var(--v-theme-info));
|
||||
}
|
||||
|
||||
.downloading-card__speed--upload .v-icon {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.downloading-card__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
@container (width <= 25rem) {
|
||||
.downloading-card {
|
||||
min-block-size: 11rem;
|
||||
grid-template-columns: 7.333rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__poster {
|
||||
min-block-size: 11rem;
|
||||
}
|
||||
|
||||
.downloading-card__body {
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem !important;
|
||||
}
|
||||
|
||||
.downloading-card__chips {
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.downloading-card__title {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.downloading-card__torrent-title {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.downloading-card__speeds {
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloading-card__speed strong {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
}
|
||||
|
||||
@container (width <= 21rem) {
|
||||
.downloading-card__body {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.downloading-card.downloading-card--no-image {
|
||||
min-block-size: 0;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,8 +82,7 @@ function openTmdbPage(type: string, tmdbId: number) {
|
||||
<VChip
|
||||
v-if="context?.media_info?.tmdb_id"
|
||||
variant="elevated"
|
||||
color="success"
|
||||
class="me-1 mb-1"
|
||||
class="me-1 mb-1 text-white bg-green-500"
|
||||
@click="openTmdbPage(context?.media_info?.type || '', context?.media_info?.tmdb_id)"
|
||||
>
|
||||
{{ context?.media_info?.tmdb_id }}
|
||||
|
||||
@@ -103,6 +103,7 @@ async function goPlay() {
|
||||
@keyup.enter="goPlay"
|
||||
@keyup.space="goPlay"
|
||||
>
|
||||
<!-- 媒体服务器图片统一采用匿名 CORS,避免同一代理资源分裂为两种请求与缓存模式;Safari 普通 dev 可能重复请求,PWA 下由图片缓存路由统一处理。 -->
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
crossorigin="anonymous"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -39,7 +39,7 @@ const createConfirm = useConfirm()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 获取当前插件的标签
|
||||
const pluginLabels = computed(() => {
|
||||
@@ -51,9 +51,6 @@ const pluginLabels = computed(() => {
|
||||
.filter(tag => tag.length > 0)
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -74,7 +71,6 @@ function closeInstallProgress() {
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -99,20 +95,16 @@ function visitPluginPage() {
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!repoUrl) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
@@ -159,7 +151,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -167,8 +159,6 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
@@ -178,8 +168,15 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
$toast.error(
|
||||
t('plugin.installFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closeInstallProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +320,7 @@ onUnmounted(() => {
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
|
||||
// 插件日志面板只有点击“查看日志”时才需要,延后加载可减轻插件列表首屏。
|
||||
const PluginConfigDialog = defineAsyncComponent(() => import('../dialog/PluginConfigDialog.vue'))
|
||||
@@ -33,11 +34,21 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['remove', 'save', 'actionDone'])
|
||||
const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0)
|
||||
const hasCardStatus = computed(() => Boolean(props.plugin?.has_update) || hasCardRating.value)
|
||||
const cardRatingValue = computed(() => Number(props.plugin?.average_rating || 0).toFixed(1))
|
||||
const cardRatingSummary = computed(() =>
|
||||
t('plugin.ratingSummary', {
|
||||
rating: cardRatingValue.value,
|
||||
count: props.plugin?.rating_count || 0,
|
||||
}),
|
||||
)
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
@@ -45,11 +56,13 @@ const display = useDisplay()
|
||||
const accentRgb = ref('40, 169, 225')
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 确认框
|
||||
const createConfirm = useConfirm()
|
||||
|
||||
@@ -62,9 +75,6 @@ const menuVisible = ref(false)
|
||||
// 用户头像是否加载完成
|
||||
const isAvatarLoaded = ref(false)
|
||||
|
||||
// 图片是否加载完成
|
||||
const isImageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -98,7 +108,6 @@ watch(
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
||||
@@ -124,17 +133,15 @@ async function uninstallPlugin() {
|
||||
|
||||
if (!isConfirmed) return
|
||||
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(t('plugin.uninstalling', { name: props.plugin?.plugin_name }))
|
||||
const result: { [key: string]: any } = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
const result: ApiResponse<unknown> = await api.delete(`plugin/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.uninstallSuccess', { name: props.plugin?.plugin_name }))
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
@@ -144,8 +151,15 @@ async function uninstallPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.uninstallFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +218,12 @@ async function resetPlugin() {
|
||||
if (!isConfirmed) return
|
||||
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/reset/${props.plugin?.id}`)
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.resetSuccess', { name: props.plugin?.plugin_name }))
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
@@ -218,6 +233,12 @@ async function resetPlugin() {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(
|
||||
t('plugin.resetFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -243,14 +264,13 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 显示等待提示框
|
||||
showPluginProgress(
|
||||
releaseVersion
|
||||
? t('plugin.installing', { name: props.plugin?.plugin_name, version: releaseVersion })
|
||||
: t('plugin.updating', { name: props.plugin?.plugin_name }),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
@@ -258,16 +278,14 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
},
|
||||
})
|
||||
|
||||
// 隐藏等待提示框
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.updateSuccess', { name: props.plugin?.plugin_name }))
|
||||
versionHistoryDialogController?.close()
|
||||
versionHistoryDialogController = null
|
||||
|
||||
// 通知父组件刷新
|
||||
emit('save')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
@@ -277,8 +295,15 @@ async function updatePlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(
|
||||
t('plugin.updateFailed', {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +387,12 @@ async function showPluginAbout() {
|
||||
count: props.count,
|
||||
},
|
||||
{
|
||||
install: () => emit('save'),
|
||||
install: () => {
|
||||
emit('save')
|
||||
// 详情弹窗的安装事件只刷新父列表,动态导航由卡片补充同步。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
},
|
||||
rating: (pluginRating: PluginRating) => emit('rating', pluginRating),
|
||||
},
|
||||
{ closeOn: ['close', 'install', 'update:modelValue'] },
|
||||
)
|
||||
@@ -445,7 +475,7 @@ async function executePluginClone(cloneForm: {
|
||||
try {
|
||||
showPluginProgress(t('plugin.cloning', { name: props.plugin?.plugin_name }))
|
||||
|
||||
const result: { [key: string]: any } = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.post(`plugin/clone/${props.plugin?.id}`, {
|
||||
suffix: cloneForm.suffix.trim(),
|
||||
name: cloneForm.name.trim(),
|
||||
description: cloneForm.description.trim(),
|
||||
@@ -453,21 +483,21 @@ async function executePluginClone(cloneForm: {
|
||||
icon: cloneForm.icon.trim(),
|
||||
})
|
||||
|
||||
closePluginProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.cloneSuccess', { name: cloneForm.name }))
|
||||
cloneDialogController?.close()
|
||||
cloneDialogController = null
|
||||
// 通知父组件刷新
|
||||
emit('remove')
|
||||
// 生命周期成功后刷新动态导航。
|
||||
void pluginSidebarNavStore.ensureSidebarNav(true)
|
||||
} else {
|
||||
$toast.error(t('plugin.cloneFailed', { message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closePluginProgress()
|
||||
$toast.error(t('plugin.cloneFailedGeneral'))
|
||||
console.error(error)
|
||||
} finally {
|
||||
closePluginProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +611,7 @@ const dropdownItems = ref([
|
||||
// 监听插件状态变化
|
||||
watch(
|
||||
() => props.plugin?.has_update,
|
||||
(newHasUpdate, _) => {
|
||||
newHasUpdate => {
|
||||
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3)
|
||||
if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate
|
||||
|
||||
@@ -593,7 +623,7 @@ watch(
|
||||
// 监听插件窗口状态变化
|
||||
watch(
|
||||
() => props.plugin?.page_open,
|
||||
(newOpenState, _) => {
|
||||
newOpenState => {
|
||||
if (newOpenState) openPluginDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -623,6 +653,7 @@ watch(
|
||||
<VCardText class="px-2 pt-2 pb-0">
|
||||
<VCardTitle
|
||||
class="text-white px-2 pb-0 text-lg text-shadow whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
:class="{ 'plugin-card__title--with-status': hasCardStatus }"
|
||||
>
|
||||
<VBadge dot inline :color="props.plugin?.state ? 'success' : 'secondary'" />
|
||||
{{ props.plugin?.plugin_name }}
|
||||
@@ -695,15 +726,29 @@ watch(
|
||||
<template #prepend>
|
||||
<VIcon :icon="item.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="item.title" />
|
||||
<VListItemTitle>{{ item.title }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<div v-if="props.plugin?.has_update" class="me-n3 absolute top-0 right-5">
|
||||
<VIcon icon="mdi-new-box" class="text-white" />
|
||||
<div
|
||||
v-if="props.plugin?.has_update"
|
||||
class="plugin-card__status plugin-card__status--update"
|
||||
:aria-label="t('plugin.hasUpdate')"
|
||||
:title="t('plugin.hasUpdate')"
|
||||
>
|
||||
<VIcon icon="mdi-new-box" class="text-white" size="20" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasCardRating"
|
||||
class="plugin-card__status plugin-card__status--rating"
|
||||
:aria-label="cardRatingSummary"
|
||||
:title="cardRatingSummary"
|
||||
>
|
||||
<VIcon icon="mdi-star" color="warning" size="16" />
|
||||
<span>{{ cardRatingValue }}</span>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
@@ -717,6 +762,28 @@ watch(
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-card__title--with-status {
|
||||
padding-inline-end: 4rem !important;
|
||||
}
|
||||
|
||||
.plugin-card__status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: white;
|
||||
inset-block-start: 0.625rem;
|
||||
inset-inline-end: 0.625rem;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 65%);
|
||||
}
|
||||
|
||||
.plugin-card__status--rating {
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-cover-blurred::before {
|
||||
position: absolute;
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PluginRating } from '@/api/types'
|
||||
import PluginCard from './PluginCard.vue'
|
||||
import PluginFolderCard from './PluginFolderCard.vue'
|
||||
|
||||
@@ -30,6 +31,7 @@ const emit = defineEmits<{
|
||||
renameFolder: [oldName: string, newName: string]
|
||||
updateFolderConfig: [folderName: string, config: any]
|
||||
refreshData: []
|
||||
rating: [pluginRating: PluginRating]
|
||||
actionDone: [pluginId: string]
|
||||
removeFromFolder: [pluginId: string]
|
||||
dropToFolder: [event: DragEvent, folderName: string]
|
||||
@@ -108,6 +110,7 @@ function handleDropToFolder(event: DragEvent) {
|
||||
:sortable="sortable"
|
||||
@remove="$emit('refreshData')"
|
||||
@save="$emit('refreshData')"
|
||||
@rating="$emit('rating', $event)"
|
||||
@action-done="$emit('actionDone', item.id)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ async function goPlay(isHovering: boolean | null = false) {
|
||||
'ring-1': isImageLoaded,
|
||||
}"
|
||||
>
|
||||
<!-- 媒体服务器图片统一采用匿名 CORS,避免同一代理资源分裂为两种请求与缓存模式;Safari 普通 dev 可能重复请求,PWA 下由图片缓存路由统一处理。 -->
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
|
||||
@@ -79,8 +79,11 @@ const downloadedEpisode = computed(() => {
|
||||
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
|
||||
})
|
||||
|
||||
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
|
||||
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
|
||||
// 是否开启洗版,供电影和电视剧共用洗版标识与配色。
|
||||
const hasBestVersion = computed(() => isEnabledFlag(props.media?.best_version))
|
||||
|
||||
// 是否为电视剧洗版订阅,仅影响分集进度条与 tooltip 的展示分支。
|
||||
const isBestVersion = computed(() => hasBestVersion.value && isTvSubscribe(props.media))
|
||||
|
||||
const rightBottomStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
@@ -100,12 +103,15 @@ const compactStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'P') {
|
||||
return { color: 'info', icon: 'mdi-timer-sand', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
if (hasBestVersion.value) {
|
||||
return { color: 'success', icon: 'mdi-shimmer', label: t('subscribe.subscribing') }
|
||||
}
|
||||
return { color: 'primary', icon: 'mdi-rss', label: t('subscribe.subscribing') }
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
if (!hasBestVersion.value) return null
|
||||
return {
|
||||
icon: 'mdi-shimmer',
|
||||
full: isEnabledFlag(props.media?.best_version_full),
|
||||
@@ -440,6 +446,7 @@ function handleCardClick() {
|
||||
:class="{
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
'subscribe-card-best-version-tint': display.xs.value && hasBestVersion && subscribeState === 'R',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
min-height="150"
|
||||
@@ -447,7 +454,7 @@ function handleCardClick() {
|
||||
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
v-if="bestVersionBadge && imageLoaded && display.smAndUp.value"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
@@ -485,17 +492,20 @@ function handleCardClick() {
|
||||
|
||||
<template v-if="display.xs.value">
|
||||
<div class="subscribe-card-mobile-media">
|
||||
<VImg :src="backdropUrl || posterUrl" :aspect-ratio="2" cover position="top" @load="imageLoadHandler">
|
||||
<VImg
|
||||
:src="backdropUrl || posterUrl"
|
||||
:aspect-ratio="16 / 9"
|
||||
cover
|
||||
position="top"
|
||||
@load="imageLoadHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-full w-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div class="subscribe-card-mobile-image-scrim subscribe-card-background"></div>
|
||||
|
||||
<div
|
||||
v-if="props.media?.username || lastUpdateText"
|
||||
class="subscribe-card-mobile-image-meta"
|
||||
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
|
||||
>
|
||||
<div v-if="props.media?.username || lastUpdateText" class="subscribe-card-mobile-image-meta">
|
||||
<div
|
||||
v-if="props.media?.username"
|
||||
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
|
||||
@@ -512,14 +522,21 @@ function handleCardClick() {
|
||||
<span>{{ lastUpdateText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-title">
|
||||
<div class="subscribe-card-mobile-title-text">
|
||||
<span>{{ props.media?.name }}</span>
|
||||
<span
|
||||
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
|
||||
class="subscribe-card-mobile-season"
|
||||
>
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-body">
|
||||
<div class="subscribe-card-mobile-title">
|
||||
{{ props.media?.name }}
|
||||
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
|
||||
</div>
|
||||
|
||||
<div class="subscribe-card-mobile-footer">
|
||||
<div class="subscribe-card-mobile-meta">
|
||||
<div
|
||||
@@ -528,14 +545,18 @@ function handleCardClick() {
|
||||
:title="compactStateDisplay.label"
|
||||
:aria-label="compactStateDisplay.label"
|
||||
>
|
||||
<VIcon :icon="compactStateDisplay.icon" size="18" />
|
||||
<VIcon
|
||||
:icon="compactStateDisplay.icon"
|
||||
:data-subscribe-state-icon="compactStateDisplay.icon"
|
||||
size="16"
|
||||
/>
|
||||
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
|
||||
{{ subscribeProgressText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconBtn v-if="!props.sortable" class="subscribe-card-mobile-menu" size="small" @click.stop>
|
||||
<VIcon icon="mdi-dots-horizontal" size="20" />
|
||||
<VIcon icon="mdi-dots-horizontal" size="18" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<template v-for="(item, i) in dropdownItems" :key="i">
|
||||
@@ -561,7 +582,7 @@ function handleCardClick() {
|
||||
:bg-color="compactStateDisplay.color"
|
||||
:color="compactStateDisplay.color"
|
||||
bg-opacity="0.18"
|
||||
height="4"
|
||||
height="3"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
@@ -705,7 +726,7 @@ function handleCardClick() {
|
||||
.subscribe-card-mobile-media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 1;
|
||||
aspect-ratio: 16 / 9;
|
||||
flex-shrink: 0;
|
||||
inline-size: 100%;
|
||||
}
|
||||
@@ -714,48 +735,39 @@ function handleCardClick() {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta {
|
||||
.subscribe-card-mobile-image-scrim {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item {
|
||||
.subscribe-card-mobile-image-meta {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
inset-block-start: 0.5rem;
|
||||
inset-inline: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
isolation: isolate;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.2;
|
||||
padding-block: 0.0625rem;
|
||||
padding-inline: 0.125rem;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__item::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(0, 0, 0, 0.36);
|
||||
content: '';
|
||||
filter: blur(3px);
|
||||
inset: -0.25rem -0.55rem;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 1rem);
|
||||
inset-block-start: 0.5rem;
|
||||
inset-inline-start: 0.5rem;
|
||||
transition: inset-block-start 0.2s ease;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta--with-badge .subscribe-card-mobile-image-meta__user {
|
||||
max-inline-size: calc(100% - 4.25rem);
|
||||
inset-block-start: 0.5rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-image-meta__user span {
|
||||
@@ -767,41 +779,58 @@ function handleCardClick() {
|
||||
|
||||
.subscribe-card-mobile-image-meta__updated {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
inset-block-end: 0.5rem;
|
||||
inset-inline-end: 0.5rem;
|
||||
margin-inline-start: auto;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.25rem 0.625rem 0.375rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
font-weight: 650;
|
||||
inset-block-end: 0;
|
||||
inset-inline: 0;
|
||||
line-height: 1.3;
|
||||
padding: 1rem 0.75rem 0.625rem;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-title-text {
|
||||
display: -webkit-box;
|
||||
max-block-size: 3.9em;
|
||||
overflow: hidden;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-season {
|
||||
margin-inline-start: 0.25rem;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.1875rem;
|
||||
margin-block-start: auto;
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-meta {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
min-block-size: 2rem;
|
||||
min-block-size: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
@@ -813,7 +842,7 @@ function handleCardClick() {
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
@@ -829,17 +858,17 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-menu {
|
||||
block-size: 2rem;
|
||||
min-block-size: 2rem;
|
||||
inline-size: 2rem;
|
||||
min-inline-size: 2rem;
|
||||
flex: 0 0 2rem;
|
||||
block-size: 1.75rem;
|
||||
min-block-size: 1.75rem;
|
||||
inline-size: 1.75rem;
|
||||
min-inline-size: 1.75rem;
|
||||
flex: 0 0 1.75rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
}
|
||||
|
||||
.subscribe-card-mobile-progress {
|
||||
display: flex;
|
||||
block-size: 4px;
|
||||
block-size: 3px;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
@@ -887,7 +916,7 @@ function handleCardClick() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版标识:卡片左上角 24x24 圆形徽标
|
||||
* 洗版标识:桌面端左上角使用 24x24 圆形徽标。
|
||||
* 分集:深色半透底 + 模糊
|
||||
* 全集:磨砂玻璃半透白底 + 大模糊
|
||||
*/
|
||||
@@ -918,24 +947,41 @@ function handleCardClick() {
|
||||
min-block-size: 0 !important;
|
||||
}
|
||||
|
||||
.subscribe-card-background.subscribe-card-mobile-image-scrim {
|
||||
background-image:
|
||||
linear-gradient(180deg, rgba(8, 12, 18, 0.28) 0%, rgba(8, 12, 18, 0) 44%),
|
||||
linear-gradient(0deg, rgba(8, 12, 18, 0.7) 0%, rgba(8, 12, 18, 0) 72%);
|
||||
}
|
||||
|
||||
.subscribe-card-paused {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subscribe-card-paused .subscribe-card-mobile-media {
|
||||
.subscribe-card-paused .subscribe-card-mobile-media .v-img {
|
||||
filter: saturate(0.65);
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.best-version-badge {
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
.subscribe-card-pending-tint::after {
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),
|
||||
inset 0 -4rem 5rem rgba(var(--v-theme-info), 0.08);
|
||||
}
|
||||
|
||||
.subscribe-card-best-version-tint {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subscribe-card-best-version-tint::after {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border-radius: inherit;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(var(--v-theme-success), 0.34),
|
||||
inset 0 -4rem 5rem rgba(var(--v-theme-success), 0.12);
|
||||
content: '';
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,43 +23,38 @@ const workflowId = ref<string>()
|
||||
// 分享时间
|
||||
const dateText = ref(props.workflow && props.workflow?.date ? formatDateDifference(props.workflow.date) : '')
|
||||
|
||||
// 随机渐变背景
|
||||
const gradientStyle = ref('')
|
||||
const gradientPalettes = [
|
||||
['74, 85, 104', '45, 55, 72'],
|
||||
['85, 60, 154', '183, 148, 244'],
|
||||
['44, 90, 160', '26, 54, 93'],
|
||||
['47, 133, 90', '34, 84, 61'],
|
||||
['197, 48, 48', '116, 42, 42'],
|
||||
['214, 158, 46', '151, 90, 22'],
|
||||
['128, 90, 213', '85, 60, 154'],
|
||||
['49, 130, 206', '44, 82, 130'],
|
||||
['56, 161, 105', '39, 103, 73'],
|
||||
['229, 62, 62', '197, 48, 48'],
|
||||
['221, 107, 32', '192, 86, 33'],
|
||||
['107, 70, 193', '85, 60, 154'],
|
||||
['43, 108, 176', '44, 82, 130'],
|
||||
['56, 161, 105', '47, 133, 90'],
|
||||
['213, 63, 140', '151, 38, 109'],
|
||||
] as const
|
||||
|
||||
// 生成随机渐变背景
|
||||
function generateRandomGradient() {
|
||||
const gradients = [
|
||||
'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
'linear-gradient(135deg, #553c9a 0%, #b794f4 100%)',
|
||||
'linear-gradient(135deg, #2c5aa0 0%, #1a365d 100%)',
|
||||
'linear-gradient(135deg, #2f855a 0%, #22543d 100%)',
|
||||
'linear-gradient(135deg, #c53030 0%, #742a2a 100%)',
|
||||
'linear-gradient(135deg, #d69e2e 0%, #975a16 100%)',
|
||||
'linear-gradient(135deg, #805ad5 0%, #553c9a 100%)',
|
||||
'linear-gradient(135deg, #3182ce 0%, #2c5282 100%)',
|
||||
'linear-gradient(135deg, #38a169 0%, #276749 100%)',
|
||||
'linear-gradient(135deg, #e53e3e 0%, #c53030 100%)',
|
||||
'linear-gradient(135deg, #dd6b20 0%, #c05621 100%)',
|
||||
'linear-gradient(135deg, #6b46c1 0%, #553c9a 100%)',
|
||||
'linear-gradient(135deg, #2b6cb0 0%, #2c5282 100%)',
|
||||
'linear-gradient(135deg, #38a169 0%, #2f855a 100%)',
|
||||
'linear-gradient(135deg, #d53f8c 0%, #97266d 100%)',
|
||||
]
|
||||
|
||||
// 基于工作流ID生成固定的随机数,确保同一工作流总是显示相同的渐变
|
||||
// 暴露渐变色通道,让材质主题能够保留色相并单独控制透光率。
|
||||
const gradientStyle = computed(() => {
|
||||
const seed = String(props.workflow?.id || Math.random())
|
||||
const hash = seed.split('').reduce((a, b) => {
|
||||
a = (a << 5) - a + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
const [startRgb, endRgb] = gradientPalettes[Math.abs(hash) % gradientPalettes.length]
|
||||
|
||||
const index = Math.abs(hash) % gradients.length
|
||||
return gradients[index]
|
||||
}
|
||||
|
||||
// 初始化渐变背景
|
||||
onMounted(() => {
|
||||
gradientStyle.value = generateRandomGradient()
|
||||
return {
|
||||
'--workflow-share-gradient-start-rgb': startRgb,
|
||||
'--workflow-share-gradient-end-rgb': endRgb,
|
||||
backgroundImage: `linear-gradient(135deg, rgb(${startRgb}) 0%, rgb(${endRgb}) 100%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// 复用工作流
|
||||
@@ -104,7 +99,7 @@ function doDelete() {
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
}"
|
||||
min-height="150"
|
||||
:style="{ background: gradientStyle }"
|
||||
:style="gradientStyle"
|
||||
@click="showForkWorkflow"
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
|
||||
const WorkflowActionsDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowActionsDialog.vue'))
|
||||
const WorkflowAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/WorkflowAddEditDialog.vue'))
|
||||
@@ -177,45 +178,175 @@ async function handleReset(item: Workflow) {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算状态颜色
|
||||
const resolveStatusVariant = (status: string | undefined) => {
|
||||
if (status === 'S')
|
||||
return {
|
||||
color: 'success',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(76, 175, 80, 0.9), rgba(76, 175, 80, 0.7))',
|
||||
text: t('workflow.task.status.success'),
|
||||
}
|
||||
else if (status === 'R')
|
||||
return {
|
||||
color: 'primary',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
|
||||
text: t('workflow.task.status.running'),
|
||||
}
|
||||
else if (status === 'F')
|
||||
return {
|
||||
color: 'error',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(244, 67, 54, 0.9), rgba(244, 67, 54, 0.7))',
|
||||
text: t('workflow.task.status.failed'),
|
||||
}
|
||||
else if (status === 'P')
|
||||
return {
|
||||
color: 'warning',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(255, 152, 0, 0.9), rgba(255, 152, 0, 0.7))',
|
||||
text: t('workflow.task.status.paused'),
|
||||
}
|
||||
else
|
||||
return {
|
||||
color: 'info',
|
||||
bgColor: 'linear-gradient(to bottom right, rgba(33, 150, 243, 0.9), rgba(33, 150, 243, 0.7))',
|
||||
text: t('workflow.task.status.waiting'),
|
||||
}
|
||||
type WorkflowStatusColor = 'error' | 'info' | 'primary' | 'secondary' | 'success' | 'warning'
|
||||
type WorkflowActionSegmentState = 'active' | 'complete' | 'failed' | 'pending'
|
||||
|
||||
interface WorkflowActionDisplay {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 计算当前动作占比
|
||||
const resolveProgress = (item: Workflow) => {
|
||||
const current_action_length = item.current_action?.split(',').length || 0
|
||||
return item.actions?.length ? Math.round((current_action_length / (item.actions.length || 1)) * 100) : 0
|
||||
interface WorkflowExecutionNode {
|
||||
state?: string
|
||||
}
|
||||
|
||||
const resolveStatusVariant = (status: string | undefined) => {
|
||||
const variants: Record<
|
||||
string,
|
||||
{
|
||||
color: WorkflowStatusColor
|
||||
icon: string
|
||||
text: string
|
||||
}
|
||||
> = {
|
||||
S: { color: 'success', icon: 'mdi-check-circle-outline', text: t('workflow.task.status.success') },
|
||||
R: { color: 'primary', icon: 'mdi-progress-clock', text: t('workflow.task.status.running') },
|
||||
F: { color: 'error', icon: 'mdi-alert-circle-outline', text: t('workflow.task.status.failed') },
|
||||
P: { color: 'secondary', icon: 'mdi-pause-circle-outline', text: t('workflow.task.status.paused') },
|
||||
W: { color: 'warning', icon: 'mdi-clock-outline', text: t('workflow.task.status.waiting') },
|
||||
}
|
||||
|
||||
return variants[status || 'W'] || variants.W
|
||||
}
|
||||
|
||||
const statusVariant = computed(() => resolveStatusVariant(props.workflow.state))
|
||||
|
||||
const triggerDisplay = computed(() => {
|
||||
if (props.workflow.trigger_type === 'event') {
|
||||
return {
|
||||
icon: 'mdi-calendar-check-outline',
|
||||
text: getEventTypeText(props.workflow.event_type || ''),
|
||||
}
|
||||
}
|
||||
|
||||
if (props.workflow.trigger_type === 'manual') {
|
||||
return {
|
||||
icon: 'mdi-hand-pointing-up',
|
||||
text: t('workflow.task.info.manualTrigger'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'mdi-clock-outline',
|
||||
text: props.workflow.timer || t('workflow.task.info.timer'),
|
||||
}
|
||||
})
|
||||
|
||||
const workflowActions = computed<WorkflowActionDisplay[]>(() =>
|
||||
Array.isArray(props.workflow.actions) ? props.workflow.actions : [],
|
||||
)
|
||||
|
||||
const totalActionCount = computed(() => workflowActions.value.length)
|
||||
|
||||
const currentActionIds = computed(() => {
|
||||
return new Set(
|
||||
(props.workflow.current_action || '')
|
||||
.split(',')
|
||||
.map(actionId => actionId.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
})
|
||||
|
||||
const executionNodes = computed<Record<string, WorkflowExecutionNode>>(() => {
|
||||
const nodes = props.workflow.execution_state?.nodes
|
||||
return nodes && typeof nodes === 'object' && !Array.isArray(nodes)
|
||||
? (nodes as Record<string, WorkflowExecutionNode>)
|
||||
: {}
|
||||
})
|
||||
|
||||
const finishedActionCount = computed(() => {
|
||||
const runtimeCount = Number(props.workflow.execution_state?.runtime?.finished_actions)
|
||||
const knownActionIds = new Set(workflowActions.value.map(action => String(action.id || '')).filter(Boolean))
|
||||
const fallbackCount = [...currentActionIds.value].filter(actionId => knownActionIds.has(actionId)).length
|
||||
const count = Number.isFinite(runtimeCount) && runtimeCount >= 0 ? Math.trunc(runtimeCount) : fallbackCount
|
||||
|
||||
return Math.min(Math.max(count, 0), totalActionCount.value)
|
||||
})
|
||||
|
||||
const actionSegments = computed<WorkflowActionSegmentState[]>(() => {
|
||||
return workflowActions.value.map((action, index) => {
|
||||
const actionId = action.id ? String(action.id) : ''
|
||||
const nodeState = actionId ? executionNodes.value[actionId]?.state : undefined
|
||||
|
||||
if (nodeState === 'failed') return 'failed'
|
||||
if (nodeState === 'running' || nodeState === 'queued') return 'active'
|
||||
if (nodeState === 'success' || nodeState === 'completed' || nodeState === 'skipped') return 'complete'
|
||||
if (actionId && currentActionIds.value.has(actionId)) return 'complete'
|
||||
if (index < finishedActionCount.value) return 'complete'
|
||||
if (props.workflow.state === 'R' && index === finishedActionCount.value) return 'active'
|
||||
|
||||
return 'pending'
|
||||
})
|
||||
})
|
||||
|
||||
const runningActionName = computed(() => {
|
||||
const runningAction = workflowActions.value.find(action => {
|
||||
if (!action.id) return false
|
||||
const nodeState = executionNodes.value[String(action.id)]?.state
|
||||
return nodeState === 'running' || nodeState === 'queued'
|
||||
})
|
||||
|
||||
return runningAction?.name || runningAction?.type || ''
|
||||
})
|
||||
|
||||
const executionStatus = computed(() => {
|
||||
if (props.workflow.state === 'R') {
|
||||
if (runningActionName.value) {
|
||||
return {
|
||||
color: 'primary' as WorkflowStatusColor,
|
||||
icon: 'mdi-pulse',
|
||||
text: t('workflow.task.info.executingAction', { name: runningActionName.value }),
|
||||
}
|
||||
}
|
||||
|
||||
if (totalActionCount.value > 0) {
|
||||
return {
|
||||
color: 'primary' as WorkflowStatusColor,
|
||||
icon: 'mdi-pulse',
|
||||
text: t('workflow.task.info.preparingAction', {
|
||||
current: Math.min(finishedActionCount.value + 1, totalActionCount.value),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'secondary' as WorkflowStatusColor,
|
||||
icon: 'mdi-vector-polyline-remove',
|
||||
text: t('workflow.task.info.noActions'),
|
||||
}
|
||||
}
|
||||
|
||||
if (props.workflow.state === 'F') {
|
||||
return {
|
||||
color: 'error' as WorkflowStatusColor,
|
||||
icon: 'mdi-alert-circle-outline',
|
||||
text: props.workflow.result || t('workflow.task.status.failed'),
|
||||
}
|
||||
}
|
||||
|
||||
if (props.workflow.last_time) {
|
||||
return {
|
||||
color: undefined,
|
||||
icon: 'mdi-history',
|
||||
text: t('workflow.task.info.lastExecuted', { time: formatDateDifference(props.workflow.last_time) }),
|
||||
}
|
||||
}
|
||||
|
||||
if (finishedActionCount.value > 0) {
|
||||
return {
|
||||
color: undefined,
|
||||
icon: 'mdi-history',
|
||||
text: t('workflow.task.info.executionIncomplete'),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
color: undefined,
|
||||
icon: 'mdi-history',
|
||||
text: t('workflow.task.info.neverExecuted'),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="h-full">
|
||||
@@ -223,151 +354,179 @@ const resolveProgress = (item: Workflow) => {
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="workflow-task-card-hover-area h-full">
|
||||
<VCard
|
||||
class="app-hover-lift-card mx-auto h-full"
|
||||
class="workflow-task-card app-hover-lift-card mx-auto h-full"
|
||||
@click="handleFlow(workflow)"
|
||||
:ripple="false"
|
||||
:loading="loading"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
:class="[
|
||||
`workflow-task-card--status-${statusVariant.color}`,
|
||||
{ 'app-hover-lift-card--hovering': hover.isHovering },
|
||||
]"
|
||||
>
|
||||
<VCardItem
|
||||
class="px-2 py-2"
|
||||
:style="{
|
||||
background: resolveStatusVariant(workflow?.state).bgColor,
|
||||
}"
|
||||
>
|
||||
<template #prepend>
|
||||
<VAvatar variant="text" size="small">
|
||||
<VIcon
|
||||
v-if="workflow?.state === 'P'"
|
||||
<VCardItem class="workflow-task-card__header">
|
||||
<template #prepend>
|
||||
<VAvatar
|
||||
:color="statusVariant.color"
|
||||
variant="tonal"
|
||||
rounded="md"
|
||||
size="32"
|
||||
class="workflow-task-card__trigger-icon"
|
||||
>
|
||||
<VIcon :icon="triggerDisplay.icon" :data-workflow-trigger-icon="triggerDisplay.icon" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<VCardTitle class="workflow-task-card__title text-body-1" :title="workflow.description || workflow.name">
|
||||
{{ workflow.name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle class="workflow-task-card__trigger-text">
|
||||
{{ triggerDisplay.text }}
|
||||
</VCardSubtitle>
|
||||
|
||||
<template #append>
|
||||
<IconBtn
|
||||
class="workflow-task-card__menu"
|
||||
size="small"
|
||||
density="compact"
|
||||
:aria-label="t('workflow.task.moreActions')"
|
||||
@click.stop
|
||||
>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VTooltip activator="parent" location="top">{{ t('workflow.task.moreActions') }}</VTooltip>
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem base-color="primary" @click="handleEdit(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-note-edit" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="success" @click="handleFlow(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-vector-polyline" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.editFlow') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-play-speed" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-replay" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-run" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="warning" @click="handleReset(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restore-alert" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="info" @click="handleShare(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-share" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.share') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="error" @click="handleDelete(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
|
||||
<VCardText class="workflow-task-card__body">
|
||||
<div class="workflow-task-card__status-row">
|
||||
<VChip :color="statusVariant.color" :prepend-icon="statusVariant.icon" size="small" variant="tonal">
|
||||
{{ statusVariant.text }}
|
||||
</VChip>
|
||||
|
||||
<VBtn
|
||||
v-if="workflow.state === 'P'"
|
||||
color="success"
|
||||
icon="mdi-play"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="compact"
|
||||
prepend-icon="mdi-play"
|
||||
:aria-label="t('common.enable')"
|
||||
@click.stop="handleEnable(workflow)"
|
||||
/>
|
||||
<VIcon v-else color="warning" icon="mdi-pause" @click.stop="handlePause(workflow)" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle class="text-white text-lg">
|
||||
<span :title="workflow?.description">{{ workflow?.name }}</span>
|
||||
</VCardTitle>
|
||||
<template #append>
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem base-color="primary" @click="handleEdit(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-note-edit" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.edit') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="success" @click="handleFlow(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-vector-polyline" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.editFlow') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, false)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-play-speed" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.continue') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="workflow.current_action" base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-replay" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.restart') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-else base-color="info" @click="handleRun(workflow, true)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-run" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.run') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="warning" @click="handleReset(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-restore-alert" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.reset') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="info" @click="handleShare(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-share" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.share') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem base-color="error" @click="handleDelete(workflow)">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-delete" />
|
||||
</template>
|
||||
<VListItemTitle>{{ t('workflow.task.delete') }}</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-3">
|
||||
<div class="d-flex flex-column gap-y-3">
|
||||
<div class="d-flex flex-wrap gap-x-3">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.trigger') }}</div>
|
||||
<h5>
|
||||
<span v-if="workflow?.trigger_type === 'timer' || !workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ workflow?.timer }}
|
||||
</span>
|
||||
<span v-else-if="workflow?.trigger_type === 'event'">
|
||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||
{{ getEventTypeText(workflow?.event_type || '') }}
|
||||
</span>
|
||||
<span v-else-if="workflow?.trigger_type === 'manual'">
|
||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||
{{ t('workflow.task.info.manualTrigger') }}
|
||||
</span>
|
||||
</h5>
|
||||
>
|
||||
{{ t('common.enable') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
:color="statusVariant.color"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="compact"
|
||||
prepend-icon="mdi-pause"
|
||||
:aria-label="t('common.pause')"
|
||||
@click.stop="handlePause(workflow)"
|
||||
>
|
||||
{{ t('common.pause') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="workflow-task-card__metrics">
|
||||
<div class="workflow-task-card__metric">
|
||||
<span>{{ t('workflow.task.info.actionCount') }}</span>
|
||||
<strong>{{ totalActionCount }}</strong>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.status') }}</div>
|
||||
<h5 :class="`text-${resolveStatusVariant(workflow?.state).color}`">
|
||||
{{ resolveStatusVariant(workflow?.state).text }}
|
||||
</h5>
|
||||
<div class="workflow-task-card__metric">
|
||||
<span>{{ t('workflow.task.info.runCount') }}</span>
|
||||
<strong>{{ workflow.run_count || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-3">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.actionCount') }}</div>
|
||||
<div>
|
||||
<VAvatar size="24" color="primary" variant="tonal">
|
||||
<span class="text-xs">{{ workflow?.actions?.length }}</span>
|
||||
</VAvatar>
|
||||
</div>
|
||||
|
||||
<div class="workflow-task-card__progress">
|
||||
<div class="workflow-task-card__progress-label">
|
||||
<span>{{ t('workflow.task.info.actionProgress') }}</span>
|
||||
<strong>{{ finishedActionCount }} / {{ totalActionCount }}</strong>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.runCount') }}</div>
|
||||
<h5>{{ workflow?.run_count }}</h5>
|
||||
<div
|
||||
class="workflow-task-card__action-track"
|
||||
role="progressbar"
|
||||
:aria-label="`${t('workflow.task.info.actionProgress')}: ${finishedActionCount} / ${totalActionCount}`"
|
||||
aria-valuemin="0"
|
||||
:aria-valuemax="Math.max(totalActionCount, 1)"
|
||||
:aria-valuenow="finishedActionCount"
|
||||
>
|
||||
<span
|
||||
v-for="(segment, index) in actionSegments"
|
||||
:key="workflowActions[index]?.id || index"
|
||||
class="workflow-task-card__action-segment"
|
||||
:class="`workflow-task-card__action-segment--${segment}`"
|
||||
/>
|
||||
<span
|
||||
v-if="totalActionCount === 0"
|
||||
class="workflow-task-card__action-segment workflow-task-card__action-segment--empty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-3">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.progress') }}</div>
|
||||
<div class="d-flex align-center gap-5">
|
||||
<div class="flex-grow-1">
|
||||
<VProgressLinear color="info" rounded :model-value="resolveProgress(workflow)" />
|
||||
</div>
|
||||
<span> {{ resolveProgress(workflow) }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="workflow-task-card__execution-status"
|
||||
:class="executionStatus.color ? `text-${executionStatus.color}` : 'text-medium-emphasis'"
|
||||
:title="executionStatus.text"
|
||||
>
|
||||
<VIcon :icon="executionStatus.icon" size="small" />
|
||||
<span>{{ executionStatus.text }}</span>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-x-3" v-if="workflow?.result">
|
||||
<div class="flex-1">
|
||||
<div class="mb-1">{{ t('workflow.task.info.error') }}</div>
|
||||
<div class="text-error">{{ workflow?.result }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VHover>
|
||||
@@ -378,4 +537,221 @@ const resolveProgress = (item: Workflow) => {
|
||||
.workflow-task-card-hover-area {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.workflow-task-card {
|
||||
--workflow-status-rgb: var(--v-theme-info);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-info);
|
||||
--workflow-card-header-content-rgb: var(--workflow-status-on-rgb);
|
||||
--workflow-card-header-background: linear-gradient(
|
||||
118deg,
|
||||
color-mix(in srgb, rgb(var(--workflow-status-rgb)) 88%, rgb(var(--v-theme-on-surface)) 12%) 0%,
|
||||
rgb(var(--workflow-status-rgb)) 52%,
|
||||
color-mix(in srgb, rgb(var(--workflow-status-rgb)) 78%, rgb(var(--v-theme-surface)) 22%) 100%
|
||||
);
|
||||
--workflow-card-header-shadow: none;
|
||||
|
||||
display: flex;
|
||||
min-block-size: 226px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-task-card--status-primary {
|
||||
--workflow-status-rgb: var(--v-theme-primary);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-primary);
|
||||
}
|
||||
|
||||
.workflow-task-card--status-secondary {
|
||||
--workflow-status-rgb: var(--v-theme-secondary);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-secondary);
|
||||
}
|
||||
|
||||
.workflow-task-card--status-info {
|
||||
--workflow-status-rgb: var(--v-theme-info);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-info);
|
||||
}
|
||||
|
||||
.workflow-task-card--status-success {
|
||||
--workflow-status-rgb: var(--v-theme-success);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-success);
|
||||
}
|
||||
|
||||
.workflow-task-card--status-warning {
|
||||
--workflow-status-rgb: var(--v-theme-warning);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-warning);
|
||||
}
|
||||
|
||||
.workflow-task-card--status-error {
|
||||
--workflow-status-rgb: var(--v-theme-error);
|
||||
--workflow-status-on-rgb: var(--v-theme-on-error);
|
||||
}
|
||||
|
||||
.workflow-task-card__header {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px !important;
|
||||
background: var(--workflow-card-header-background);
|
||||
box-shadow: var(--workflow-card-header-shadow);
|
||||
}
|
||||
|
||||
.workflow-task-card__trigger-icon {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(var(--workflow-card-header-content-rgb)) !important;
|
||||
}
|
||||
|
||||
.workflow-task-card__title {
|
||||
display: -webkit-box;
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
color: rgb(var(--workflow-card-header-content-rgb));
|
||||
font-weight: 500;
|
||||
line-height: 20px !important;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.workflow-task-card__trigger-text {
|
||||
min-inline-size: 0;
|
||||
margin-block-start: 1px;
|
||||
overflow: hidden;
|
||||
color: rgba(var(--workflow-card-header-content-rgb), 0.78);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0;
|
||||
line-height: 16px !important;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-task-card__menu {
|
||||
flex: 0 0 auto;
|
||||
color: rgb(var(--workflow-card-header-content-rgb)) !important;
|
||||
}
|
||||
|
||||
.workflow-task-card__body {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 11px 12px !important;
|
||||
}
|
||||
|
||||
.workflow-task-card__status-row {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-task-card__metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.workflow-task-card__metric {
|
||||
display: grid;
|
||||
min-inline-size: 0;
|
||||
gap: 1px;
|
||||
padding-inline-end: 10px;
|
||||
}
|
||||
|
||||
.workflow-task-card__metric + .workflow-task-card__metric {
|
||||
padding-inline: 10px 0;
|
||||
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.workflow-task-card__metric span,
|
||||
.workflow-task-card__progress-label span {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
.workflow-task-card__metric strong,
|
||||
.workflow-task-card__progress-label strong {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.workflow-task-card__progress {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.workflow-task-card__progress-label {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-task-card__action-track {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
gap: 4px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
|
||||
.workflow-task-card__action-segment {
|
||||
block-size: 6px;
|
||||
min-inline-size: 2px;
|
||||
flex: 1 1 0;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.workflow-task-card__action-segment--complete {
|
||||
background: rgb(var(--workflow-status-rgb));
|
||||
}
|
||||
|
||||
.workflow-task-card__action-segment--active {
|
||||
background: rgba(var(--workflow-status-rgb), 0.22);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--workflow-status-rgb));
|
||||
}
|
||||
|
||||
.workflow-task-card__action-segment--failed {
|
||||
background: rgb(var(--v-theme-error));
|
||||
}
|
||||
|
||||
.workflow-task-card__action-segment--empty {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.workflow-task-card__execution-status {
|
||||
display: flex;
|
||||
min-inline-size: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-block-start: auto;
|
||||
padding-block-start: 9px;
|
||||
border-block-start: 1px solid rgba(var(--v-theme-on-surface), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.workflow-task-card__execution-status .v-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workflow-task-card__execution-status span {
|
||||
min-inline-size: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width <= 599.98px) {
|
||||
.workflow-task-card {
|
||||
min-block-size: 222px;
|
||||
}
|
||||
|
||||
.workflow-task-card__header,
|
||||
.workflow-task-card__body {
|
||||
padding-inline: 10px !important;
|
||||
}
|
||||
|
||||
.workflow-task-card__body {
|
||||
gap: 9px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,16 +4,24 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { deleteDownloadHandler, downloadActionHandler } from '@tests/support/msw/handlers/download'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
|
||||
/** 扩展卡片会消费但公共下载类型尚未声明的站点字段。 */
|
||||
interface DownloadingCardInfo extends DownloadingInfo {
|
||||
site_name?: string
|
||||
trackers?: string[]
|
||||
}
|
||||
|
||||
/** 创建可按用例覆盖字段的下载任务数据。 */
|
||||
function downloading(overrides: Partial<DownloadingCardInfo> = {}): DownloadingCardInfo {
|
||||
return {
|
||||
dlspeed: '2 MiB',
|
||||
hash: 'hash-1',
|
||||
left_time: '1 小时',
|
||||
media: {
|
||||
episode: 'E02',
|
||||
image: 'https://images.example.com/poster.jpg',
|
||||
poster: 'https://images.example.com/poster.jpg',
|
||||
season: 'S01',
|
||||
title: '测试媒体',
|
||||
},
|
||||
@@ -28,12 +36,14 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
|
||||
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
|
||||
return renderWithProviders(DownloadingCard, {
|
||||
props: { downloaderName, info },
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取卡片的继续/暂停和删除操作按钮。 */
|
||||
function actionButtons(container: Element) {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
|
||||
expect(buttons).toHaveLength(2)
|
||||
@@ -71,6 +81,139 @@ describe('DownloadingCard display and pause state', () => {
|
||||
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('normalizes media types, progress bounds, speeds and missing metadata', async () => {
|
||||
const { container, rerender } = await renderCard(
|
||||
downloading({
|
||||
dlspeed: ' 3 MiB/s ',
|
||||
left_time: ' ',
|
||||
media: { title: '英文电影类型', type: 'movie' },
|
||||
progress: 101,
|
||||
size: 0,
|
||||
upspeed: ' ',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
|
||||
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('100%')
|
||||
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('--')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('3 MiB/s')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('0.00 B')
|
||||
|
||||
for (const [type, label] of [
|
||||
['电影', '电影'],
|
||||
['tv', '电视剧'],
|
||||
['电视剧', '电视剧'],
|
||||
['纪录片', '纪录片'],
|
||||
]) {
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { title: `${type}标题`, type }, progress: -1 }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent(label)
|
||||
expect(container.querySelector('.downloading-card__progress')).not.toBeInTheDocument()
|
||||
}
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({
|
||||
dlspeed: undefined,
|
||||
media: { title: '无类型电影' },
|
||||
progress: Number.NaN,
|
||||
season_episode: undefined,
|
||||
upspeed: '1 MiB/s',
|
||||
}),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
|
||||
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('1 MiB/s')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { season: 'S02' }, name: '', season_episode: undefined, title: '任务标题回退' }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电视剧')
|
||||
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('任务标题回退')
|
||||
|
||||
await rerender({ downloaderName: '', info: undefined })
|
||||
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('未知')
|
||||
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
|
||||
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
|
||||
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { ...downloading().media, site_name: '媒体站点' }, site_name: undefined }),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('媒体站点')
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({
|
||||
media: downloading().media,
|
||||
site_name: undefined,
|
||||
trackers: ['', 'not-a-url', 'https://www.tracker.example.com/announce?passkey=secret'],
|
||||
}),
|
||||
})
|
||||
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('tracker.example.com')
|
||||
expect(container).not.toHaveTextContent('passkey')
|
||||
expect(container).not.toHaveTextContent('secret')
|
||||
})
|
||||
|
||||
it('only renders a centered cover image when a poster is available', async () => {
|
||||
const { container, rerender } = await renderCard()
|
||||
|
||||
expect(container.querySelector('.downloading-card__image')).toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { backdrop: 'https://images.example.com/backdrop.jpg' } }),
|
||||
})
|
||||
|
||||
expect(container.querySelector('.downloading-card')).toHaveClass('downloading-card--no-image')
|
||||
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides a failed poster and retries when the task receives a new poster', async () => {
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error'],
|
||||
/** 提供可由用例主动触发失败事件的图片替身。 */
|
||||
setup(_props, { emit }) {
|
||||
return () => h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') })
|
||||
},
|
||||
})
|
||||
const { container, rerender } = await renderWithProviders(DownloadingCard, {
|
||||
props: { downloaderName: 'qb-main', info: downloading() },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '图片加载失败' }))
|
||||
await waitFor(() => expect(container.querySelector('.downloading-card')).toHaveClass('downloading-card--no-image'))
|
||||
|
||||
await rerender({
|
||||
downloaderName: 'qb-main',
|
||||
info: downloading({ media: { ...downloading().media, poster: 'https://images.example.com/new-poster.jpg' } }),
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '图片加载失败' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('applies the shared lift state to the outer shell on hover', async () => {
|
||||
const { container } = await renderCard()
|
||||
const hoverArea = container.querySelector('.downloading-card-hover-area')!
|
||||
const shell = container.querySelector('.downloading-card-shell')!
|
||||
|
||||
await fireEvent.mouseEnter(hoverArea)
|
||||
await waitFor(() => expect(shell).toHaveClass('app-hover-lift-card--hovering'))
|
||||
|
||||
await fireEvent.mouseLeave(hoverArea)
|
||||
await waitFor(() => expect(shell).not.toHaveClass('app-hover-lift-card--hovering'))
|
||||
})
|
||||
|
||||
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
||||
const stopRequested = vi.fn()
|
||||
const startRequested = vi.fn()
|
||||
|
||||
@@ -32,17 +32,22 @@ const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_
|
||||
|
||||
let intersectionObservers: IntersectionObserverMock[] = []
|
||||
|
||||
/** 提供可手动触发的视口观察器,供媒体卡片测试验证懒加载行为。 */
|
||||
class IntersectionObserverMock implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly thresholds: readonly number[]
|
||||
/** 记录观察器释放调用。 */
|
||||
readonly disconnect = vi.fn()
|
||||
/** 记录被观察元素,供后续构造交叉状态。 */
|
||||
readonly observe = vi.fn((target: Element) => {
|
||||
this.target = target
|
||||
})
|
||||
/** 记录停止观察调用。 */
|
||||
readonly unobserve = vi.fn()
|
||||
private target: Element = document.body
|
||||
|
||||
/** 创建使用指定回调和阈值的测试观察器。 */
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
options: IntersectionObserverInit = {},
|
||||
@@ -53,10 +58,12 @@ class IntersectionObserverMock implements IntersectionObserver {
|
||||
intersectionObservers.push(this)
|
||||
}
|
||||
|
||||
/** 返回测试期间未消费的观察记录。 */
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** 手动向组件发送进入或离开视口的交叉状态。 */
|
||||
trigger(isIntersecting = true) {
|
||||
const bounds = this.target.getBoundingClientRect()
|
||||
this.callback(
|
||||
@@ -76,11 +83,13 @@ class IntersectionObserverMock implements IntersectionObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染媒体卡片时可覆盖的用户权限状态。 */
|
||||
interface RenderCardOptions {
|
||||
permissions?: Record<string, boolean>
|
||||
superUser?: boolean
|
||||
}
|
||||
|
||||
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
|
||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
return renderWithProviders(MediaCard, {
|
||||
props: {
|
||||
@@ -101,32 +110,38 @@ async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取已渲染的媒体卡片根元素。 */
|
||||
function getCard(container: Element) {
|
||||
const card = container.querySelector<HTMLElement>('.media-card')
|
||||
expect(card).not.toBeNull()
|
||||
return card as HTMLElement
|
||||
}
|
||||
|
||||
/** 获取负责桌面悬停和触摸交互的卡片区域。 */
|
||||
function getHoverArea(container: Element) {
|
||||
const area = container.querySelector<HTMLElement>('.media-card-hover-area')
|
||||
expect(area).not.toBeNull()
|
||||
return area as HTMLElement
|
||||
}
|
||||
|
||||
/** 获取媒体卡片当前渲染的所有操作按钮。 */
|
||||
function getActionButtons(container: Element) {
|
||||
return [...container.querySelectorAll<HTMLButtonElement>('.media-card .v-card-text button')]
|
||||
}
|
||||
|
||||
/** 获取媒体搜索操作按钮并确保其已渲染。 */
|
||||
function getSearchButton(container: Element) {
|
||||
const button = getActionButtons(container)[0]
|
||||
expect(button).toBeDefined()
|
||||
return button
|
||||
}
|
||||
|
||||
/** 筛选用于触发媒体状态懒加载的观察器。 */
|
||||
function getStatusObservers() {
|
||||
return intersectionObservers.filter(observer => observer.thresholds.includes(0.1))
|
||||
}
|
||||
|
||||
/** 安装站点列表及已选站点的搜索请求处理器。 */
|
||||
function installSearchHandlers(sites: Record<string, unknown>[], selected: number[]) {
|
||||
server.use(
|
||||
http.get(siteListUrl, () => HttpResponse.json(sites)),
|
||||
@@ -522,6 +537,7 @@ describe('MediaCard', () => {
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-src': props.src }, [
|
||||
@@ -560,20 +576,25 @@ describe('MediaCard', () => {
|
||||
name: 'VImg',
|
||||
emits: ['load'],
|
||||
props: { src: String },
|
||||
/** 渲染可主动触发海报加载完成事件的测试替身。 */
|
||||
setup(_props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
|
||||
},
|
||||
})
|
||||
const VIconStub = {
|
||||
props: ['icon'],
|
||||
template: '<i :data-icon="icon" />',
|
||||
}
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
global: { stubs: { VIcon: VIconStub, VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
|
||||
await waitFor(() => expect(container.querySelector('[data-icon="mdi-alpha-a-circle"]')).not.toBeNull())
|
||||
})
|
||||
|
||||
it('hides search and subscribe actions when the user lacks both permissions', async () => {
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
@@ -16,7 +44,32 @@ const plugin: Plugin = {
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginAppCard rating badge', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('shows the top-right score only after the plugin has ratings', async () => {
|
||||
const unrated = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: { ...plugin, average_rating: 0, rating_count: 0 } },
|
||||
@@ -32,4 +85,175 @@ describe('PluginAppCard rating badge', () => {
|
||||
expect(badge).toHaveTextContent('4.3')
|
||||
expect(rated.container.querySelector('.plugin-app-card__title--with-rating')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports an HTTP failure without emitting install or hiding the card', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('network unavailable'))
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(container.querySelector('.plugin-app-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens market details and forwards only the detail completion event', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin, count: 12 },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ plugin, count: 12 })
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const detailEvents = mocks.openSharedDialog.mock.calls[0][2] as { install: () => void }
|
||||
detailEvents.install()
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs a selected release with exact parameters and emits completion', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(mocks.toastError).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the version dialog open and emits nothing on a business failure', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: false, message: '下载失败' })
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await dialogEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:下载失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
})
|
||||
|
||||
it('blocks incompatible latest installs and honors a cancelled release confirmation', async () => {
|
||||
const lifecyclePlugin = {
|
||||
...plugin,
|
||||
history: { 'v1.0.0': '初始版本' },
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: { plugin: lifecyclePlugin },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('版本历史'))
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await dialogEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
await dialogEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders normalized labels, handles image events, and opens a raw GitHub repository', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
plugin_icon: 'https://example.com/plugin.png',
|
||||
plugin_label: ' 工具, 自动化, ,工具 ',
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('工具')).toHaveLength(2)
|
||||
expect(screen.getByText('自动化')).toBeInTheDocument()
|
||||
const image = screen.getByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||
})
|
||||
|
||||
it('uses the author page for a local plugin project link', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { container } = await renderWithProviders(PluginAppCard, {
|
||||
props: {
|
||||
plugin: {
|
||||
...plugin,
|
||||
is_local: true,
|
||||
repo_url: 'local://DemoPlugin',
|
||||
author_url: 'https://github.com/example-author',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example-author', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accentFromImage: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogCloses: [] as Array<ReturnType<typeof vi.fn>>,
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
delete: mocks.apiDelete,
|
||||
get: mocks.apiGet,
|
||||
post: mocks.apiPost,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
const plugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_desc: '用于测试插件生命周期',
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
installed: true,
|
||||
state: true,
|
||||
}
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
describe('PluginCard lifecycle actions', () => {
|
||||
beforeEach(() => {
|
||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.openSharedDialog.mockReset().mockImplementation(() => {
|
||||
const close = vi.fn()
|
||||
mocks.dialogCloses.push(close)
|
||||
return {
|
||||
close,
|
||||
id: mocks.dialogCloses.length,
|
||||
updateProps: vi.fn(),
|
||||
}
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('refreshes plugin sidebar navigation after uninstall succeeds', async () => {
|
||||
mocks.apiDelete.mockResolvedValue({ success: true })
|
||||
const { container, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
await fireEvent.click(menuButton!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiDelete).toHaveBeenCalledWith('plugin/DemoPlugin'))
|
||||
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 卸载成功!')
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors uninstall cancellation and preserves the card on business failure', async () => {
|
||||
mocks.confirm.mockResolvedValueOnce(false)
|
||||
const cancelled = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(cancelled.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
expect(mocks.apiDelete).not.toHaveBeenCalled()
|
||||
cancelled.unmount()
|
||||
|
||||
mocks.confirm.mockResolvedValueOnce(true)
|
||||
mocks.apiDelete.mockResolvedValueOnce({ success: false, message: '仍有任务运行' })
|
||||
const failed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(failed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 卸载失败:仍有任务运行'))
|
||||
expect(failed.emitted()).not.toHaveProperty('remove')
|
||||
expect(failed.container.querySelector('.plugin-card-hover-area')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports uninstall HTTP failures and always closes progress', async () => {
|
||||
mocks.apiDelete.mockRejectedValue(new Error('network unavailable'))
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('卸载'))
|
||||
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('卸载失败')))
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('resets plugin data only after confirmation and refreshes navigation on success', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
|
||||
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('plugin/reset/DemoPlugin'))
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: expect.stringContaining('演示插件') }),
|
||||
)
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 数据已重置')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports reset business and HTTP failures without emitting save', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '无法清理数据' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:无法清理数据'))
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('重置'))
|
||||
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 重置失败:服务器连接失败'))
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('updates from a confirmed Release with exact query parameters', async () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: updatablePlugin },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks an incompatible latest update without sending a request', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
}
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('需要更高版本')
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps version history open after update business and HTTP failures', async () => {
|
||||
const updatablePlugin = {
|
||||
...plugin,
|
||||
has_update: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
}
|
||||
mocks.apiGet.mockResolvedValueOnce({ success: false, message: '校验失败' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
let versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:校验失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(businessFailed.emitted()).not.toHaveProperty('save')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('更新'))
|
||||
versionEvents = mocks.openSharedDialog.mock.calls[0][2] as { update: () => Promise<void> }
|
||||
await versionEvents.update()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('save')
|
||||
})
|
||||
|
||||
it('creates a clone with trimmed form values and refreshes navigation', async () => {
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({
|
||||
suffix: ' Test ',
|
||||
name: '演示分身',
|
||||
description: ' 独立配置 ',
|
||||
version: ' 1.0.1 ',
|
||||
icon: ' https://example.com/icon.png ',
|
||||
})
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/clone/DemoPlugin', {
|
||||
suffix: 'Test',
|
||||
name: '演示分身',
|
||||
description: '独立配置',
|
||||
version: '1.0.1',
|
||||
icon: 'https://example.com/icon.png',
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
expect(emitted().remove).toHaveLength(1)
|
||||
expect(mocks.dialogCloses[0]).toHaveBeenCalled()
|
||||
expect(mocks.dialogCloses[1]).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty clone suffix before calling the API', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
const cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps clone dialog open after business and HTTP failures', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false, message: '后缀已存在' })
|
||||
const businessFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(businessFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
let cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (form: {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.openSharedDialog.mockClear()
|
||||
mocks.dialogCloses.length = 0
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
const httpFailed = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(httpFailed.container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('分身'))
|
||||
cloneEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
clone: (value: typeof form) => Promise<void>
|
||||
}
|
||||
await cloneEvents.clone(form)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败')
|
||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||
expect(httpFailed.emitted()).not.toHaveProperty('remove')
|
||||
})
|
||||
|
||||
it('opens data and config surfaces with reciprocal switch contracts', async () => {
|
||||
const { container, emitted } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin: { ...plugin, has_page: true } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
plugin: expect.objectContaining({ id: 'DemoPlugin' }),
|
||||
})
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({ closeOn: ['close', 'switch'] })
|
||||
|
||||
const dataEvents = mocks.openSharedDialog.mock.calls[0][2] as { switch: () => void }
|
||||
dataEvents.switch()
|
||||
expect(mocks.openSharedDialog.mock.calls[1][3]).toEqual({ closeOn: ['close', 'save', 'switch'] })
|
||||
|
||||
const configEvents = mocks.openSharedDialog.mock.calls[1][2] as { save: () => void; switch: () => void }
|
||||
configEvents.save()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
configEvents.switch()
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||
sortable: true,
|
||||
},
|
||||
global: { stubs: { VImg: ImageStub } },
|
||||
})
|
||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||
await fireEvent.click(image)
|
||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||
await fireEvent.contextMenu(image)
|
||||
await fireEvent.click(authorImage)
|
||||
await fireEvent.click(container.querySelector('.v-card')!)
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the shared log dialog from the menu', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('查看日志'))
|
||||
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ plugin },
|
||||
{},
|
||||
{ closeOn: ['close', 'update:modelValue'] },
|
||||
)
|
||||
})
|
||||
|
||||
it('opens plugin detail from an external action exactly once', async () => {
|
||||
const { emitted, rerender } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, action: false },
|
||||
})
|
||||
|
||||
await rerender({ plugin, action: true })
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(emitted().actionDone).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Plugin } from '@/api/types'
|
||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -54,9 +55,11 @@ describe('PluginCard about menu', () => {
|
||||
})
|
||||
|
||||
it('loads installed plugin detail and opens the shared market detail dialog', async () => {
|
||||
const { container } = await renderWithProviders(PluginCard, {
|
||||
const { container, emitted, pinia } = await renderWithProviders(PluginCard, {
|
||||
props: { plugin, count: 24 },
|
||||
})
|
||||
const sidebarStore = usePluginSidebarNavStore(pinia)
|
||||
vi.mocked(sidebarStore.ensureSidebarNav).mockResolvedValue(undefined)
|
||||
|
||||
const menuButton = container.querySelector<HTMLButtonElement>('.v-card .v-btn')
|
||||
expect(menuButton).not.toBeNull()
|
||||
@@ -76,5 +79,92 @@ describe('PluginCard about menu', () => {
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
})
|
||||
expect(dialogProps.count).toBe(24)
|
||||
expect(mocks.openSharedDialog.mock.calls[0][3]).toEqual({
|
||||
closeOn: ['close', 'install', 'update:modelValue'],
|
||||
})
|
||||
const dialogEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
install: () => void
|
||||
rating: (pluginRating: { plugin_id: string; average_rating: number; rating_count: number }) => void
|
||||
}
|
||||
dialogEvents.rating({ plugin_id: 'DemoPlugin', average_rating: 4.5, rating_count: 13 })
|
||||
expect(emitted().rating).toContainEqual([
|
||||
expect.objectContaining({ plugin_id: 'DemoPlugin', average_rating: 4.5, rating_count: 13 }),
|
||||
])
|
||||
expect(emitted()).not.toHaveProperty('save')
|
||||
expect(sidebarStore.ensureSidebarNav).not.toHaveBeenCalled()
|
||||
|
||||
dialogEvents.install()
|
||||
expect(emitted().save).toHaveLength(1)
|
||||
expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('resolves a missing installed repo from market metadata before opening the project page', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/history/DemoPlugin') return Promise.resolve({ ...plugin, repo_url: 'local://DemoPlugin' })
|
||||
if (url === 'plugin/') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
...plugin,
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
])
|
||||
}
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
const replace = vi.fn()
|
||||
const popup = {
|
||||
close: vi.fn(),
|
||||
location: { replace },
|
||||
opener: window,
|
||||
} as unknown as Window
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(popup)
|
||||
const { container } = await renderWithProviders(PluginCard, { props: { plugin } })
|
||||
|
||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||
await fireEvent.click(await screen.findByText('项目主页'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/', {
|
||||
params: { force: false, state: 'market' },
|
||||
})
|
||||
})
|
||||
expect(open).toHaveBeenCalledWith('about:blank', '_blank')
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith('https://github.com/example/plugins'))
|
||||
expect(popup.opener).toBeNull()
|
||||
})
|
||||
|
||||
it('prioritizes the update status over the rating status', async () => {
|
||||
await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, has_update: true, average_rating: 4.3, rating_count: 12 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('有更新')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('4.3 分,共 12 人评分')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the rating status when no update is available', async () => {
|
||||
await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, has_update: false, average_rating: 4.3, rating_count: 12 },
|
||||
},
|
||||
})
|
||||
|
||||
const ratingStatus = screen.getByLabelText('4.3 分,共 12 人评分')
|
||||
|
||||
expect(ratingStatus).toHaveClass('plugin-card__status--rating')
|
||||
expect(ratingStatus).toHaveTextContent('4.3')
|
||||
expect(screen.queryByLabelText('有更新')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves the status position empty without an update or rating', async () => {
|
||||
await renderWithProviders(PluginCard, {
|
||||
props: {
|
||||
plugin: { ...plugin, has_update: false, average_rating: 0, rating_count: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(document.querySelector('.plugin-card__status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,15 +191,17 @@ describe('SubscribeCard display and progress', () => {
|
||||
expect(progress.querySelector('.v-progress-linear__buffer')).toHaveStyle({ width: expectedWash ? '80%' : '0%' })
|
||||
expect(Boolean(container.querySelector('.best-version-badge'))).toBe(expectedWash)
|
||||
expect(Boolean(container.querySelector('.best-version-badge-full'))).toBe(expectedFull)
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-best-version-tint')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
|
||||
setViewport(480)
|
||||
const { media, rerender } = await renderCard({
|
||||
const { container, media, rerender } = await renderCard({
|
||||
best_version: true,
|
||||
completed_episode: 3,
|
||||
lack_episode: 2,
|
||||
season: 1,
|
||||
state: 'P',
|
||||
total_episode: 10,
|
||||
type: '电视剧',
|
||||
@@ -211,14 +213,41 @@ describe('SubscribeCard display and progress', () => {
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
expect(document.querySelector('.subscribe-card-mobile-menu')).toBeInTheDocument()
|
||||
expect(document.querySelector('.subscribe-card-mobile-media')).toContainElement(screen.getByText(/卡片测试媒体/))
|
||||
expect(document.querySelector('.subscribe-card-mobile-image-meta__updated')).toHaveTextContent(lastUpdateText)
|
||||
expect(document.querySelector('.subscribe-card-mobile-body')).not.toHaveTextContent('卡片测试媒体')
|
||||
expect(document.querySelector('.subscribe-card-mobile-season')).toHaveTextContent('S01')
|
||||
expect(document.querySelector('.subscribe-card-mobile-title-text')).toHaveTextContent('卡片测试媒体S01')
|
||||
expect(document.querySelector('.subscribe-card-mobile-best-version-badge')).not.toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-pending-tint')
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-best-version-tint')
|
||||
|
||||
await rerender({ media: { ...media, state: 'S' } })
|
||||
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).not.toHaveClass('subscribe-card-best-version-tint')
|
||||
|
||||
await rerender({ media: { ...media, state: 'R' } })
|
||||
expect(screen.getByLabelText('订阅中')).toBeInTheDocument()
|
||||
expect(screen.getByText(lastUpdateText)).toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-best-version-tint')
|
||||
})
|
||||
|
||||
it('applies mobile wash visuals to movies without episode progress', async () => {
|
||||
setViewport(480)
|
||||
const { container } = await renderCard({
|
||||
best_version: true,
|
||||
state: 'R',
|
||||
total_episode: undefined,
|
||||
type: '电影',
|
||||
})
|
||||
|
||||
expect(container.querySelector('.subscribe-card')).toHaveClass('subscribe-card-best-version-tint')
|
||||
expect(container.querySelector('[data-subscribe-state-icon="mdi-shimmer"]')).toBeInTheDocument()
|
||||
expect(container.querySelector('.subscribe-card-mobile-state')).toHaveStyle({
|
||||
color: 'rgb(var(--v-theme-success))',
|
||||
})
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('synchronizes desktop P, S, and R state from updated media props', async () => {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import type { Workflow } from '@/api/types'
|
||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiDelete: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: {
|
||||
delete: (...args: unknown[]) => mocks.apiDelete(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
function createWorkflow(overrides: Partial<Workflow> = {}): Workflow {
|
||||
return {
|
||||
actions: [
|
||||
{ id: 'scan', name: '扫描目录', type: 'ScanFile' },
|
||||
{ id: 'scrape', name: '刮削文件', type: 'ScrapeFile' },
|
||||
{ id: 'transfer', name: '整理文件', type: 'TransferFile' },
|
||||
],
|
||||
current_action: undefined,
|
||||
execution_state: {},
|
||||
id: 'workflow-1',
|
||||
name: '扫描和刮削',
|
||||
run_count: 0,
|
||||
state: 'W',
|
||||
trigger_type: 'manual',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCard(workflowOverrides: Partial<Workflow> = {}) {
|
||||
return renderWithProviders(WorkflowTaskCard, {
|
||||
props: {
|
||||
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
|
||||
workflow: createWorkflow(workflowOverrides),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkflowTaskCard redesign', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiDelete.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.confirm.mockReset()
|
||||
mocks.openSharedDialog.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiPost.mockResolvedValue({ success: true })
|
||||
mocks.confirm.mockResolvedValue(true)
|
||||
mocks.openSharedDialog.mockReturnValue({ close: vi.fn(), id: 1, updateProps: vi.fn() })
|
||||
})
|
||||
|
||||
it('maps the generated card icon to the workflow trigger type', async () => {
|
||||
const { container, rerender } = await renderCard()
|
||||
|
||||
expect(container.querySelector('[data-workflow-trigger-icon="mdi-hand-pointing-up"]')).toBeInTheDocument()
|
||||
expect(screen.getByText('手动')).toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
|
||||
workflow: createWorkflow({ timer: '10 * * * *', trigger_type: 'timer' }),
|
||||
})
|
||||
expect(container.querySelector('[data-workflow-trigger-icon="mdi-clock-outline"]')).toBeInTheDocument()
|
||||
expect(screen.getByText('10 * * * *')).toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
eventTypes: [{ title: '下载完成', value: 'download.completed' }],
|
||||
workflow: createWorkflow({ event_type: 'download.completed', trigger_type: 'event' }),
|
||||
})
|
||||
expect(container.querySelector('[data-workflow-trigger-icon="mdi-calendar-check-outline"]')).toBeInTheDocument()
|
||||
expect(screen.getByText('下载完成')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['W', 'warning', '待执行'],
|
||||
['R', 'primary', '运行中'],
|
||||
['S', 'success', '成功'],
|
||||
['P', 'secondary', '暂停'],
|
||||
['F', 'error', '失败'],
|
||||
] as const)('maps %s to the semantic %s status color', async (state, color, label) => {
|
||||
const { container } = await renderCard({ state })
|
||||
|
||||
expect(container.querySelector('.workflow-task-card')).toHaveClass(`workflow-task-card--status-${color}`)
|
||||
expect(container.querySelector('.v-chip')).toHaveTextContent(label)
|
||||
})
|
||||
|
||||
it('renders structured node states as segmented action progress', async () => {
|
||||
const { container } = await renderCard({
|
||||
execution_state: {
|
||||
nodes: {
|
||||
scan: { state: 'success' },
|
||||
scrape: { state: 'skipped' },
|
||||
transfer: { state: 'running' },
|
||||
},
|
||||
runtime: { finished_actions: 2 },
|
||||
},
|
||||
state: 'R',
|
||||
})
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.workflow-task-card__action-segment--complete')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.workflow-task-card__action-segment--active')).toHaveLength(1)
|
||||
expect(screen.getByText('正在执行 整理文件')).toBeInTheDocument()
|
||||
expect(screen.getByRole('progressbar', { name: '动作进度: 2 / 3' })).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
|
||||
it('falls back to unique legacy current-action ids and clamps the count', async () => {
|
||||
const { container } = await renderCard({ current_action: ',scan,,scrape,scan,unknown,', state: 'P' })
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.workflow-task-card__action-segment--complete')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('.workflow-task-card__action-segment--pending')).toHaveLength(1)
|
||||
expect(screen.getByText('上次执行尚未完成')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows failure, last-run and never-run execution details at the bottom', async () => {
|
||||
const lastTime = '2026-08-03 08:00:00'
|
||||
const { rerender } = await renderCard({ result: '目录无访问权限', state: 'F' })
|
||||
|
||||
expect(screen.getByText('目录无访问权限')).toBeInTheDocument()
|
||||
|
||||
await rerender({
|
||||
eventTypes: [],
|
||||
workflow: createWorkflow({ last_time: lastTime, state: 'S' }),
|
||||
})
|
||||
expect(screen.getByText(`上次执行 ${formatDateDifference(lastTime)}`)).toBeInTheDocument()
|
||||
|
||||
await rerender({ eventTypes: [], workflow: createWorkflow() })
|
||||
expect(screen.getByText('从未执行')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps card navigation and enable controls as separate actions', async () => {
|
||||
const { container } = await renderCard({ state: 'P' })
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '启用' }))
|
||||
await waitFor(() => expect(mocks.apiPost).toHaveBeenCalledWith('workflow/workflow-1/start'))
|
||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||
|
||||
await fireEvent.click(container.querySelector('.workflow-task-card') as Element)
|
||||
expect(mocks.openSharedDialog).toHaveBeenCalledOnce()
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toEqual({ workflow: expect.objectContaining({ id: 'workflow-1' }) })
|
||||
})
|
||||
|
||||
it('derives all custom card colors and geometry from global theme tokens', () => {
|
||||
const source = readFileSync('src/components/cards/WorkflowTaskCard.vue', 'utf8')
|
||||
const transparentTheme = readFileSync('src/styles/themes/transparent.scss', 'utf8')
|
||||
const glassTheme = readFileSync('src/styles/themes/glass.scss', 'utf8')
|
||||
|
||||
expect(source).toContain('var(--v-theme-primary)')
|
||||
expect(source).toContain('var(--v-theme-info)')
|
||||
expect(source).toContain('var(--v-theme-success)')
|
||||
expect(source).toContain('var(--v-theme-warning)')
|
||||
expect(source).toContain('var(--v-theme-error)')
|
||||
expect(source).toContain('var(--v-theme-on-surface)')
|
||||
expect(source).toContain('var(--app-control-radius)')
|
||||
expect(source).toContain('linear-gradient(')
|
||||
expect(source).toContain('var(--workflow-card-header-background)')
|
||||
expect(source).not.toContain('--workflow-card-header-border')
|
||||
expect(source).not.toMatch(/#[\da-f]{3,8}\b/i)
|
||||
|
||||
expect(transparentTheme).toContain('.workflow-task-card')
|
||||
expect(transparentTheme).toContain('var(--transparent-opacity-heavy)')
|
||||
expect(transparentTheme).not.toContain('--workflow-card-header-border')
|
||||
expect(glassTheme).toContain('var(--glass-sheen)')
|
||||
expect(glassTheme).toContain("&[data-glass-appearance='frosted'] .workflow-task-card")
|
||||
expect(glassTheme).toContain("&[data-glass-appearance='tinted'] .workflow-task-card")
|
||||
expect(glassTheme).not.toContain('--workflow-card-header-border')
|
||||
})
|
||||
})
|
||||
@@ -100,7 +100,7 @@ const versionStatisticLoading = ref(false)
|
||||
// 版本统计数据
|
||||
const versionStatistic = ref<any>({})
|
||||
|
||||
const MIN_VISIBLE_VERSION_INSTALLS = 2
|
||||
const MIN_VISIBLE_VERSION_INSTALLS = 10
|
||||
|
||||
/** 过滤安装实例过少的版本,避免在版本统计中展示孤立记录。 */
|
||||
function filterVersionStatistics(items: unknown) {
|
||||
|
||||
@@ -22,6 +22,7 @@ const pageSize = 30
|
||||
const loading = ref(false)
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
/** 分页加载下载历史,并将新页追加到现有列表。 */
|
||||
async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
@@ -54,6 +55,7 @@ async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除指定下载历史,并在成功后同步移除当前列表项。 */
|
||||
async function deleteHistory(item: DownloadHistory) {
|
||||
try {
|
||||
const result: { success?: boolean } = await api.delete('history/download', { data: item })
|
||||
@@ -68,15 +70,19 @@ async function deleteHistory(item: DownloadHistory) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先返回海报,缺失时使用背景图,并统一转换为可展示地址。 */
|
||||
function getHistoryImage(item: DownloadHistory) {
|
||||
if (!item.image) return noImage
|
||||
return getDisplayImageUrl(item.image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
const image = item.poster || item.image
|
||||
if (!image) return noImage
|
||||
return getDisplayImageUrl(image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 返回下载历史的主标题。 */
|
||||
function getHistoryTitle(item: DownloadHistory) {
|
||||
return item.title || item.torrent_name || t('dialog.downloadHistory.unknownTitle')
|
||||
}
|
||||
|
||||
/** 合并下载历史的季号和集号。 */
|
||||
function getSeasonEpisode(item: DownloadHistory) {
|
||||
return `${item.seasons || ''}${item.episodes || ''}`
|
||||
}
|
||||
@@ -118,18 +124,19 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
<template #empty />
|
||||
|
||||
<VList lines="three" class="download-history-dialog__content py-0">
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="136">
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="120">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem class="download-history-item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="64"
|
||||
width="96"
|
||||
height="96"
|
||||
width="64"
|
||||
:src="getHistoryImage(item)"
|
||||
aspect-ratio="3/2"
|
||||
aspect-ratio="2/3"
|
||||
class="download-history-item__image me-3 rounded-md"
|
||||
cover
|
||||
position="center"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
@@ -215,7 +222,8 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
}
|
||||
|
||||
.download-history-item {
|
||||
min-block-size: 8.5rem;
|
||||
min-block-size: 7rem;
|
||||
padding-block: 0.5rem !important;
|
||||
}
|
||||
|
||||
.download-history-item__image {
|
||||
@@ -248,6 +256,11 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.download-history-item__date {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.download-history-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -277,9 +290,13 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.download-history-item {
|
||||
min-block-size: 6.5rem;
|
||||
}
|
||||
|
||||
.download-history-item__image {
|
||||
block-size: 54px !important;
|
||||
inline-size: 81px !important;
|
||||
block-size: 84px !important;
|
||||
inline-size: 56px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -188,8 +188,10 @@ onMounted(() => {
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="ma-auto">
|
||||
<div
|
||||
class="subscribe-share-detail-layout d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row"
|
||||
>
|
||||
<div class="subscribe-share-detail__poster">
|
||||
<VImg
|
||||
width="10rem"
|
||||
aspect-ratio="2/3"
|
||||
@@ -205,52 +207,52 @@ onMounted(() => {
|
||||
</template>
|
||||
</VImg>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<div class="flex-grow subscribe-share-detail">
|
||||
<VCardItem class="subscribe-share-detail__header pa-0">
|
||||
<VCardTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
class="subscribe-share-detail__title break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
class="subscribe-share-detail__description break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.media?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('subscribe.sharer') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.keyword">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('subscribe.keyword') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.keyword }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="media?.custom_words" @click.stop="toggleExpand">
|
||||
<VListItemTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces"
|
||||
<dl class="subscribe-share-detail__metadata">
|
||||
<div class="subscribe-share-detail__metadata-row">
|
||||
<dt>{{ t('subscribe.sharer') }}:</dt>
|
||||
<dd>{{ media?.share_user }}</dd>
|
||||
</div>
|
||||
<div v-if="media?.keyword" class="subscribe-share-detail__metadata-row">
|
||||
<dt>{{ t('subscribe.keyword') }}:</dt>
|
||||
<dd>{{ media?.keyword }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="media?.custom_words"
|
||||
class="subscribe-share-detail__recognition"
|
||||
@click.stop="toggleExpand"
|
||||
>
|
||||
<dt>{{ t('subscribe.recognitionWords') }}:</dt>
|
||||
<dd
|
||||
class="break-words"
|
||||
:class="{
|
||||
'line-clamp-4 overflow-hidden text-ellipsis': !isExpanded,
|
||||
}"
|
||||
>
|
||||
<span class="font-weight-medium">{{ t('subscribe.recognitionWords') }}:</span>
|
||||
<span class="text-body-1"> {{ media?.custom_words }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<div>
|
||||
{{ media?.custom_words }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="subscribe-share-detail__actions">
|
||||
<div class="subscribe-share-detail__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.normalSub') }}
|
||||
</VBtn>
|
||||
@@ -259,7 +261,7 @@ onMounted(() => {
|
||||
color="warning"
|
||||
@click="unfollowUser"
|
||||
prepend-icon="mdi-account-remove"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.unfollow') }}
|
||||
</VBtn>
|
||||
@@ -268,7 +270,7 @@ onMounted(() => {
|
||||
@click="followUser"
|
||||
color="info"
|
||||
prepend-icon="mdi-account-plus"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.follow') }}
|
||||
</VBtn>
|
||||
@@ -282,15 +284,14 @@ onMounted(() => {
|
||||
@click="doDelete"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleting"
|
||||
class="mb-2 me-2"
|
||||
class="subscribe-share-detail__button"
|
||||
>
|
||||
{{ t('subscribe.cancelShare') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() })
|
||||
}}
|
||||
<div class="subscribe-share-detail__usage" v-if="props.media?.count">
|
||||
<VIcon icon="mdi-fire" size="18" />
|
||||
<span>{{ t('subscribe.usageCount', { count: props.media?.count?.toLocaleString() }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
@@ -302,3 +303,124 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.subscribe-share-detail-layout {
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__poster {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.subscribe-share-detail {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__title,
|
||||
.subscribe-share-detail__description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__metadata {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
margin: 1.125rem auto;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__metadata-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.625rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__metadata dt {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__metadata dd {
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__recognition {
|
||||
min-inline-size: 0;
|
||||
margin-block-start: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__recognition dt {
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__recognition dd {
|
||||
inline-size: 100%;
|
||||
margin-block-start: 0.35rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__usage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@media (width < 360px) {
|
||||
.subscribe-share-detail__buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__button {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 960px) {
|
||||
.subscribe-share-detail-layout {
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.subscribe-share-detail__poster {
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||
import { WorkflowShare } from '@/api/types'
|
||||
import type { WorkflowShare } from '@/api/types'
|
||||
import WorkflowSummaryPreview from '@/components/workflow/WorkflowSummaryPreview.vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -42,35 +42,6 @@ const getEventTypeText = (eventTypeValue: string) => {
|
||||
return eventType ? eventType.title : eventTypeValue
|
||||
}
|
||||
|
||||
// 流程图相关
|
||||
const { nodes, edges } = useVueFlow()
|
||||
|
||||
// 自定义节点类型
|
||||
const nodeTypes: Record<string, any> = ref({})
|
||||
|
||||
// 自动扫描目录下所有的 .vue 文件
|
||||
const components = import.meta.glob('../workflow/*Action.vue')
|
||||
|
||||
// 动态加载某个组件
|
||||
const loadComponent = async (componentName: string) => {
|
||||
const component = components[`../workflow/${componentName}.vue`]
|
||||
if (component) {
|
||||
return ((await component()) as any).default
|
||||
}
|
||||
throw new Error(t('dialog.workflowActions.componentNotFound', { component: componentName }))
|
||||
}
|
||||
|
||||
// 将所有components中的组件加载到nodeTypes中
|
||||
for (const path in components) {
|
||||
const componentName = path.match(/\.\/workflow\/(.*).vue$/)?.[1]
|
||||
if (!componentName) {
|
||||
continue
|
||||
}
|
||||
loadComponent(componentName).then(component => {
|
||||
nodeTypes.value[componentName] = markRaw(component)
|
||||
})
|
||||
}
|
||||
|
||||
// 解析工作流数据
|
||||
const parsedWorkflow = computed(() => {
|
||||
if (!props.workflow) return null
|
||||
@@ -95,13 +66,10 @@ const parsedWorkflow = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化流程图数据
|
||||
onMounted(() => {
|
||||
if (parsedWorkflow.value) {
|
||||
nodes.value = parsedWorkflow.value.actions ?? []
|
||||
edges.value = parsedWorkflow.value.flows ?? []
|
||||
}
|
||||
})
|
||||
const previewActions = computed(() =>
|
||||
Array.isArray(parsedWorkflow.value?.actions) ? parsedWorkflow.value.actions : [],
|
||||
)
|
||||
const previewFlows = computed(() => (Array.isArray(parsedWorkflow.value?.flows) ? parsedWorkflow.value.flows : []))
|
||||
|
||||
// 复用工作流
|
||||
async function doFork() {
|
||||
@@ -160,81 +128,63 @@ async function doDelete() {
|
||||
<VCard>
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="ma-auto mt-5">
|
||||
<div class="workflow-preview">
|
||||
<VueFlow
|
||||
:nodes="nodes"
|
||||
:edges="edges"
|
||||
:nodeTypes="nodeTypes"
|
||||
:default-edge-options="{ type: 'animation', animated: true }"
|
||||
:delete-key-code="null"
|
||||
:select-nodes-on-drag="false"
|
||||
:nodes-draggable="false"
|
||||
:nodes-connectable="false"
|
||||
:fit-view="true"
|
||||
:fit-view-options="{ padding: 0.1, minZoom: 0.2, maxZoom: 1 }"
|
||||
:default-viewport="{ x: 0, y: 0, zoom: 0.2 }"
|
||||
class="workflow-preview-flow"
|
||||
/>
|
||||
</div>
|
||||
<div class="workflow-share-layout">
|
||||
<div class="workflow-share-preview">
|
||||
<WorkflowSummaryPreview :actions="previewActions" :flows="previewFlows" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<div class="flex-grow workflow-share-detail">
|
||||
<VCardItem class="workflow-share-detail__header pa-0">
|
||||
<VCardTitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
class="workflow-share-detail__title break-words whitespace-break-spaces line-clamp-2 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.workflow?.share_title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
class="workflow-share-detail__description break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ props.workflow?.share_comment }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.sharer') }}:</span>
|
||||
<span class="text-body-1"> {{ props.workflow?.share_user }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="props.workflow?.trigger_type || props.workflow?.timer">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.trigger') }}:</span>
|
||||
<span class="text-body-1">
|
||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ props.workflow?.timer }}
|
||||
</span>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'event'">
|
||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||
{{ getEventTypeText(props.workflow?.event_type || '') }}
|
||||
</span>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'manual'">
|
||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||
{{ t('workflow.manualTrigger') }}
|
||||
</span>
|
||||
<dl class="workflow-share-detail__metadata">
|
||||
<div class="workflow-share-detail__metadata-row">
|
||||
<dt>{{ t('workflow.sharer') }}:</dt>
|
||||
<dd>{{ props.workflow?.share_user }}</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.workflow?.trigger_type || props.workflow?.timer"
|
||||
class="workflow-share-detail__metadata-row"
|
||||
>
|
||||
<dt>{{ t('workflow.trigger') }}:</dt>
|
||||
<dd>
|
||||
<span v-if="props.workflow?.trigger_type === 'timer' || !props.workflow?.trigger_type">
|
||||
<VIcon icon="mdi-clock-outline" size="small" class="me-1" />
|
||||
{{ props.workflow?.timer }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0" v-if="parsedWorkflow?.actions">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('workflow.actionCount') }}:</span>
|
||||
<span class="text-body-1"> {{ parsedWorkflow?.actions?.length }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<div>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'event'">
|
||||
<VIcon icon="mdi-calendar-check" size="small" class="me-1" />
|
||||
{{ getEventTypeText(props.workflow?.event_type || '') }}
|
||||
</span>
|
||||
<span v-else-if="props.workflow?.trigger_type === 'manual'">
|
||||
<VIcon icon="mdi-hand-pointing-up" size="small" class="me-1" />
|
||||
{{ t('workflow.manualTrigger') }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="parsedWorkflow?.actions" class="workflow-share-detail__metadata-row">
|
||||
<dt>{{ t('workflow.actionCount') }}:</dt>
|
||||
<dd>{{ parsedWorkflow?.actions?.length }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="workflow-share-detail__actions">
|
||||
<div class="workflow-share-detail__buttons">
|
||||
<VBtn
|
||||
color="primary"
|
||||
:disabled="processing"
|
||||
@click="doFork"
|
||||
prepend-icon="mdi-heart"
|
||||
:loading="processing"
|
||||
class="mb-2 me-2"
|
||||
class="workflow-share-detail__button"
|
||||
>
|
||||
{{ t('workflow.normalFork') }}
|
||||
</VBtn>
|
||||
@@ -248,15 +198,14 @@ async function doDelete() {
|
||||
@click="doDelete"
|
||||
prepend-icon="mdi-delete"
|
||||
:loading="deleting"
|
||||
class="mb-2 me-2"
|
||||
class="workflow-share-detail__button"
|
||||
>
|
||||
{{ t('workflow.cancelShare') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="text-xs mt-2" v-if="props.workflow?.count">
|
||||
<VIcon icon="mdi-fire" />{{
|
||||
t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() })
|
||||
}}
|
||||
<div class="workflow-share-detail__usage" v-if="props.workflow?.count">
|
||||
<VIcon icon="mdi-fire" size="18" />
|
||||
<span>{{ t('workflow.usageCount', { count: props.workflow?.count?.toLocaleString() }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
@@ -270,65 +219,102 @@ async function doDelete() {
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@vue-flow/core/dist/style.css';
|
||||
@import '@vue-flow/core/dist/theme-default.css';
|
||||
@import '@vue-flow/minimap/dist/style.css';
|
||||
|
||||
.workflow-preview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: rgba(var(--v-theme-surface), 0.8);
|
||||
block-size: 280px;
|
||||
inline-size: 240px;
|
||||
.workflow-share-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 13.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.workflow-preview-flow {
|
||||
block-size: 100%;
|
||||
.workflow-share-preview {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.workflow-share-detail {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.workflow-share-detail__header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-share-detail__title,
|
||||
.workflow-share-detail__description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-share-detail__metadata {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
margin: 1.125rem auto;
|
||||
}
|
||||
|
||||
.workflow-share-detail__metadata-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.workflow-share-detail__metadata dt {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-share-detail__metadata dd {
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.workflow-share-detail__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.workflow-share-detail__buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.vue-flow__node {
|
||||
font-size: 10px;
|
||||
.workflow-share-detail__usage {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
box-shadow: none;
|
||||
}
|
||||
@media (width < 360px) {
|
||||
.workflow-share-detail__buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path,
|
||||
.vue-flow__connection-path {
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
border-radius: 2px;
|
||||
block-size: 12px;
|
||||
inline-size: 4px;
|
||||
}
|
||||
|
||||
// 自定义动作连线样式
|
||||
.vue-flow__edge.animation {
|
||||
.vue-flow__edge-path {
|
||||
stroke: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
&.selected {
|
||||
.vue-flow__edge-path {
|
||||
stroke: rgb(var(--v-theme-primary));
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
.workflow-share-detail__button {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.workflow-preview {
|
||||
block-size: 240px;
|
||||
inline-size: 240px;
|
||||
.workflow-share-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
padding-block-start: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
previewGlassSettings,
|
||||
useThemeCustomizer,
|
||||
type ThemeCustomizerGlassAppearance,
|
||||
type ThemeCustomizerGlassDynamicsMode,
|
||||
type ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ const usesMobilePresentation = useGlassMobilePresentation()
|
||||
const { settings } = useThemeCustomizer()
|
||||
const draftAppearance = ref<ThemeCustomizerGlassAppearance>(settings.value.glassAppearance)
|
||||
const draftDeformationStrength = ref(settings.value.glassDeformationStrength)
|
||||
const draftDynamicsMode = ref<ThemeCustomizerGlassDynamicsMode>(settings.value.glassDynamicsMode)
|
||||
const draftFlowStrength = ref(settings.value.glassFlowStrength)
|
||||
const draftPreset = ref<GlassOpticalPreset>(settings.value.glassPreset)
|
||||
const draftPresetOverrides = ref<GlassOpticalPresetOverrides>({ ...settings.value.glassPresetOverrides })
|
||||
@@ -53,7 +55,8 @@ const draftTranslationStrength = ref(settings.value.glassTranslationStrength)
|
||||
const draftTransparencyStrength = ref(settings.value.glassTransparencyStrength)
|
||||
const isSaving = ref(false)
|
||||
const usesRealtimeOptics = computed(() => draftQuality.value !== 'css')
|
||||
const showsDynamicTuning = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
|
||||
const showsDynamicsMode = computed(() => usesRealtimeOptics.value && !usesMobilePresentation.value)
|
||||
const showsDynamicTuning = computed(() => showsDynamicsMode.value && draftDynamicsMode.value !== 'off')
|
||||
const availablePresets = computed(() => getAvailableGlassOpticalPresets(draftQuality.value))
|
||||
const activePreset = computed<GlassOpticalPreset>(() =>
|
||||
availablePresets.value.includes(draftPreset.value) ? draftPreset.value : 'natural',
|
||||
@@ -75,6 +78,7 @@ watch(
|
||||
if (value) {
|
||||
draftAppearance.value = settings.value.glassAppearance
|
||||
draftDeformationStrength.value = settings.value.glassDeformationStrength
|
||||
draftDynamicsMode.value = settings.value.glassDynamicsMode
|
||||
draftFlowStrength.value = settings.value.glassFlowStrength
|
||||
draftPreset.value = settings.value.glassPreset
|
||||
draftPresetOverrides.value = { ...settings.value.glassPresetOverrides }
|
||||
@@ -120,6 +124,18 @@ const presetOptions: Array<{ label: string; value: GlassOpticalPreset }> = [
|
||||
const visiblePresetOptions = computed(() =>
|
||||
presetOptions.filter(option => availablePresets.value.includes(option.value)),
|
||||
)
|
||||
const dynamicsModeOptions: Array<{
|
||||
hint: string
|
||||
label: string
|
||||
value: ThemeCustomizerGlassDynamicsMode
|
||||
}> = [
|
||||
{ hint: 'theme.glassDynamicsModeFluidHint', label: 'theme.glassDynamicsModeFluid', value: 'fluid' },
|
||||
{ hint: 'theme.glassDynamicsModeRippleHint', label: 'theme.glassDynamicsModeRipple', value: 'ripple' },
|
||||
{ hint: 'theme.glassDynamicsModeOffHint', label: 'theme.glassDynamicsModeOff', value: 'off' },
|
||||
]
|
||||
const dynamicsModeHint = computed(
|
||||
() => dynamicsModeOptions.find(option => option.value === draftDynamicsMode.value)?.hint ?? '',
|
||||
)
|
||||
|
||||
/** 仅允许已实现的材质进入待保存设置。 */
|
||||
function updateAppearance(value: unknown) {
|
||||
@@ -138,11 +154,21 @@ function updateQuality(value: unknown) {
|
||||
applyPreset(activePreset.value)
|
||||
}
|
||||
|
||||
/** 仅切换动态效果草稿,六个具体参数和预设覆盖保持原值。 */
|
||||
function updateDynamicsMode(value: unknown) {
|
||||
const option = dynamicsModeOptions.find(item => item.value === value)
|
||||
if (!option) return
|
||||
|
||||
draftDynamicsMode.value = option.value
|
||||
previewDraftParameters()
|
||||
}
|
||||
|
||||
/** 将材质、质量、预设归属与六个具体参数作为一个预览事务同步。 */
|
||||
function previewDraftParameters() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassDynamicsMode: draftDynamicsMode.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
@@ -258,6 +284,7 @@ async function saveSettings() {
|
||||
previewGlassSettings({
|
||||
glassAppearance: draftAppearance.value,
|
||||
glassDeformationStrength: draftDeformationStrength.value,
|
||||
glassDynamicsMode: draftDynamicsMode.value,
|
||||
glassFlowStrength: draftFlowStrength.value,
|
||||
glassPreset: draftPreset.value,
|
||||
glassPresetOverrides: draftPresetOverrides.value,
|
||||
@@ -317,6 +344,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t('theme.glassAppearanceHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -362,6 +390,29 @@ onScopeDispose(cancelGlassPreview)
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t('theme.glassPresetHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="showsDynamicsMode" class="glass-settings-dialog__dynamics-mode-section">
|
||||
<h3 class="glass-settings-dialog__label">{{ t('theme.glassDynamicsMode') }}</h3>
|
||||
<VBtnToggle
|
||||
:model-value="draftDynamicsMode"
|
||||
mandatory
|
||||
color="primary"
|
||||
variant="text"
|
||||
class="glass-settings-dialog__dynamics-mode"
|
||||
@update:model-value="updateDynamicsMode"
|
||||
>
|
||||
<VBtn
|
||||
v-for="option in dynamicsModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="glass-settings-dialog__dynamics-mode-option"
|
||||
>
|
||||
{{ t(option.label) }}
|
||||
</VBtn>
|
||||
</VBtnToggle>
|
||||
<p class="glass-settings-dialog__hint">{{ t(dynamicsModeHint) }}</p>
|
||||
</section>
|
||||
|
||||
<section class="glass-settings-dialog__tuning">
|
||||
@@ -580,6 +631,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance,
|
||||
.glass-settings-dialog__dynamics-mode,
|
||||
.glass-settings-dialog__quality,
|
||||
.glass-settings-dialog__preset {
|
||||
display: grid;
|
||||
@@ -597,6 +649,11 @@ onScopeDispose(cancelGlassPreview)
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__dynamics-mode {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.glass-settings-dialog__quality {
|
||||
block-size: 42px !important;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -615,6 +672,7 @@ onScopeDispose(cancelGlassPreview)
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option,
|
||||
.glass-settings-dialog__dynamics-mode-option,
|
||||
.glass-settings-dialog__quality-option,
|
||||
.glass-settings-dialog__preset-option {
|
||||
border: 0 !important;
|
||||
@@ -630,11 +688,16 @@ onScopeDispose(cancelGlassPreview)
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__dynamics-mode-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__preset-option {
|
||||
block-size: 32px !important;
|
||||
}
|
||||
|
||||
.glass-settings-dialog__appearance-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__dynamics-mode-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__quality-option:deep(.v-btn--active),
|
||||
.glass-settings-dialog__preset-option:deep(.v-btn--active) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.14) !important;
|
||||
|
||||
@@ -36,7 +36,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'install'])
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'install', 'rating'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
@@ -59,9 +59,6 @@ const selectedRating = ref(props.plugin?.user_rating || 0)
|
||||
const ratingLoading = ref(false)
|
||||
const ratingSubmitting = ref(false)
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
@@ -97,20 +94,16 @@ function visitPluginPage() {
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
if (repoUrl?.includes('raw.githubusercontent.com')) {
|
||||
try {
|
||||
const rawUrl = new URL(repoUrl)
|
||||
const [user, repo] = rawUrl.pathname.split('/').filter(Boolean)
|
||||
if (user && repo) repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (!repoUrl) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
@@ -136,6 +129,8 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
if (!isConfirmed) return
|
||||
}
|
||||
|
||||
const failureMessageKey = isInstalled.value ? 'plugin.updateFailed' : 'plugin.installFailed'
|
||||
|
||||
try {
|
||||
showInstallProgress(
|
||||
isInstalled.value && !releaseVersion
|
||||
@@ -146,16 +141,14 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
const result: ApiResponse<unknown> = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: repoUrl || props.plugin?.repo_url,
|
||||
release_version: releaseVersion,
|
||||
force: props.plugin?.has_update || Boolean(releaseVersion),
|
||||
force: isInstalled.value || props.plugin?.has_update || Boolean(releaseVersion),
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(
|
||||
isInstalled.value
|
||||
@@ -167,11 +160,18 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
$toast.error(t(failureMessageKey, { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
$toast.error(
|
||||
t(failureMessageKey, {
|
||||
name: props.plugin?.plugin_name,
|
||||
message: t('common.serverConnectionFailed'),
|
||||
}),
|
||||
)
|
||||
console.error(error)
|
||||
} finally {
|
||||
closeInstallProgress()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,7 @@ async function submitPluginRating() {
|
||||
if (result.success) {
|
||||
rating.value = result.data
|
||||
selectedRating.value = result.data.user_rating || selectedRating.value
|
||||
emit('rating', result.data)
|
||||
$toast.success(t('plugin.ratingSuccess', { name: props.plugin?.plugin_name }))
|
||||
} else {
|
||||
$toast.error(t('plugin.ratingFailed', { message: result.message || t('common.unknown') }))
|
||||
@@ -247,190 +248,263 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialog v-if="visible" v-model="visible" width="100%" max-width="25rem" max-height="90dvh" scrollable>
|
||||
<VCard class="plugin-market-detail">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg ref="imageRef" :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one" class="border-0">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.author') }}:</span>
|
||||
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="props.plugin?.system_version" class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}:</span>
|
||||
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem v-if="rating.rating_count > 0" class="ps-0">
|
||||
<VListItemTitle class="plugin-market-detail-rating-row text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('plugin.rating') }}:</span>
|
||||
<PluginRatingDisplay
|
||||
:rating="rating.average_rating"
|
||||
:count="rating.rating_count"
|
||||
:icon-size="18"
|
||||
/>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
v-if="!isInstalled"
|
||||
color="primary"
|
||||
@click="installPlugin()"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.plugin?.has_update"
|
||||
color="primary"
|
||||
prepend-icon="mdi-arrow-up-circle-outline"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
@click="installPlugin()"
|
||||
>
|
||||
{{ t('plugin.update') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" @click="showUpdateHistory" prepend-icon="mdi-update">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div class="plugin-market-detail-actions__downloads" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isInstalled" class="plugin-market-detail-user-rating mt-5">
|
||||
<div class="text-body-2 font-weight-medium mb-2">
|
||||
{{ t('plugin.yourRating') }}
|
||||
</div>
|
||||
<div class="plugin-market-detail-user-rating__controls">
|
||||
<VRating
|
||||
v-model="selectedRating"
|
||||
:disabled="ratingLoading || ratingSubmitting"
|
||||
half-increments
|
||||
hover
|
||||
density="compact"
|
||||
active-color="warning"
|
||||
/>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-star-check-outline"
|
||||
:loading="ratingSubmitting"
|
||||
:disabled="ratingLoading || selectedRating <= 0"
|
||||
@click="submitPluginRating"
|
||||
>
|
||||
{{ t('plugin.submitRating') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
<VCardText class="plugin-market-detail__content">
|
||||
<header class="plugin-market-detail__header">
|
||||
<VAvatar size="64" class="plugin-market-detail__avatar">
|
||||
<VImg :src="pluginIconPath()" aspect-ratio="4/3" cover @error="imageLoadError = true" />
|
||||
</VAvatar>
|
||||
<h2 class="plugin-market-detail__title">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</h2>
|
||||
<p v-if="props.plugin?.plugin_desc" class="plugin-market-detail__description">
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</p>
|
||||
<div v-if="rating.rating_count > 0" class="plugin-market-detail__header-rating">
|
||||
<PluginRatingDisplay :rating="rating.average_rating" :count="rating.rating_count" :icon-size="18" />
|
||||
</div>
|
||||
</VCol>
|
||||
</header>
|
||||
|
||||
<dl class="plugin-market-detail__metadata">
|
||||
<div class="plugin-market-detail__metadata-row">
|
||||
<dt>{{ t('common.version') }}:</dt>
|
||||
<dd>v{{ props.plugin?.plugin_version }}</dd>
|
||||
</div>
|
||||
<div class="plugin-market-detail__metadata-row">
|
||||
<dt>{{ t('common.author') }}:</dt>
|
||||
<dd>
|
||||
<button type="button" class="plugin-market-detail__author" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<VAlert
|
||||
v-if="props.plugin?.system_version_compatible === false"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="plugin-market-detail__warning"
|
||||
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
|
||||
<div class="plugin-market-detail-actions">
|
||||
<div class="plugin-market-detail-actions__buttons">
|
||||
<VBtn
|
||||
v-if="!isInstalled"
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
@click="installPlugin()"
|
||||
>
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else-if="props.plugin?.has_update"
|
||||
color="primary"
|
||||
prepend-icon="mdi-arrow-up-circle-outline"
|
||||
:disabled="props.plugin?.system_version_compatible === false"
|
||||
@click="installPlugin()"
|
||||
>
|
||||
{{ t('plugin.update') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" prepend-icon="mdi-update" @click="showUpdateHistory">
|
||||
{{ t('plugin.versionHistory') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
<div v-if="props.count" class="plugin-market-detail-actions__downloads">
|
||||
<VIcon icon="mdi-fire" size="18" />
|
||||
<span>{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="isInstalled" class="plugin-market-detail-user-rating">
|
||||
<h3 class="plugin-market-detail-user-rating__title">
|
||||
{{ t('plugin.yourRating') }}
|
||||
</h3>
|
||||
<div class="plugin-market-detail-user-rating__controls">
|
||||
<VRating
|
||||
v-model="selectedRating"
|
||||
:disabled="ratingLoading || ratingSubmitting"
|
||||
half-increments
|
||||
hover
|
||||
density="compact"
|
||||
active-color="warning"
|
||||
/>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-star-check-outline"
|
||||
:loading="ratingSubmitting"
|
||||
:disabled="ratingLoading || selectedRating <= 0"
|
||||
@click="submitPluginRating"
|
||||
>
|
||||
{{ t('plugin.submitRating') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</section>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugin-market-detail-actions {
|
||||
.plugin-market-detail__content {
|
||||
padding: 1.75rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-market-detail__avatar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.plugin-market-detail__title {
|
||||
max-inline-size: 100%;
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.plugin-market-detail__description {
|
||||
max-inline-size: 24rem;
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.plugin-market-detail__header-rating {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin-block: 0.75rem 0.375rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail__metadata {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
margin: 1.125rem 0;
|
||||
}
|
||||
|
||||
.plugin-market-detail__metadata-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.plugin-market-detail__metadata dt {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plugin-market-detail__metadata dd {
|
||||
min-inline-size: 0;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.plugin-market-detail__author {
|
||||
max-inline-size: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.plugin-market-detail__author:hover,
|
||||
.plugin-market-detail__author:focus-visible {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.plugin-market-detail__warning {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
/* 窄屏换行时用统一 gap 控制按钮间距,避免第二个按钮带左边距导致视觉偏移。 */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
flex-basis: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plugin-market-detail-rating-row {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
white-space: normal;
|
||||
gap: 0.3rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-market-detail-user-rating {
|
||||
margin-block-start: 1.25rem;
|
||||
padding-block-start: 1rem;
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.plugin-market-detail-user-rating__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
.plugin-market-detail-user-rating__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (width >= 960px) {
|
||||
.plugin-market-detail-actions {
|
||||
justify-content: flex-start;
|
||||
.plugin-market-detail-user-rating__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
@media (width < 360px) {
|
||||
.plugin-market-detail__content {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__buttons {
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.plugin-market-detail-actions__downloads {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.plugin-market-detail-rating-row {
|
||||
justify-content: flex-start;
|
||||
.plugin-market-detail-actions__buttons :deep(.v-btn) {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,11 +81,13 @@ function closeDialog() {
|
||||
<VTextField
|
||||
v-model="searchKeyword"
|
||||
:label="t('plugin.searchPlugins')"
|
||||
:mobile-layout="false"
|
||||
single-line
|
||||
:placeholder="t('plugin.searchPlaceholder')"
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
autofocus
|
||||
class="mx-1"
|
||||
/>
|
||||
</VToolbar>
|
||||
|
||||
@@ -63,7 +63,9 @@ const resolvedHistory = computed(() => {
|
||||
|
||||
const hasHistory = computed(() => Object.keys(resolvedHistory.value).length > 0)
|
||||
|
||||
const latestActionText = computed(() => props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'))
|
||||
const latestActionText = computed(() =>
|
||||
props.actionMode === 'install' ? t('plugin.installReleaseVersion') : t('plugin.updateToLatest'),
|
||||
)
|
||||
|
||||
const releaseItems = computed(() => releaseDetail.value?.items || [])
|
||||
|
||||
@@ -187,29 +189,48 @@ watch(
|
||||
<div v-if="loading" class="plugin-version-history-dialog__loading">
|
||||
<VProgressCircular indeterminate color="primary" />
|
||||
</div>
|
||||
<VCardText v-else-if="loadError && !hasHistory">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
</VCardText>
|
||||
<VCardText v-else-if="!hasHistory && !releaseLoading">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<template v-else>
|
||||
<VCardText v-if="releaseError" class="pb-0">
|
||||
<VAlert type="warning" variant="tonal" density="compact" :text="releaseError" />
|
||||
<VCardText v-if="loadError || releaseError" class="pb-0">
|
||||
<VAlert v-if="loadError" type="warning" variant="tonal" density="compact" :text="loadError" />
|
||||
<VAlert
|
||||
v-if="releaseError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
:class="{ 'mt-2': loadError }"
|
||||
:text="releaseError"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardText v-if="!hasHistory && !releaseLoading && !loadError && !releaseError">
|
||||
<VAlert type="info" variant="tonal" density="compact" :text="t('plugin.updateHistoryEmpty')" />
|
||||
</VCardText>
|
||||
<VersionHistory
|
||||
v-if="hasHistory"
|
||||
:history="resolvedHistory"
|
||||
:has-action="version => shouldShowReleaseButton(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
<template #meta="{ version }">
|
||||
<div v-if="releaseItemByHistoryVersion(version)" class="plugin-release-meta">
|
||||
<span v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)" class="plugin-release-meta__date">
|
||||
<span
|
||||
v-if="formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at)"
|
||||
class="plugin-release-meta__date"
|
||||
>
|
||||
{{ formatReleaseDate(releaseItemByHistoryVersion(version)?.published_at) }}
|
||||
</span>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_latest" size="x-small" color="primary" variant="tonal">
|
||||
<VChip
|
||||
v-if="releaseItemByHistoryVersion(version)?.is_latest"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('plugin.latestVersion') }}
|
||||
</VChip>
|
||||
<VChip v-if="releaseItemByHistoryVersion(version)?.is_current" size="x-small" color="success" variant="tonal">
|
||||
<VChip
|
||||
v-if="releaseItemByHistoryVersion(version)?.is_current"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ t('plugin.currentVersion') }}
|
||||
</VChip>
|
||||
</div>
|
||||
@@ -229,9 +250,7 @@ watch(
|
||||
@click.stop="handleUpdate(releaseItemByHistoryVersion(version))"
|
||||
>
|
||||
{{
|
||||
releaseItemByHistoryVersion(version)?.is_latest
|
||||
? latestActionText
|
||||
: t('plugin.installReleaseVersion')
|
||||
releaseItemByHistoryVersion(version)?.is_latest ? latestActionText : t('plugin.installReleaseVersion')
|
||||
}}
|
||||
</VBtn>
|
||||
</template>
|
||||
@@ -248,11 +267,7 @@ watch(
|
||||
class="mb-3"
|
||||
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
|
||||
/>
|
||||
<VBtn
|
||||
@click="handleUpdate()"
|
||||
block
|
||||
:disabled="resolvedPlugin?.system_version_compatible === false"
|
||||
>
|
||||
<VBtn @click="handleUpdate()" block :disabled="resolvedPlugin?.system_version_compatible === false">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
@@ -289,5 +304,4 @@ watch(
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -88,7 +88,7 @@ const filteredSites = computed(() => {
|
||||
</script>
|
||||
<template>
|
||||
<!-- Site Selection Dialog -->
|
||||
<VDialog max-width="40rem" :fullscreen="!display.smAndUp.value">
|
||||
<VDialog scrollable max-width="40rem" :fullscreen="!display.smAndUp.value">
|
||||
<VCard class="site-dialog">
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
@@ -100,7 +100,7 @@ const filteredSites = computed(() => {
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VDivider />
|
||||
<VCardText style="max-block-size: 420px" class="overflow-y-auto px-4 py-4">
|
||||
<VCardText class="site-dialog__content overflow-y-auto px-4 py-4">
|
||||
<!-- 站点列表 -->
|
||||
<div v-if="filteredSites.length > 0">
|
||||
<!-- 选择操作 -->
|
||||
@@ -205,6 +205,12 @@ const filteredSites = computed(() => {
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.site-dialog__content {
|
||||
max-block-size: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-checkbox-wrapper:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDisplay, useTheme } from 'vuetify'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { hexToRgb } from '@layouts/utils'
|
||||
|
||||
// 多语言支持
|
||||
const { t } = useI18n()
|
||||
@@ -33,6 +34,28 @@ const currentTheme = controlledComputed(
|
||||
() => vuetifyTheme.current.value.colors,
|
||||
)
|
||||
|
||||
const variableTheme = controlledComputed(
|
||||
() => vuetifyTheme.name.value,
|
||||
() => vuetifyTheme.current.value.variables,
|
||||
)
|
||||
|
||||
/** 将 Vuetify 主题颜色转换为 ApexCharts 使用的透明色。 */
|
||||
function toThemeRgba(color: unknown, opacity: string | number) {
|
||||
const rgb = hexToRgb(String(color))
|
||||
|
||||
return rgb ? `rgba(${rgb},${opacity})` : String(color)
|
||||
}
|
||||
|
||||
/** 将站点统计日期格式化为本地化短日期,减少图表横轴占用空间。 */
|
||||
function formatChartDate(value: string) {
|
||||
if (!value) return ''
|
||||
|
||||
return new Date(value).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
// 站点数据列表
|
||||
const siteDatas = ref<SiteUserData[]>([])
|
||||
|
||||
@@ -58,17 +81,24 @@ const historySeries = computed(() => {
|
||||
|
||||
// 图形选项
|
||||
const historyChartOptions = computed(() => {
|
||||
const axisLabelColor = toThemeRgba(currentTheme.value['on-surface'], variableTheme.value['medium-emphasis-opacity'])
|
||||
const gridColor = toThemeRgba(variableTheme.value['border-color'], variableTheme.value['border-opacity'])
|
||||
const themeMode = vuetifyTheme.global.current.value.dark ? 'dark' : 'light'
|
||||
|
||||
return {
|
||||
chart: {
|
||||
type: 'area',
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 图表背景随应用主题切换
|
||||
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
|
||||
dataLabels: {
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 450,
|
||||
animateGradually: { enabled: true, delay: 100 },
|
||||
dynamicAnimation: { enabled: true, speed: 350 },
|
||||
},
|
||||
background: 'transparent',
|
||||
foreColor: axisLabelColor,
|
||||
zoom: {
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
@@ -76,70 +106,82 @@ const historyChartOptions = computed(() => {
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
mode: themeMode,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
tooltip: {
|
||||
x: {
|
||||
format: 'dd MMM yyyy',
|
||||
},
|
||||
shared: true,
|
||||
intersect: false,
|
||||
theme: themeMode,
|
||||
x: {
|
||||
formatter: (value: string) => formatChartDate(value),
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
y: {
|
||||
formatter: (value: number) => `${value.toLocaleString()} GB`,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
},
|
||||
lines: { show: true },
|
||||
},
|
||||
borderColor: gridColor,
|
||||
strokeDashArray: 6,
|
||||
xaxis: { lines: { show: false } },
|
||||
yaxis: { lines: { show: true } },
|
||||
padding: { top: -10, left: 2, right: 8, bottom: 0 },
|
||||
},
|
||||
stroke: {
|
||||
width: 3,
|
||||
lineCap: 'butt',
|
||||
width: [3, 3],
|
||||
lineCap: 'round',
|
||||
curve: 'smooth',
|
||||
},
|
||||
colors: [currentTheme.value.success, currentTheme.value.warning],
|
||||
legend: { show: false },
|
||||
markers: {
|
||||
size: 0,
|
||||
style: 'hollow',
|
||||
strokeWidth: 2,
|
||||
strokeColors: currentTheme.value.surface,
|
||||
hover: { size: 6, sizeOffset: 2 },
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
xaxis: {
|
||||
type: 'category',
|
||||
categories: siteDatas.value.map(item => item.updated_day),
|
||||
labels: {
|
||||
show: true,
|
||||
formatter: function (val: string) {
|
||||
return new Date(val).toLocaleDateString('zh-CN')
|
||||
},
|
||||
formatter: (val: string) => formatChartDate(val),
|
||||
style: { colors: axisLabelColor, fontSize: '10px' },
|
||||
},
|
||||
axisTicks: { show: false },
|
||||
axisBorder: { show: false },
|
||||
crosshairs: { stroke: { color: currentTheme.value.success, opacity: 0.2, dashArray: 4 } },
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
style: { color: axisLabelColor, fontSize: '10px', fontWeight: 500 },
|
||||
},
|
||||
labels: {
|
||||
formatter: function (val: number) {
|
||||
return val.toLocaleString()
|
||||
},
|
||||
style: { colors: axisLabelColor, fontSize: '10px' },
|
||||
},
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0.7,
|
||||
stops: [0, 100],
|
||||
shade: 'light',
|
||||
type: 'vertical',
|
||||
shadeIntensity: 0.28,
|
||||
opacityFrom: 0.38,
|
||||
opacityTo: 0.04,
|
||||
stops: [0, 90, 100],
|
||||
},
|
||||
},
|
||||
noData: {
|
||||
text: t('dialog.siteUserData.noData'),
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
style: { color: axisLabelColor, fontSize: '13px' },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,14 +199,23 @@ const seedingSeries = computed(() => {
|
||||
|
||||
// 做种分布图形选项
|
||||
const seedingChartOptions = computed(() => {
|
||||
const axisLabelColor = toThemeRgba(currentTheme.value['on-surface'], variableTheme.value['medium-emphasis-opacity'])
|
||||
const gridColor = toThemeRgba(variableTheme.value['border-color'], variableTheme.value['border-opacity'])
|
||||
const themeMode = vuetifyTheme.global.current.value.dark ? 'dark' : 'light'
|
||||
|
||||
return {
|
||||
chart: {
|
||||
type: 'scatter',
|
||||
parentHeightOffset: 0,
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true },
|
||||
background: currentTheme.value.surface, // 图表背景随应用主题切换
|
||||
foreColor: currentTheme.value.onSurface, // 图表文字随应用主题切换
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: 'easeinout',
|
||||
speed: 420,
|
||||
dynamicAnimation: { speed: 320 },
|
||||
},
|
||||
background: 'transparent',
|
||||
foreColor: axisLabelColor,
|
||||
zoom: {
|
||||
enabled: false,
|
||||
allowMouseWheelZoom: false,
|
||||
@@ -172,29 +223,36 @@ const seedingChartOptions = computed(() => {
|
||||
selection: { enabled: false },
|
||||
},
|
||||
theme: {
|
||||
mode: vuetifyTheme.global.current.value.dark ? 'dark' : 'light', // 同步主题模式
|
||||
mode: themeMode,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
theme: themeMode,
|
||||
intersect: true,
|
||||
x: {
|
||||
formatter: function (val: number) {
|
||||
return t('dialog.siteUserData.countTitle') + val.toLocaleString()
|
||||
return `${t('dialog.siteUserData.countTitle')}${val.toLocaleString()}`
|
||||
},
|
||||
},
|
||||
style: {
|
||||
background: currentTheme.value.background, // 提示框背景色同步
|
||||
color: currentTheme.value.onBackground, // 文字颜色同步
|
||||
y: {
|
||||
formatter: (val: number) => `${val.toLocaleString()} GB`,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
xaxis: {
|
||||
lines: { show: true },
|
||||
},
|
||||
yaxis: {
|
||||
lines: { show: true },
|
||||
},
|
||||
borderColor: gridColor,
|
||||
strokeDashArray: 6,
|
||||
xaxis: { lines: { show: true } },
|
||||
yaxis: { lines: { show: true } },
|
||||
padding: { top: -4, left: 4, right: 12, bottom: 4 },
|
||||
},
|
||||
colors: [currentTheme.value.primary],
|
||||
markers: {
|
||||
size: 7,
|
||||
strokeWidth: 2,
|
||||
strokeColors: currentTheme.value.surface,
|
||||
hover: { size: 9, sizeOffset: 2 },
|
||||
},
|
||||
dataLabels: { enabled: false },
|
||||
xaxis: {
|
||||
type: 'numeric',
|
||||
labels: {
|
||||
@@ -202,22 +260,34 @@ const seedingChartOptions = computed(() => {
|
||||
formatter: function (val: number) {
|
||||
return Math.round(val).toLocaleString()
|
||||
},
|
||||
style: { colors: axisLabelColor, fontSize: '10px' },
|
||||
},
|
||||
title: {
|
||||
text: t('dialog.siteUserData.countTitle'),
|
||||
style: { color: axisLabelColor, fontSize: '10px', fontWeight: 500 },
|
||||
},
|
||||
tickAmount: 10,
|
||||
axisTicks: { show: false },
|
||||
axisBorder: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'GB',
|
||||
style: { color: axisLabelColor, fontSize: '10px', fontWeight: 500 },
|
||||
},
|
||||
labels: {
|
||||
formatter: function (val: number) {
|
||||
return val.toLocaleString() + ' GB'
|
||||
},
|
||||
style: { colors: axisLabelColor, fontSize: '10px' },
|
||||
},
|
||||
},
|
||||
noData: {
|
||||
text: t('dialog.siteUserData.noData'),
|
||||
align: 'center',
|
||||
verticalAlign: 'middle',
|
||||
style: { color: axisLabelColor, fontSize: '13px' },
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -358,10 +428,10 @@ onBeforeMount(() => {
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
<VRow class="match-height">
|
||||
<VRow class="match-height site-data-summary-grid">
|
||||
<!-- 用户信息 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--primary">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -378,8 +448,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 积分 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--warning">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -399,8 +469,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 分享率 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--info">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1">
|
||||
@@ -420,8 +490,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总上传量 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--success">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -441,8 +511,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总下载量 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--warning">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -462,8 +532,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总做种数 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--primary">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -483,8 +553,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 总做种体积 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--info">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -504,8 +574,8 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
<!-- 加入时间 -->
|
||||
<VCol cols="12" md="3">
|
||||
<VCard>
|
||||
<VCol cols="12" sm="6" md="3">
|
||||
<VCard class="site-data-summary-card site-data-summary-card--secondary">
|
||||
<VCardText class="d-flex align-center">
|
||||
<div class="d-flex justify-space-between" style="inline-size: 100%">
|
||||
<div class="d-flex flex-column gap-y-1 overflow-hidden">
|
||||
@@ -522,20 +592,55 @@ onBeforeMount(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VCard :title="t('dialog.siteUserData.trafficHistory')">
|
||||
<VCardText>
|
||||
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
|
||||
<VRow class="site-data-chart-grid match-height">
|
||||
<VCol cols="12" md="7">
|
||||
<VCard class="site-data-chart-card">
|
||||
<VCardItem class="site-data-chart-header">
|
||||
<template #prepend>
|
||||
<VAvatar color="success" variant="tonal" size="34" rounded="lg">
|
||||
<VIcon icon="mdi-chart-areaspline" size="19" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>{{ t('dialog.siteUserData.trafficHistory') }}</VCardTitle>
|
||||
<template #append>
|
||||
<span class="site-data-chart-caption">GB</span>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="site-data-chart-content">
|
||||
<div class="site-data-chart-plot">
|
||||
<VApexChart type="line" :options="historyChartOptions" :series="historySeries" height="100%" />
|
||||
</div>
|
||||
<div class="site-data-chart-legend">
|
||||
<span
|
||||
><i class="site-data-legend-dot site-data-legend-dot--upload" />{{ historySeries[0].name }}</span
|
||||
>
|
||||
<span
|
||||
><i class="site-data-legend-dot site-data-legend-dot--download" />{{ historySeries[1].name }}</span
|
||||
>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol>
|
||||
<VCard :title="t('dialog.siteUserData.seedingDistribution')">
|
||||
<VCardText>
|
||||
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
|
||||
<VCol cols="12" md="5">
|
||||
<VCard class="site-data-chart-card">
|
||||
<VCardItem class="site-data-chart-header">
|
||||
<template #prepend>
|
||||
<VAvatar color="primary" variant="tonal" size="34" rounded="lg">
|
||||
<VIcon icon="mdi-chart-scatter-plot" size="19" />
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VCardTitle>{{ t('dialog.siteUserData.seedingDistribution') }}</VCardTitle>
|
||||
<template #append>
|
||||
<span class="site-data-chart-caption">GB</span>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText class="site-data-chart-content">
|
||||
<div class="site-data-chart-plot">
|
||||
<VApexChart type="scatter" :options="seedingChartOptions" :series="seedingSeries" height="100%" />
|
||||
</div>
|
||||
<div class="site-data-chart-legend">
|
||||
<span><i class="site-data-legend-dot site-data-legend-dot--seed" />{{ seedingSeries[0].name }}</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VCol>
|
||||
@@ -546,3 +651,196 @@ onBeforeMount(() => {
|
||||
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="t('dialog.siteUserData.refreshing')" />
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.site-data-summary-card,
|
||||
.site-data-chart-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 0.35rem 1rem rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.site-data-summary-card {
|
||||
--site-data-accent: var(--v-theme-primary);
|
||||
|
||||
block-size: 100%;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.site-data-summary-card--success {
|
||||
--site-data-accent: var(--v-theme-success);
|
||||
}
|
||||
|
||||
.site-data-summary-card--warning {
|
||||
--site-data-accent: var(--v-theme-warning);
|
||||
}
|
||||
|
||||
.site-data-summary-card--info {
|
||||
--site-data-accent: var(--v-theme-info);
|
||||
}
|
||||
|
||||
.site-data-summary-card--secondary {
|
||||
--site-data-accent: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.site-data-summary-card:hover {
|
||||
border-color: rgba(var(--site-data-accent), 0.34);
|
||||
box-shadow: 0 0.5rem 1.25rem rgba(var(--v-theme-on-surface), 0.09);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.site-data-summary-card :deep(.v-card-text) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-block-size: 98px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.site-data-summary-card :deep(.v-card-text > .d-flex) {
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.site-data-summary-card .text-base {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.site-data-summary-card .text-h5 {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.site-data-summary-card .text-h5 > .text-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.045);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0.28rem 0.42rem;
|
||||
}
|
||||
|
||||
.site-data-summary-card .text-h5 > .text-base.text-success {
|
||||
border-color: rgba(var(--v-theme-success), 0.22);
|
||||
background: rgba(var(--v-theme-success), 0.1);
|
||||
color: rgb(var(--v-theme-success)) !important;
|
||||
}
|
||||
|
||||
.site-data-summary-card .text-h5 > .text-base.text-error {
|
||||
border-color: rgba(var(--v-theme-error), 0.22);
|
||||
background: rgba(var(--v-theme-error), 0.1);
|
||||
color: rgb(var(--v-theme-error)) !important;
|
||||
}
|
||||
|
||||
.site-data-summary-card :deep(.v-avatar) {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgba(var(--site-data-accent), 0.18);
|
||||
background: rgba(var(--site-data-accent), 0.13) !important;
|
||||
box-shadow: 0 0.45rem 1rem rgba(var(--site-data-accent), 0.12);
|
||||
color: rgb(var(--site-data-accent)) !important;
|
||||
}
|
||||
|
||||
.site-data-chart-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
block-size: 100%;
|
||||
min-block-size: 310px;
|
||||
}
|
||||
|
||||
.site-data-chart-header {
|
||||
min-block-size: 64px;
|
||||
}
|
||||
|
||||
.site-data-chart-header :deep(.v-card-title) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.site-data-chart-caption {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-data-chart-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.site-data-chart-plot {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 230px;
|
||||
block-size: clamp(15rem, 25vw, 20rem);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.site-data-chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.72rem;
|
||||
padding-block-start: 0.65rem;
|
||||
}
|
||||
|
||||
.site-data-chart-legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.site-data-legend-dot {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
block-size: 0.48rem;
|
||||
inline-size: 0.48rem;
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.site-data-legend-dot--upload {
|
||||
background: rgb(var(--v-theme-success));
|
||||
}
|
||||
|
||||
.site-data-legend-dot--download {
|
||||
background: rgb(var(--v-theme-warning));
|
||||
}
|
||||
|
||||
.site-data-legend-dot--seed {
|
||||
background: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.site-data-chart-card {
|
||||
min-block-size: 290px;
|
||||
}
|
||||
|
||||
.site-data-chart-plot {
|
||||
min-block-size: 210px;
|
||||
block-size: 14rem;
|
||||
}
|
||||
|
||||
.site-data-chart-legend {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@ const DialogStub = defineComponent({
|
||||
})
|
||||
|
||||
describe('AboutDialog version statistics', () => {
|
||||
it('hides backend and frontend versions with fewer than two installations', async () => {
|
||||
it('hides backend and frontend versions with fewer than ten installations', async () => {
|
||||
mocks.apiGet.mockImplementation((path: string) => {
|
||||
if (path === 'system/env') {
|
||||
return Promise.resolve({
|
||||
@@ -44,12 +44,12 @@ describe('AboutDialog version statistics', () => {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
backend_versions: [
|
||||
{ version: 'backend-hidden', count: 1 },
|
||||
{ version: 'backend-visible', count: 2 },
|
||||
{ version: 'backend-hidden', count: 9 },
|
||||
{ version: 'backend-visible', count: 10 },
|
||||
],
|
||||
frontend_versions: [
|
||||
{ version: 'frontend-hidden', count: 0 },
|
||||
{ version: 'frontend-visible', count: 3 },
|
||||
{ version: 'frontend-visible', count: 11 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -81,6 +81,31 @@ const MenuStub = defineComponent({
|
||||
},
|
||||
})
|
||||
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
aspectRatio: [String, Number],
|
||||
cover: Boolean,
|
||||
height: [String, Number],
|
||||
position: String,
|
||||
src: String,
|
||||
width: [String, Number],
|
||||
},
|
||||
setup(props, { attrs }) {
|
||||
return () =>
|
||||
h('img', {
|
||||
...attrs,
|
||||
src: props.src,
|
||||
'data-aspect-ratio': props.aspectRatio,
|
||||
'data-cover': String(props.cover),
|
||||
'data-height': props.height,
|
||||
'data-position': props.position,
|
||||
'data-width': props.width,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
let historySeed = 5000
|
||||
|
||||
function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistory {
|
||||
@@ -90,7 +115,8 @@ function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistor
|
||||
download_hash: `hash-${historySeed}`,
|
||||
episodes: 'E01-E02',
|
||||
id: historySeed,
|
||||
image: `https://images.example.com/history-${historySeed}.jpg`,
|
||||
image: `https://images.example.com/backdrop-${historySeed}.jpg`,
|
||||
poster: `https://images.example.com/poster-${historySeed}.jpg`,
|
||||
path: `/downloads/history-${historySeed}`,
|
||||
seasons: 'S01',
|
||||
title: `历史媒体 ${historySeed}`,
|
||||
@@ -123,6 +149,7 @@ async function renderDialog() {
|
||||
},
|
||||
stubs: {
|
||||
VInfiniteScroll: InfiniteScrollStub,
|
||||
VImg: ImageStub,
|
||||
VMenu: MenuStub,
|
||||
VVirtualScroll: VirtualScrollStub,
|
||||
},
|
||||
@@ -138,9 +165,10 @@ describe('DownloadHistoryDialog', () => {
|
||||
|
||||
it('loads and renders download history with page parameters', async () => {
|
||||
const item = createHistory({ title: '首载剧集' })
|
||||
const backdropItem = createHistory({ poster: undefined, title: '背景图历史' })
|
||||
const requests: URL[] = []
|
||||
server.use(
|
||||
downloadHistoryHandler([item], 200, url => {
|
||||
downloadHistoryHandler([item, backdropItem], 200, url => {
|
||||
requests.push(url)
|
||||
}),
|
||||
)
|
||||
@@ -148,9 +176,9 @@ describe('DownloadHistoryDialog', () => {
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
|
||||
expect(screen.getByText('(2026)')).toBeInTheDocument()
|
||||
expect(screen.getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
||||
expect(screen.getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
||||
expect(screen.getAllByText('(2026)')).toHaveLength(2)
|
||||
expect(within(historyRow(item)).getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
||||
expect(within(historyRow(item)).getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
||||
const resourceTitle = screen.getByText(item.torrent_name!)
|
||||
expect(resourceTitle).toHaveClass('download-history-item__torrent', 'download-history-item__meta')
|
||||
expect(document.querySelector('.download-history-item__date')).toHaveClass('download-history-item__meta')
|
||||
@@ -159,6 +187,16 @@ describe('DownloadHistoryDialog', () => {
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||
const posterImage = historyRow(item).querySelector<HTMLImageElement>('img.download-history-item__image')
|
||||
const backdropImage = historyRow(backdropItem).querySelector<HTMLImageElement>('img.download-history-item__image')
|
||||
|
||||
expect(posterImage).toHaveAttribute('src', item.poster)
|
||||
expect(backdropImage).toHaveAttribute('src', backdropItem.image)
|
||||
expect(posterImage).toHaveAttribute('data-aspect-ratio', '2/3')
|
||||
expect(posterImage).toHaveAttribute('data-cover', 'true')
|
||||
expect(posterImage).toHaveAttribute('data-height', '96')
|
||||
expect(posterImage).toHaveAttribute('data-position', 'center')
|
||||
expect(posterImage).toHaveAttribute('data-width', '64')
|
||||
})
|
||||
|
||||
it('appends later pages and preserves existing rows at the end', async () => {
|
||||
|
||||
@@ -142,6 +142,21 @@ describe('ForkSubscribeDialog follow behavior', () => {
|
||||
expect(requested).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('places long recognition words in a full-width metadata row', async () => {
|
||||
server.use(followSubscribersSettingHandler([]))
|
||||
const { media } = await renderDialog(
|
||||
createSubscribeShare({
|
||||
custom_words: '#九门2026\n【ADWeb】\n^The.Mystic.Nine => 九门.The.Mystic.Nine',
|
||||
}),
|
||||
)
|
||||
|
||||
const row = document.querySelector('.subscribe-share-detail__recognition')
|
||||
|
||||
expect(row).toBeInTheDocument()
|
||||
expect(row?.querySelector(':scope > dt')).toHaveTextContent('识别词:')
|
||||
expect(row?.querySelector(':scope > dd')).toHaveTextContent(media.custom_words!.replaceAll('\n', ' '))
|
||||
})
|
||||
|
||||
it('follows a share user and refreshes the action from the server setting', async () => {
|
||||
const media = createSubscribeShare({ share_uid: 'new-follow-user' })
|
||||
const users: string[] = []
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const dialogSource = readFileSync('src/components/dialog/ForkWorkflowDialog.vue', 'utf8')
|
||||
|
||||
describe('ForkWorkflowDialog preview contract', () => {
|
||||
it('uses the static summary without loading an interactive VueFlow canvas', () => {
|
||||
expect(dialogSource).toContain('WorkflowSummaryPreview')
|
||||
expect(dialogSource).not.toContain('@vue-flow/core')
|
||||
expect(dialogSource).not.toContain('import.meta.glob')
|
||||
expect(dialogSource).not.toContain('<VueFlow')
|
||||
})
|
||||
|
||||
it('reserves the close-button safe area on small screens', () => {
|
||||
expect(dialogSource).toMatch(
|
||||
/@media screen and \(width <= 600px\)[\s\S]*?\.workflow-share-layout\s*\{[\s\S]*?padding-block-start:\s*2rem/,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ const dialogStub = {
|
||||
template: '<div class="dialog-stub" :data-fullscreen="String(fullscreen)"><slot /></div>',
|
||||
}
|
||||
const toggleStub = {
|
||||
emits: ['update:modelValue'],
|
||||
name: 'VBtnToggle',
|
||||
props: ['modelValue'],
|
||||
template: '<div :data-model-value="modelValue"><slot /></div>',
|
||||
}
|
||||
@@ -31,6 +33,7 @@ const mocks = vi.hoisted(() => ({
|
||||
value: {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -78,6 +81,7 @@ describe('GlassSettingsDialog', () => {
|
||||
mocks.display.smAndDown.value = false
|
||||
mocks.settings.value.glassAppearance = 'clear'
|
||||
mocks.settings.value.glassDeformationStrength = 50
|
||||
mocks.settings.value.glassDynamicsMode = 'fluid'
|
||||
mocks.settings.value.glassFlowStrength = 50
|
||||
mocks.settings.value.glassPreset = 'natural'
|
||||
mocks.settings.value.glassPresetOverrides = {}
|
||||
@@ -174,6 +178,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'frosted',
|
||||
glassDeformationStrength: 79,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 77,
|
||||
glassPreset: 'liquid',
|
||||
glassPresetOverrides: {
|
||||
@@ -218,6 +223,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 50,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 50,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -255,6 +261,7 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
expect(wrapper.find('.glass-settings-dialog__preset').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__preset-state').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the selected preset highlighted and records its combination override', async () => {
|
||||
@@ -335,6 +342,7 @@ describe('GlassSettingsDialog', () => {
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 69,
|
||||
glassDynamicsMode: 'fluid',
|
||||
glassFlowStrength: 62,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -364,6 +372,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -374,6 +383,7 @@ describe('GlassSettingsDialog', () => {
|
||||
const sliders = wrapper.findAll('.slider-stub')
|
||||
|
||||
expect(sliders).toHaveLength(6)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('fluid')
|
||||
expect(sliders.map(slider => slider.attributes('aria-label'))).toEqual([
|
||||
'theme.glassTransparencyStrength',
|
||||
'theme.glassTransmissionStrength',
|
||||
@@ -386,6 +396,7 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
it('restores motion tuning when a mobile presentation returns to desktop', async () => {
|
||||
mocks.usesMobilePresentation!.value = true
|
||||
mocks.settings.value.glassDynamicsMode = 'ripple'
|
||||
mocks.settings.value.glassQuality = 'high'
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
@@ -393,6 +404,7 @@ describe('GlassSettingsDialog', () => {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
@@ -407,6 +419,7 @@ describe('GlassSettingsDialog', () => {
|
||||
'theme.glassReflectionStrength',
|
||||
])
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
|
||||
expect(wrapper.text()).not.toContain('theme.glassOpticalStrengthHint')
|
||||
expect(wrapper.text()).toContain('theme.glassQualityMobileHint')
|
||||
@@ -416,7 +429,63 @@ describe('GlassSettingsDialog', () => {
|
||||
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(true)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').attributes('data-model-value')).toBe('ripple')
|
||||
expect(wrapper.text()).toContain('theme.glassAppearanceHint')
|
||||
expect(wrapper.text()).toContain('theme.glassPresetHint')
|
||||
expect(wrapper.text()).toContain('theme.glassMaterialStrengthHint')
|
||||
expect(wrapper.text()).toContain('theme.glassOpticalStrengthHint')
|
||||
})
|
||||
|
||||
it('keeps optical parameters while switching modes and hides motion tuning only when off', async () => {
|
||||
mocks.settings.value.glassDynamicsMode = 'ripple'
|
||||
mocks.settings.value.glassQuality = 'balanced'
|
||||
mocks.settings.value.glassDeformationStrength = 62
|
||||
mocks.settings.value.glassFlowStrength = 58
|
||||
mocks.settings.value.glassReflectionStrength = 44
|
||||
mocks.settings.value.glassTransmissionStrength = 67
|
||||
mocks.settings.value.glassTranslationStrength = 76
|
||||
mocks.settings.value.glassTransparencyStrength = 53
|
||||
const wrapper = shallowMount(GlassSettingsDialog, {
|
||||
global: {
|
||||
stubs: {
|
||||
VCard: slotStub,
|
||||
VCardActions: slotStub,
|
||||
VCardText: slotStub,
|
||||
VBtn: slotStub,
|
||||
VBtnToggle: toggleStub,
|
||||
VDialog: dialogStub,
|
||||
VDialogCloseBtn: true,
|
||||
VSlider: sliderStub,
|
||||
},
|
||||
},
|
||||
props: { modelValue: true },
|
||||
})
|
||||
const modeControl = wrapper
|
||||
.findAllComponents({ name: 'VBtnToggle' })
|
||||
.find(component => component.classes().includes('glass-settings-dialog__dynamics-mode'))
|
||||
if (!modeControl) throw new Error('dynamics mode control was not rendered')
|
||||
|
||||
expect(modeControl.attributes('data-model-value')).toBe('ripple')
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(6)
|
||||
|
||||
modeControl.vm.$emit('update:modelValue', 'off')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(mocks.previewGlassSettings).toHaveBeenLastCalledWith({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 62,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 58,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
glassQuality: 'balanced',
|
||||
glassReflectionStrength: 44,
|
||||
glassTransmissionStrength: 67,
|
||||
glassTranslationStrength: 76,
|
||||
glassTransparencyStrength: 53,
|
||||
})
|
||||
expect(wrapper.findAll('.slider-stub')).toHaveLength(3)
|
||||
expect(wrapper.find('.glass-settings-dialog__live-controls').exists()).toBe(false)
|
||||
expect(wrapper.find('.glass-settings-dialog__dynamics-mode').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,16 @@ import type { Plugin, PluginRating } from '@/api/types'
|
||||
import PluginMarketDetailDialog from '@/components/dialog/PluginMarketDetailDialog.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import type { Stubs } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
dialogClose: vi.fn(),
|
||||
openSharedDialog: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
@@ -23,6 +28,14 @@ vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSharedDialog', () => ({
|
||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useConfirm', () => ({
|
||||
useConfirm: () => mocks.confirm,
|
||||
}))
|
||||
|
||||
const basePlugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
@@ -30,6 +43,9 @@ const basePlugin: Plugin = {
|
||||
plugin_version: '1.0.0',
|
||||
plugin_author: 'MoviePilot',
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
average_rating: 4.3,
|
||||
rating_count: 12,
|
||||
user_rating: 4.0,
|
||||
}
|
||||
|
||||
const ratingResult: PluginRating = {
|
||||
@@ -39,25 +55,45 @@ const ratingResult: PluginRating = {
|
||||
user_rating: 4.0,
|
||||
}
|
||||
|
||||
async function renderDialog(plugin: Plugin) {
|
||||
const ImageStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error'],
|
||||
template: '<button data-testid="plugin-image" @contextmenu.prevent="$emit(\'error\')" />',
|
||||
})
|
||||
|
||||
async function renderDialog(plugin: Plugin, stubs: Stubs = {}) {
|
||||
return renderWithProviders(PluginMarketDetailDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
plugin,
|
||||
},
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
global: {
|
||||
components: { VDialogCloseBtn: DialogCloseBtn },
|
||||
stubs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginMarketDetailDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockResolvedValue(ratingResult)
|
||||
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: true })
|
||||
})
|
||||
mocks.apiPost.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
data: { ...ratingResult, average_rating: 4.5, user_rating: 4.5 },
|
||||
})
|
||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||
mocks.dialogClose.mockReset()
|
||||
mocks.openSharedDialog.mockReset().mockReturnValue({
|
||||
close: mocks.dialogClose,
|
||||
id: 1,
|
||||
updateProps: vi.fn(),
|
||||
})
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('shows install action and readonly rating for a market plugin', async () => {
|
||||
@@ -69,7 +105,7 @@ describe('PluginMarketDetailDialog', () => {
|
||||
})
|
||||
|
||||
it('hides install action and submits a half-star rating for an installed plugin', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true })
|
||||
|
||||
expect(await screen.findByText('提交评分')).toBeInTheDocument()
|
||||
expect(screen.queryByText('安装到本地')).not.toBeInTheDocument()
|
||||
@@ -84,6 +120,9 @@ describe('PluginMarketDetailDialog', () => {
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/rating/DemoPlugin', { rating: 4.5 })
|
||||
})
|
||||
expect(emitted().rating).toContainEqual([
|
||||
expect.objectContaining({ average_rating: 4.5, plugin_id: 'DemoPlugin', user_rating: 4.5 }),
|
||||
])
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('已提交对插件 演示插件 的评分')
|
||||
})
|
||||
|
||||
@@ -95,11 +134,263 @@ describe('PluginMarketDetailDialog', () => {
|
||||
user_rating: undefined,
|
||||
})
|
||||
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
await renderDialog({
|
||||
...basePlugin,
|
||||
installed: true,
|
||||
average_rating: 0,
|
||||
rating_count: 0,
|
||||
user_rating: undefined,
|
||||
})
|
||||
|
||||
expect(await screen.findByText('v1.0.0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('插件评分:')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('我的评分')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '提交评分' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps long plugin details in centered metadata rows', async () => {
|
||||
const pluginDescription = '支持包含较长说明文字和换行内容的插件详情。\n第二行内容保持完整展示。'
|
||||
const pluginAuthor = 'MoviePilot-Plugin-Author-With-A-Long-Name'
|
||||
|
||||
await renderDialog({
|
||||
...basePlugin,
|
||||
installed: true,
|
||||
plugin_desc: pluginDescription,
|
||||
plugin_author: pluginAuthor,
|
||||
system_version: 'v2.15.0 or later',
|
||||
})
|
||||
|
||||
const description = document.querySelector('.plugin-market-detail__description')
|
||||
|
||||
expect(description).toHaveTextContent('支持包含较长说明文字和换行内容的插件详情。 第二行内容保持完整展示。')
|
||||
expect(screen.getByRole('button', { name: pluginAuthor })).toHaveClass('plugin-market-detail__author')
|
||||
|
||||
const metadata = document.querySelector('.plugin-market-detail__metadata')
|
||||
const metadataRows = metadata?.querySelectorAll('.plugin-market-detail__metadata-row')
|
||||
|
||||
expect(metadataRows).toHaveLength(2)
|
||||
metadataRows?.forEach(row => {
|
||||
expect(row.querySelector(':scope > dt')).not.toBeNull()
|
||||
expect(row.querySelector(':scope > dd')).not.toBeNull()
|
||||
})
|
||||
|
||||
const headerRating = document.querySelector('.plugin-market-detail__header-rating')
|
||||
|
||||
expect(headerRating?.previousElementSibling).toBe(description)
|
||||
expect(headerRating?.querySelector('.plugin-rating-display')).not.toBeNull()
|
||||
expect(screen.queryByText('插件评分:')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('v2.15.0 or later')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits installation completion only after installation succeeds', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: false,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the detail open and emits nothing after a business failure', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: false, message: '安装包损坏' })
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 安装失败:安装包损坏')
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
})
|
||||
|
||||
it('reports an HTTP install failure without closing or emitting completion', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.reject(new Error('network unavailable'))
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('安装失败'))
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks an incompatible latest install before sending a request', async () => {
|
||||
await renderDialog({
|
||||
...basePlugin,
|
||||
installed: false,
|
||||
system_version_compatible: false,
|
||||
system_version_message: '需要更高版本',
|
||||
})
|
||||
|
||||
const installButton = await screen.findByRole('button', { name: '安装到本地' })
|
||||
expect(installButton).toBeDisabled()
|
||||
expect(screen.getByText('需要更高版本')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
|
||||
})
|
||||
|
||||
it('installs a confirmed historical Release with its repo URL', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: false, release: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update('0.9.0', 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: '0.9.0',
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows update semantics for an installed plugin with a newer version', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
})
|
||||
|
||||
it('reports an HTTP update failure with update semantics', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.reject(new Error('network unavailable'))
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:服务器连接失败')
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
expect(mocks.dialogClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a business update failure with the backend message', async () => {
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === 'plugin/rating/DemoPlugin') return Promise.resolve(ratingResult)
|
||||
return Promise.resolve({ success: false, message: '更新包损坏' })
|
||||
})
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '更新' }))
|
||||
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('插件 演示插件 更新失败:更新包损坏')
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
expect(emitted()).not.toHaveProperty('install')
|
||||
expect(emitted()).not.toHaveProperty('update:modelValue')
|
||||
})
|
||||
|
||||
it('forces a latest Release update when the installed plugin snapshot has no update flag', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false, release: true })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
const versionEvents = mocks.openSharedDialog.mock.calls[0][2] as {
|
||||
update: (releaseVersion?: string, repoUrl?: string) => Promise<void>
|
||||
}
|
||||
await versionEvents.update(undefined, 'https://github.com/example/releases')
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
release_version: undefined,
|
||||
repo_url: 'https://github.com/example/releases',
|
||||
},
|
||||
})
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 更新成功!')
|
||||
expect(emitted().install).toHaveLength(1)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
})
|
||||
|
||||
it('handles image fallback and opens a raw GitHub repository from the author row', async () => {
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
await renderDialog(
|
||||
{
|
||||
...basePlugin,
|
||||
installed: false,
|
||||
plugin_icon: 'https://example.com/plugin.png',
|
||||
repo_url: 'https://raw.githubusercontent.com/example/plugins/main/package.json',
|
||||
},
|
||||
{ VImg: ImageStub },
|
||||
)
|
||||
|
||||
await fireEvent.contextMenu(screen.getByTestId('plugin-image'))
|
||||
await fireEvent.click(await screen.findByText('MoviePilot'))
|
||||
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/example/plugins', '_blank')
|
||||
})
|
||||
|
||||
it('reports rating business and HTTP failures with stable feedback', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ success: false })
|
||||
const businessFailed = await renderDialog({ ...basePlugin, installed: true })
|
||||
const businessButton = await screen.findByRole('button', { name: '提交评分' })
|
||||
await waitFor(() => expect(businessButton).toBeEnabled())
|
||||
await fireEvent.click(businessButton)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:未知')
|
||||
businessFailed.unmount()
|
||||
|
||||
mocks.apiPost.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
await renderDialog({ ...basePlugin, installed: true })
|
||||
const httpButton = await screen.findByRole('button', { name: '提交评分' })
|
||||
await waitFor(() => expect(httpButton).toBeEnabled())
|
||||
await fireEvent.click(httpButton)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('评分提交失败:服务器连接失败')
|
||||
})
|
||||
|
||||
it('refreshes the current plugin rating when the detail opens', async () => {
|
||||
await renderDialog({ ...basePlugin, installed: false })
|
||||
|
||||
expect(await screen.findByRole('button', { name: '安装到本地' })).toBeEnabled()
|
||||
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledOnce()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/rating/DemoPlugin')
|
||||
})
|
||||
|
||||
it('opens installed version history without an update action and closes through the model contract', async () => {
|
||||
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: false })
|
||||
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '版本历史' }))
|
||||
expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({
|
||||
actionMode: 'update',
|
||||
showUpdateAction: false,
|
||||
})
|
||||
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||
expect(closeButton).not.toBeNull()
|
||||
await fireEvent.click(closeButton!)
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(emitted().close).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||
import type { Plugin, PluginReleaseVersionsResponse } from '@/api/types'
|
||||
import PluginVersionHistoryDialog from '@/components/dialog/PluginVersionHistoryDialog.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
const installedPlugin: Plugin = {
|
||||
id: 'DemoPlugin',
|
||||
plugin_name: '演示插件',
|
||||
plugin_version: '1.0.0',
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
installed: true,
|
||||
release: true,
|
||||
}
|
||||
|
||||
const releases: PluginReleaseVersionsResponse = {
|
||||
release_supported: true,
|
||||
latest_version: '2.0.0',
|
||||
current_version: '1.0.0',
|
||||
items: [
|
||||
{
|
||||
version: '2.0.0',
|
||||
tag_name: 'v2.0.0',
|
||||
body: '最新说明',
|
||||
published_at: '2026-08-01T00:00:00Z',
|
||||
is_latest: true,
|
||||
is_current: false,
|
||||
},
|
||||
{
|
||||
version: '1.0.0',
|
||||
tag_name: 'v1.0.0',
|
||||
body: '当前说明',
|
||||
is_latest: false,
|
||||
is_current: true,
|
||||
},
|
||||
{
|
||||
version: '0.9.0',
|
||||
tag_name: 'v0.9.0',
|
||||
body: '旧版说明',
|
||||
published_at: 'invalid-date',
|
||||
is_latest: false,
|
||||
is_current: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function renderDialog(props: Record<string, unknown>) {
|
||||
return renderWithProviders(PluginVersionHistoryDialog, {
|
||||
props,
|
||||
global: { components: { VDialogCloseBtn: DialogCloseBtn } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('PluginVersionHistoryDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset().mockImplementation((url: string) => {
|
||||
if (url === 'plugin/history/DemoPlugin') {
|
||||
return Promise.resolve({ ...installedPlugin, history: { 'v1.0.0': '当前更新说明' } })
|
||||
}
|
||||
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
|
||||
throw new Error(`Unexpected request: ${url}`)
|
||||
})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
})
|
||||
|
||||
it('loads installed history and releases, marks versions, and emits exact update arguments', async () => {
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
showUpdateAction: true,
|
||||
actionMode: 'update',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||
expect(screen.getByText('最新')).toBeInTheDocument()
|
||||
expect(screen.getByText('当前')).toBeInTheDocument()
|
||||
expect(screen.getByText('invalid-date')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(1, 'plugin/history/DemoPlugin', {
|
||||
params: { force: true },
|
||||
})
|
||||
expect(mocks.apiGet).toHaveBeenNthCalledWith(2, 'plugin/releases/DemoPlugin', {
|
||||
params: {
|
||||
force: true,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '安装' }))
|
||||
expect(emitted().update).toEqual([['0.9.0', 'https://github.com/example/plugins']])
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '更新到最新版本' }))
|
||||
expect(emitted().update).toEqual([
|
||||
['0.9.0', 'https://github.com/example/plugins'],
|
||||
[undefined, 'https://github.com/example/plugins'],
|
||||
])
|
||||
})
|
||||
|
||||
it('uses market metadata directly and emits latest installation without a release version', async () => {
|
||||
const marketPlugin = { ...installedPlugin, installed: false, history: {} }
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: marketPlugin,
|
||||
actionMode: 'install',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('v2.0.0')).toBeInTheDocument()
|
||||
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/history/DemoPlugin', expect.anything())
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/releases/DemoPlugin', {
|
||||
params: {
|
||||
force: false,
|
||||
repo_url: 'https://github.com/example/plugins',
|
||||
},
|
||||
})
|
||||
|
||||
const installButtons = screen.getAllByRole('button', { name: '安装' })
|
||||
await fireEvent.click(installButtons[0])
|
||||
expect(emitted().update).toEqual([[undefined, 'https://github.com/example/plugins']])
|
||||
})
|
||||
|
||||
it('distinguishes a Release request failure from an empty history', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('release unavailable'))
|
||||
await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: { ...installedPlugin, installed: false, history: {} },
|
||||
actionMode: 'install',
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Release 版本加载失败')).toBeInTheDocument()
|
||||
expect(screen.queryByText('暂未获取到更新说明')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows history request failures and true empty history as different states', async () => {
|
||||
mocks.apiGet.mockRejectedValueOnce(new Error('history unavailable'))
|
||||
const failed = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
expect(await screen.findByText('读取更新说明失败,请稍后重试')).toBeInTheDocument()
|
||||
failed.unmount()
|
||||
|
||||
mocks.apiGet.mockReset().mockResolvedValue({ ...installedPlugin, release: false, history: {} })
|
||||
await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
expect(await screen.findByText('暂未获取到更新说明')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes through the model contract', async () => {
|
||||
const { emitted } = await renderDialog({
|
||||
modelValue: true,
|
||||
plugin: installedPlugin,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
const closeButton = document.querySelector<HTMLButtonElement>('.absolute.right-3.top-3')
|
||||
expect(closeButton).not.toBeNull()
|
||||
await fireEvent.click(closeButton!)
|
||||
|
||||
expect(emitted()['update:modelValue']).toContainEqual([false])
|
||||
expect(emitted().close).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -150,7 +150,12 @@ describe('SiteUserDataDialog projections', () => {
|
||||
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
|
||||
expect(
|
||||
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
|
||||
).toBe(new Date('2026-07-19').toLocaleDateString('zh-CN'))
|
||||
).toBe(
|
||||
new Date('2026-07-19').toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
)
|
||||
expect((history.options.yaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1234)).toBe(
|
||||
(1234).toLocaleString(),
|
||||
)
|
||||
|
||||
@@ -104,11 +104,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VCard class="media-id-selector mx-auto" width="100%">
|
||||
<VCardTitle class="d-flex align-center justify-space-between">
|
||||
<span>{{ t('dialog.reorganize.mediaSearchInput') }}</span>
|
||||
<VDialogCloseBtn
|
||||
inner-class="static"
|
||||
:aria-label="t('common.close')"
|
||||
inner-class="media-id-selector__close"
|
||||
@click="
|
||||
() => {
|
||||
emit('close')
|
||||
@@ -116,24 +117,25 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<VCardText class="media-id-selector__search">
|
||||
<VTextField
|
||||
ref="inputKeyword"
|
||||
v-model="keyword"
|
||||
:mobile-layout="false"
|
||||
single-line
|
||||
:placeholder="t('dialog.reorganize.mediaSearchPlaceholder')"
|
||||
variant="solo"
|
||||
variant="outlined"
|
||||
append-inner-icon="mdi-magnify"
|
||||
flat
|
||||
hide-details
|
||||
:loading="loading"
|
||||
@click:append-inner="searchMedias"
|
||||
@keydown.enter="searchMedias"
|
||||
/>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VList v-if="items.length > 0" lines="three">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<VList v-if="items.length > 0" class="media-id-selector__results" lines="three">
|
||||
<template v-for="item in items" :key="`${item.type || 'media'}-${item.id}`">
|
||||
<VListItem @click="selectMedia(item)">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
@@ -160,3 +162,29 @@ onMounted(() => {
|
||||
</VList>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.media-id-selector {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.media-id-selector__search {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible !important;
|
||||
padding-block: 0.25rem 1rem !important;
|
||||
}
|
||||
|
||||
.media-id-selector__close {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.media-id-selector__results {
|
||||
flex: 1 1 auto;
|
||||
min-block-size: 0;
|
||||
overflow-anchor: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import MediaIdSelector from '@/components/misc/MediaIdSelector.vue'
|
||||
import { fireEvent, screen } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
describe('MediaIdSelector layout', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
})
|
||||
|
||||
it('keeps the search field visible while results scroll independently', async () => {
|
||||
mocks.apiGet.mockResolvedValue([
|
||||
{
|
||||
media_id: 'tmdb-1',
|
||||
overview: '测试简介',
|
||||
poster_path: '',
|
||||
title: 'Hello Mini',
|
||||
type: '电视剧',
|
||||
year: '2019',
|
||||
},
|
||||
])
|
||||
|
||||
const { container } = await renderWithProviders(MediaIdSelector, {
|
||||
global: {
|
||||
stubs: {
|
||||
VDialogCloseBtn: {
|
||||
props: ['innerClass'],
|
||||
template: '<button type="button" :class="innerClass"><slot /></button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const searchPanel = container.querySelector('.media-id-selector__search')
|
||||
const closeButton = container.querySelector('.media-id-selector__close')
|
||||
|
||||
expect(container.querySelector('.media-id-selector')).toBeInTheDocument()
|
||||
expect(searchPanel).toBeInstanceOf(HTMLElement)
|
||||
expect(closeButton).toBeInstanceOf(HTMLButtonElement)
|
||||
expect(container.querySelector('.v-input__details')).not.toBeInTheDocument()
|
||||
expect(closeButton).toHaveAttribute('aria-label', '关闭')
|
||||
expect(closeButton).not.toHaveClass('static')
|
||||
|
||||
const input = screen.getByPlaceholderText('输入媒体名称')
|
||||
await fireEvent.update(input, 'hello')
|
||||
await fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(await screen.findByText('Hello Mini(2019)')).toBeInTheDocument()
|
||||
|
||||
const results = container.querySelector('.media-id-selector__results')
|
||||
expect(results).toBeInstanceOf(HTMLElement)
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,17 @@ const transitionStyle = computed(() => ({
|
||||
:class="`is-${layer.role}`"
|
||||
:data-backplate-slot="layer.key"
|
||||
>
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style">
|
||||
<img
|
||||
v-if="layer.src"
|
||||
class="glass-fixed-shell-backplate__source"
|
||||
:crossorigin="layer.crossOrigin"
|
||||
:src="layer.src"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +61,17 @@ const transitionStyle = computed(() => ({
|
||||
:class="`is-${layer.role}`"
|
||||
:data-backplate-slot="layer.key"
|
||||
>
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style" />
|
||||
<div class="glass-fixed-shell-backplate__wallpaper" :style="layer.style">
|
||||
<img
|
||||
v-if="layer.src"
|
||||
class="glass-fixed-shell-backplate__source"
|
||||
:crossorigin="layer.crossOrigin"
|
||||
:src="layer.src"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -160,6 +180,16 @@ const transitionStyle = computed(() => ({
|
||||
}
|
||||
}
|
||||
|
||||
.glass-fixed-shell-backplate__source {
|
||||
position: absolute;
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
inset: 0;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glass-fixed-shell-backplate,
|
||||
.glass-fixed-shell-backplate__layer {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from '@/composables/useThemeCustomizer'
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import type {
|
||||
ThemeCustomizerGlassAppearance,
|
||||
ThemeCustomizerGlassDynamicsMode,
|
||||
ThemeCustomizerGlassQuality,
|
||||
} from '@/composables/useThemeCustomizer'
|
||||
import { useGlassMobilePresentation } from '@/composables/useGlassPresentationCapabilities'
|
||||
import { usePagePresentationMotion } from '@/composables/usePagePresentationMotion'
|
||||
import {
|
||||
@@ -18,6 +23,8 @@ const props = defineProps<{
|
||||
deformationStrength: number
|
||||
/** 用户选择的轨迹、尾波与惯性强度。 */
|
||||
flowStrength: number
|
||||
/** 用户保存的动态效果模式;能力降级不会回写该选择。 */
|
||||
dynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||
/** 当前光学质量;标准档不会挂载该组件。 */
|
||||
quality: Exclude<ThemeCustomizerGlassQuality, 'css'>
|
||||
/** 用户选择的亮边、镜面高光与焦散强度。 */
|
||||
@@ -74,7 +81,16 @@ const emit = defineEmits<{
|
||||
const fixedCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const scrollCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const usesMobilePresentation = useGlassMobilePresentation()
|
||||
const dynamicsActive = computed(() => !usesMobilePresentation.value)
|
||||
const preferredMotion = usePreferredReducedMotion()
|
||||
const compositeFailureLatched = ref(false)
|
||||
const compositeRecoveryPending = ref(false)
|
||||
const presentationMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
|
||||
usesMobilePresentation.value || preferredMotion.value === 'reduce' ? 'off' : props.dynamicsMode,
|
||||
)
|
||||
const effectiveDynamicsMode = computed<ThemeCustomizerGlassDynamicsMode>(() =>
|
||||
compositeFailureLatched.value ? 'off' : presentationMode.value,
|
||||
)
|
||||
const dynamicsActive = computed(() => effectiveDynamicsMode.value !== 'off')
|
||||
const interactionSource = useGlassOpticalInteractionSource(dynamicsActive)
|
||||
const pagePresentationMotion = usePagePresentationMotion()
|
||||
const wallpaperSourceCache = createGlassWallpaperSourceCache()
|
||||
@@ -84,6 +100,7 @@ const fixedRenderer = useGlassOpticalRenderer({
|
||||
canvas: fixedCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
dynamicsActive,
|
||||
dynamicsMode: effectiveDynamicsMode,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
quality: () => props.quality,
|
||||
@@ -109,6 +126,7 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
canvas: scrollCanvas,
|
||||
deformationStrength: () => props.deformationStrength,
|
||||
dynamicsActive,
|
||||
dynamicsMode: effectiveDynamicsMode,
|
||||
flowStrength: () => props.flowStrength,
|
||||
interactionSource,
|
||||
pageMotion: pagePresentationMotion.reader,
|
||||
@@ -130,16 +148,45 @@ const scrollRenderer = useGlassOpticalRenderer({
|
||||
syncDocumentState: false,
|
||||
})
|
||||
|
||||
/** 用户显式改选动态策略时,用同一代次重建两个已进入复合回退的 renderer。 */
|
||||
function retryCompositeRenderers() {
|
||||
compositeRecoveryPending.value = true
|
||||
compositeFailureLatched.value = false
|
||||
void Promise.allSettled([fixedRenderer.retryAfterFailure(), scrollRenderer.retryAfterFailure()])
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.dynamicsMode,
|
||||
(mode, previousMode) => {
|
||||
if (mode === previousMode || (!compositeFailureLatched.value && !compositeRecoveryPending.value)) return
|
||||
|
||||
retryCompositeRenderers()
|
||||
},
|
||||
)
|
||||
|
||||
const rendererState = ref<GlassRendererState>('loading')
|
||||
|
||||
/** 两个呈现 context 作为同一材质能力接管 CSS,避免部分就绪时出现混合材质。 */
|
||||
watchEffect(() => {
|
||||
const states = [fixedRenderer.state.value, scrollRenderer.state.value]
|
||||
const state: GlassRendererState = states.every(value => value === 'ready')
|
||||
? 'ready'
|
||||
: states.some(value => value === 'loading')
|
||||
? 'loading'
|
||||
: 'fallback'
|
||||
const allReady = states.every(value => value === 'ready')
|
||||
const anyFallback = states.some(value => value === 'fallback')
|
||||
const anyLoading = states.some(value => value === 'loading')
|
||||
if (compositeRecoveryPending.value) {
|
||||
if (allReady) compositeRecoveryPending.value = false
|
||||
else if (anyFallback && !anyLoading) {
|
||||
compositeRecoveryPending.value = false
|
||||
compositeFailureLatched.value = true
|
||||
}
|
||||
} else if (anyFallback) compositeFailureLatched.value = true
|
||||
else if (compositeFailureLatched.value && allReady) compositeFailureLatched.value = false
|
||||
const state: GlassRendererState = compositeRecoveryPending.value
|
||||
? 'loading'
|
||||
: compositeFailureLatched.value
|
||||
? 'fallback'
|
||||
: allReady
|
||||
? 'ready'
|
||||
: 'loading'
|
||||
|
||||
setGlassRendererState(rendererState, state)
|
||||
if (import.meta.env.DEV && timingWindow.__glassPerformanceProbeEnabled) {
|
||||
@@ -151,6 +198,11 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.dataset.glassDynamicsMode = props.dynamicsMode
|
||||
document.documentElement.dataset.glassDynamicsEffectiveMode = effectiveDynamicsMode.value
|
||||
})
|
||||
|
||||
let lastPreparedAcknowledgement = ''
|
||||
watchEffect(() => {
|
||||
const url = props.pendingWallpaperUrl
|
||||
@@ -287,6 +339,8 @@ watchEffect(() => {
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (activationFrame !== null) cancelAnimationFrame(activationFrame)
|
||||
delete document.documentElement.dataset.glassDynamicsMode
|
||||
delete document.documentElement.dataset.glassDynamicsEffectiveMode
|
||||
setGlassRendererState(rendererState, 'fallback')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -143,6 +143,7 @@ const hasAppModeCustomization = computed(() => {
|
||||
settings.value.primaryColor !== defaultPrimaryColor ||
|
||||
settings.value.glassAppearance !== defaultAppModeGlassSettings.glassAppearance ||
|
||||
settings.value.glassDeformationStrength !== defaultAppModeGlassSettings.glassDeformationStrength ||
|
||||
settings.value.glassDynamicsMode !== defaultAppModeGlassSettings.glassDynamicsMode ||
|
||||
settings.value.glassFlowStrength !== defaultAppModeGlassSettings.glassFlowStrength ||
|
||||
settings.value.glassQuality !== defaultAppModeGlassSettings.glassQuality ||
|
||||
settings.value.glassReflectionStrength !== defaultAppModeGlassSettings.glassReflectionStrength ||
|
||||
|
||||
@@ -7,8 +7,9 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
||||
{
|
||||
key: 'front',
|
||||
role: 'active',
|
||||
crossOrigin: 'anonymous',
|
||||
src: '/wallpaper-current.jpg',
|
||||
style: {
|
||||
'backgroundImage': 'url(/wallpaper-current.jpg)',
|
||||
'--glass-wallpaper-brightness': '0.82',
|
||||
},
|
||||
url: '/wallpaper-current.jpg',
|
||||
@@ -16,8 +17,9 @@ const initialLayers: readonly GlassFixedShellBackplateLayer[] = [
|
||||
{
|
||||
key: 'back',
|
||||
role: 'standby',
|
||||
crossOrigin: 'anonymous',
|
||||
src: '/wallpaper-next.jpg',
|
||||
style: {
|
||||
'backgroundImage': 'url(/wallpaper-next.jpg)',
|
||||
'--glass-wallpaper-brightness': '0.76',
|
||||
},
|
||||
url: '/wallpaper-next.jpg',
|
||||
@@ -38,9 +40,10 @@ describe('GlassFixedShellBackplate', () => {
|
||||
expect(wrapper.findAll('[data-backplate-surface="main"] [data-backplate-slot]')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-backplate-slot="front"]').classes()).toContain('is-active')
|
||||
expect(wrapper.find('[data-backplate-slot="back"]').classes()).toContain('is-standby')
|
||||
expect(
|
||||
(wrapper.find('[data-backplate-slot="front"] > div').element as HTMLElement).style.backgroundImage,
|
||||
).toContain('/wallpaper-current.jpg')
|
||||
expect(wrapper.find('[data-backplate-slot="front"] img').attributes()).toMatchObject({
|
||||
crossorigin: 'anonymous',
|
||||
src: '/wallpaper-current.jpg',
|
||||
})
|
||||
expect(wrapper.find('[data-backplate-surface="main"]').attributes('style')).toContain(
|
||||
'--glass-fixed-shell-transition-duration: 1500ms',
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import GlassOpticalLayer from '@/components/theme/GlassOpticalLayer.vue'
|
||||
|
||||
const rendererCalls = vi.hoisted(() => [] as Array<Record<string, unknown>>)
|
||||
const rendererInitialStates = vi.hoisted(() => [] as string[])
|
||||
const rendererResults = vi.hoisted(
|
||||
() =>
|
||||
[] as Array<{
|
||||
@@ -19,6 +20,7 @@ const rendererResults = vi.hoisted(
|
||||
preparedWallpaperRevision: { value: number }
|
||||
preparedWallpaperUrl: { value: string }
|
||||
renderedFrames: { value: number }
|
||||
retryAfterFailure: ReturnType<typeof vi.fn>
|
||||
rollbackPreparedWallpaperActivation: ReturnType<typeof vi.fn>
|
||||
state: { value: string }
|
||||
}>,
|
||||
@@ -61,7 +63,12 @@ vi.mock('@/composables/useGlassOpticalRenderer', () => ({
|
||||
preparedWallpaperRevision: ref(0),
|
||||
preparedWallpaperUrl: ref(''),
|
||||
renderedFrames: ref(0),
|
||||
state: ref('ready'),
|
||||
state: ref(rendererInitialStates.shift() ?? 'ready'),
|
||||
retryAfterFailure: vi.fn(() => {
|
||||
result.state.value = 'loading'
|
||||
|
||||
return Promise.resolve()
|
||||
}),
|
||||
canActivatePreparedWallpaper: vi.fn((url: string, revision: number, preparationKey: string) => {
|
||||
return (
|
||||
result.state.value === 'ready' &&
|
||||
@@ -114,6 +121,7 @@ vi.mock('@/composables/useGlassPresentationCapabilities', async () => {
|
||||
|
||||
afterEach(() => {
|
||||
mobilePresentationState.current!.value = false
|
||||
rendererInitialStates.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
@@ -126,6 +134,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
@@ -146,6 +155,7 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(canvases.map(canvas => canvas.attributes('data-presentation-space'))).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.map(options => options.surfaceSpace)).toEqual(['fixed', 'scroll'])
|
||||
expect(rendererCalls.every(options => options.interactionSource === interactionSource)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'fluid')).toBe(true)
|
||||
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)
|
||||
@@ -161,6 +171,37 @@ describe('GlassOpticalLayer', () => {
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
})
|
||||
|
||||
it('does not latch a composite failure while both contexts are initially loading', () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
rendererInitialStates.push('loading', 'loading')
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
|
||||
expect(setRendererState).toHaveBeenCalledWith(expect.any(Object), 'loading')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps both material contexts while disabling dynamics on mobile presentations', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
@@ -169,6 +210,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'high',
|
||||
@@ -186,11 +228,15 @@ describe('GlassOpticalLayer', () => {
|
||||
|
||||
expect(wrapper.findAll('canvas')).toHaveLength(2)
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
|
||||
mobilePresentationState.current!.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -210,6 +256,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 0,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 7,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -267,6 +314,7 @@ describe('GlassOpticalLayer', () => {
|
||||
props: {
|
||||
appearance: 'frosted',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 10,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -322,6 +370,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 8,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 8,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
@@ -348,13 +397,34 @@ describe('GlassOpticalLayer', () => {
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsMode).toBe('ripple')
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
expect(fixedRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(scrollRenderer.activatePreparedWallpaper).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('wallpaperActivated')).toBeUndefined()
|
||||
|
||||
scrollRenderer.state.value = 'loading'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'off')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
|
||||
fixedRenderer.state.value = 'loading'
|
||||
scrollRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => !(options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(requestFrame).not.toHaveBeenCalled()
|
||||
|
||||
fixedRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(rendererCalls.every(options => (options.dynamicsActive as { value: boolean }).value)).toBe(true)
|
||||
expect(rendererCalls.every(options => (options.dynamicsMode as { value: string }).value === 'ripple')).toBe(true)
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('ripple')
|
||||
expect(requestFrame).toHaveBeenCalledOnce()
|
||||
;(activationCallback as FrameRequestCallback | null)?.(640)
|
||||
await nextTick()
|
||||
@@ -365,6 +435,93 @@ describe('GlassOpticalLayer', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('retries both contexts when the requested dynamics mode changes after a composite failure', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
|
||||
await wrapper.setProps({ dynamicsMode: 'fluid' })
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
|
||||
|
||||
fixedRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'loading')
|
||||
|
||||
scrollRenderer.state.value = 'ready'
|
||||
await nextTick()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('fluid')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'ready')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the composite fallback without retrying in a loop when explicit recovery fails', async () => {
|
||||
rendererCalls.length = 0
|
||||
rendererResults.length = 0
|
||||
const wrapper = shallowMount(GlassOpticalLayer, {
|
||||
props: {
|
||||
appearance: 'clear',
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'ripple',
|
||||
flowStrength: 50,
|
||||
previousWallpaperUrl: '',
|
||||
quality: 'balanced',
|
||||
reflectionStrength: 50,
|
||||
routeKey: '/dashboard',
|
||||
tintColor: '#8D51F9',
|
||||
transitionDuration: 1500,
|
||||
transitionStartedAt: 0,
|
||||
transmissionStrength: 50,
|
||||
translationStrength: 50,
|
||||
transparencyStrength: 50,
|
||||
wallpaperUrl: '/wallpaper.jpg',
|
||||
},
|
||||
})
|
||||
const [fixedRenderer, scrollRenderer] = rendererResults
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.setProps({ dynamicsMode: 'fluid' })
|
||||
await nextTick()
|
||||
fixedRenderer.state.value = 'ready'
|
||||
scrollRenderer.state.value = 'fallback'
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(fixedRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(scrollRenderer.retryAfterFailure).toHaveBeenCalledOnce()
|
||||
expect(document.documentElement.dataset.glassDynamicsEffectiveMode).toBe('off')
|
||||
expect(setRendererState).toHaveBeenLastCalledWith(expect.any(Object), 'fallback')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'returns false',
|
||||
@@ -392,6 +549,7 @@ describe('GlassOpticalLayer', () => {
|
||||
appearance: 'frosted',
|
||||
activateWallpaperRevision: 9,
|
||||
deformationStrength: 50,
|
||||
dynamicsMode: 'fluid',
|
||||
flowStrength: 50,
|
||||
pendingWallpaperRevision: 9,
|
||||
pendingWallpaperUrl: '/wallpaper-next.jpg',
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { actionStepDict } from '@/api/constants'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface WorkflowSummaryAction {
|
||||
data?: {
|
||||
label?: string
|
||||
}
|
||||
id?: number | string
|
||||
name?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface WorkflowSummaryFlow {
|
||||
source?: number | string
|
||||
target?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
actions?: WorkflowSummaryAction[]
|
||||
flows?: WorkflowSummaryFlow[]
|
||||
}>(),
|
||||
{
|
||||
actions: () => [],
|
||||
flows: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const maxVisibleActions = 4
|
||||
|
||||
const actionIconMap: Record<string, string> = {
|
||||
AddDownloadAction: 'mdi-download',
|
||||
AddSubscribeAction: 'mdi-star-plus',
|
||||
FetchDownloadsAction: 'mdi-progress-download',
|
||||
FetchMediasAction: 'mdi-movie-search',
|
||||
FetchRssAction: 'mdi-rss',
|
||||
FetchTorrentsAction: 'mdi-search-web',
|
||||
FilterMediasAction: 'mdi-filter-check',
|
||||
FilterTorrentsAction: 'mdi-filter-multiple',
|
||||
InvokePluginAction: 'mdi-run',
|
||||
NoteAction: 'mdi-note-text',
|
||||
ScanFileAction: 'mdi-folder-search',
|
||||
ScrapeFileAction: 'mdi-file-find',
|
||||
SendEventAction: 'mdi-send-check',
|
||||
SendMessageAction: 'mdi-message-arrow-right',
|
||||
TransferFileAction: 'mdi-file-move',
|
||||
}
|
||||
|
||||
const normalizedActions = computed(() => props.actions.filter(action => action && typeof action === 'object'))
|
||||
const normalizedFlows = computed(() => props.flows.filter(flow => flow && typeof flow === 'object'))
|
||||
|
||||
function getActionKey(action: WorkflowSummaryAction, index: number) {
|
||||
return action.id == null ? `action-${index}` : String(action.id)
|
||||
}
|
||||
|
||||
// Shared workflows are normally DAGs. Stable topological sorting keeps the summary useful
|
||||
// even when stored node coordinates or array order no longer reflect the execution path.
|
||||
const orderedActions = computed(() => {
|
||||
const actions = normalizedActions.value
|
||||
const actionRecords = actions.map((action, index) => ({ action, key: getActionKey(action, index) }))
|
||||
const actionById = new Map(actionRecords.map(({ action, key }) => [key, action]))
|
||||
const actionIndex = new Map(actionRecords.map(({ key }, index) => [key, index]))
|
||||
const indegree = new Map(actionRecords.map(({ key }) => [key, 0]))
|
||||
const targetsBySource = new Map<string, string[]>()
|
||||
|
||||
normalizedFlows.value.forEach(flow => {
|
||||
const source = String(flow.source)
|
||||
const target = String(flow.target)
|
||||
if (!actionById.has(source) || !actionById.has(target) || source === target) return
|
||||
|
||||
const targets = targetsBySource.get(source) ?? []
|
||||
if (targets.includes(target)) return
|
||||
|
||||
targets.push(target)
|
||||
targetsBySource.set(source, targets)
|
||||
indegree.set(target, (indegree.get(target) ?? 0) + 1)
|
||||
})
|
||||
|
||||
const queue = actionRecords.filter(({ key }) => (indegree.get(key) ?? 0) === 0).map(({ key }) => key)
|
||||
const result: WorkflowSummaryAction[] = []
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (queue.length) {
|
||||
queue.sort((left, right) => (actionIndex.get(left) ?? 0) - (actionIndex.get(right) ?? 0))
|
||||
const actionId = queue.shift()!
|
||||
if (visited.has(actionId)) continue
|
||||
|
||||
visited.add(actionId)
|
||||
result.push(actionById.get(actionId)!)
|
||||
|
||||
const targets = targetsBySource.get(actionId) ?? []
|
||||
targets.forEach(targetId => {
|
||||
indegree.set(targetId, (indegree.get(targetId) ?? 0) - 1)
|
||||
if (indegree.get(targetId) === 0) queue.push(targetId)
|
||||
})
|
||||
}
|
||||
|
||||
// Preserve malformed or cyclic nodes instead of silently hiding them from the summary.
|
||||
actionRecords.forEach(({ action, key }) => {
|
||||
if (!visited.has(key)) result.push(action)
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const visibleActions = computed(() => orderedActions.value.slice(0, maxVisibleActions))
|
||||
const hiddenActionCount = computed(() => Math.max(orderedActions.value.length - visibleActions.value.length, 0))
|
||||
const summaryItemCount = computed(() => visibleActions.value.length + (hiddenActionCount.value ? 1 : 0))
|
||||
|
||||
const hasBranches = computed(() => {
|
||||
const sourceCounts = new Map<string, number>()
|
||||
const targetCounts = new Map<string, number>()
|
||||
|
||||
normalizedFlows.value.forEach(flow => {
|
||||
const source = String(flow.source)
|
||||
const target = String(flow.target)
|
||||
sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1)
|
||||
targetCounts.set(target, (targetCounts.get(target) ?? 0) + 1)
|
||||
})
|
||||
|
||||
return [...sourceCounts.values(), ...targetCounts.values()].some(count => count > 1)
|
||||
})
|
||||
|
||||
function getActionIcon(type?: string) {
|
||||
return (type && actionIconMap[type]) || 'mdi-puzzle-outline'
|
||||
}
|
||||
|
||||
function getActionLabel(action: WorkflowSummaryAction) {
|
||||
const name = action.name || action.data?.label
|
||||
if (name) return actionStepDict[name] || name
|
||||
return action.type?.replace(/Action$/, '') || t('workflow.preview.unknownAction')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="workflow-summary-preview"
|
||||
:aria-label="t('workflow.preview.title')"
|
||||
:style="{ '--workflow-summary-item-count': Math.max(summaryItemCount, 1) }"
|
||||
>
|
||||
<header class="workflow-summary-preview__header">
|
||||
<span class="workflow-summary-preview__heading">
|
||||
<VIcon icon="mdi-source-branch" size="18" />
|
||||
{{ t('workflow.preview.title') }}
|
||||
</span>
|
||||
<span class="workflow-summary-preview__action-count">
|
||||
{{ t('workflow.preview.actionCount', { count: orderedActions.length }) }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="visibleActions.length" class="workflow-summary-preview__steps" role="list">
|
||||
<div
|
||||
v-for="(action, index) in visibleActions"
|
||||
:key="getActionKey(action, index)"
|
||||
class="workflow-summary-preview__step"
|
||||
role="listitem"
|
||||
>
|
||||
<span class="workflow-summary-preview__step-icon">
|
||||
<VIcon :icon="getActionIcon(action.type)" size="17" />
|
||||
</span>
|
||||
<span class="workflow-summary-preview__step-name" :title="getActionLabel(action)">
|
||||
{{ getActionLabel(action) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hiddenActionCount" class="workflow-summary-preview__step" role="listitem">
|
||||
<span class="workflow-summary-preview__step-icon workflow-summary-preview__step-icon--more">
|
||||
<VIcon icon="mdi-dots-horizontal" size="18" />
|
||||
</span>
|
||||
<span class="workflow-summary-preview__step-name">
|
||||
{{ t('workflow.preview.moreActions', { count: hiddenActionCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="workflow-summary-preview__empty">
|
||||
<VIcon icon="mdi-vector-polyline-remove" size="24" />
|
||||
<span>{{ t('workflow.task.info.noActions') }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="workflow-summary-preview__footer">
|
||||
<span>{{ t('workflow.preview.flowCount', { count: normalizedFlows.length }) }}</span>
|
||||
<span v-if="hasBranches" class="workflow-summary-preview__branch">
|
||||
<VIcon icon="mdi-source-branch" size="14" />
|
||||
{{ t('workflow.preview.hasBranches') }}
|
||||
</span>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.workflow-summary-preview {
|
||||
--workflow-summary-line: rgba(var(--v-theme-on-surface), 0.16);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.875rem;
|
||||
min-block-size: 9rem;
|
||||
padding: 0.875rem;
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-on-surface), 0.035);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
||||
inline-size: 13.5rem;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__header,
|
||||
.workflow-summary-preview__footer,
|
||||
.workflow-summary-preview__heading,
|
||||
.workflow-summary-preview__branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__header,
|
||||
.workflow-summary-preview__footer {
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__heading {
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__action-count,
|
||||
.workflow-summary-preview__footer {
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__action-count {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--workflow-summary-item-count), minmax(0, 1fr));
|
||||
align-items: start;
|
||||
min-block-size: 3.625rem;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-inline-size: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__step:not(:last-child)::after {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
border-block-start: 1px solid var(--workflow-summary-line);
|
||||
content: '';
|
||||
inline-size: calc(100% - 2rem);
|
||||
inset-block-start: 0.9375rem;
|
||||
inset-inline-start: calc(50% + 1rem);
|
||||
}
|
||||
|
||||
.workflow-summary-preview__step-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
block-size: 1.875rem;
|
||||
color: rgb(var(--v-theme-primary));
|
||||
inline-size: 1.875rem;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__step-icon--more {
|
||||
background: rgba(var(--v-theme-on-surface), 0.07);
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
}
|
||||
|
||||
.workflow-summary-preview__step-name {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
max-inline-size: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.625rem;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.workflow-summary-preview__branch {
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px) {
|
||||
.workflow-summary-preview {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
import WorkflowSummaryPreview from '@/components/workflow/WorkflowSummaryPreview.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('WorkflowSummaryPreview', () => {
|
||||
it('orders actions by their connections and collapses the remaining actions', async () => {
|
||||
const { container } = await renderWithProviders(WorkflowSummaryPreview, {
|
||||
props: {
|
||||
actions: [
|
||||
{ id: 'download', name: '创建下载', type: 'AddDownloadAction' },
|
||||
{ id: 'rss', name: '读取源', type: 'FetchRssAction' },
|
||||
{ id: 'filter', name: '筛选候选', type: 'FilterTorrentsAction' },
|
||||
{ id: 'notify', name: '发送提醒', type: 'SendMessageAction' },
|
||||
{ id: 'organize', name: '整理结果', type: 'TransferFileAction' },
|
||||
],
|
||||
flows: [
|
||||
{ source: 'rss', target: 'filter' },
|
||||
{ source: 'filter', target: 'download' },
|
||||
{ source: 'download', target: 'organize' },
|
||||
{ source: 'organize', target: 'notify' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('流程概览')).toBeInTheDocument()
|
||||
expect(screen.getByText('5 个动作')).toBeInTheDocument()
|
||||
expect(screen.getByText('另有 1 个')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 条连接')).toBeInTheDocument()
|
||||
|
||||
const steps = screen.getAllByRole('listitem')
|
||||
expect(steps[0]).toHaveTextContent('读取源')
|
||||
expect(steps[1]).toHaveTextContent('筛选候选')
|
||||
expect(steps[2]).toHaveTextContent('创建下载')
|
||||
expect(steps[3]).toHaveTextContent('整理结果')
|
||||
expect(container.querySelector('.vue-flow')).not.toBeInTheDocument()
|
||||
expect(container.querySelectorAll('button, input, textarea, select')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks branching workflows without exposing an interactive graph', async () => {
|
||||
await renderWithProviders(WorkflowSummaryPreview, {
|
||||
props: {
|
||||
actions: [
|
||||
{ id: 'source', name: '获取媒体', type: 'FetchMediasAction' },
|
||||
{ id: 'left', name: '过滤媒体', type: 'FilterMediasAction' },
|
||||
{ id: 'right', name: '发送事件', type: 'SendEventAction' },
|
||||
],
|
||||
flows: [
|
||||
{ source: 'source', target: 'left' },
|
||||
{ source: 'source', target: 'right' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('含分支')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 条连接')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a quiet empty state when the workflow has no actions', async () => {
|
||||
const { container } = await renderWithProviders(WorkflowSummaryPreview)
|
||||
|
||||
expect(screen.getByText('暂无动作')).toBeInTheDocument()
|
||||
expect(container.querySelector('.workflow-summary-preview__steps')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_ACTIVITY_SUSPEND_DELAY_MS } from '@/utils/appActivityLifecycle'
|
||||
import type { ShaderMaterial, Vector2, WebGLRenderTarget } from 'three'
|
||||
import type { Object3D, ShaderMaterial, Vector2, WebGLRenderTarget } from 'three'
|
||||
|
||||
const wallpaperToneMocks = vi.hoisted(() => ({
|
||||
load: vi.fn(),
|
||||
@@ -294,6 +294,30 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('attaches shared input only while a dynamic presentation is active', async () => {
|
||||
const active = ref(false)
|
||||
const scrollListener = vi.fn()
|
||||
const scope = effectScope()
|
||||
scope.run(() => {
|
||||
const source = useGlassOpticalInteractionSource(active)
|
||||
source.subscribe('scroll', scrollListener)
|
||||
})
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 400, clientY: 400 }))
|
||||
expect(scrollListener).not.toHaveBeenCalled()
|
||||
|
||||
active.value = true
|
||||
await nextTick()
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 420, clientY: 420 }))
|
||||
expect(scrollListener).toHaveBeenCalledOnce()
|
||||
|
||||
active.value = false
|
||||
await nextTick()
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 440, clientY: 440 }))
|
||||
expect(scrollListener).toHaveBeenCalledOnce()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('detects a target surface added directly', () => {
|
||||
const surface = document.createElement('section')
|
||||
surface.dataset.glassOpticalSurface = ''
|
||||
@@ -2269,6 +2293,90 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clears an expired ripple field before the first resumed frame', async () => {
|
||||
const three = await import('three')
|
||||
let visibilityState: DocumentVisibilityState = 'visible'
|
||||
let now = 0
|
||||
let interactionListener: ((event: PointerEvent | TouchEvent) => void) | null = null
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState)
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode: ref('ripple'),
|
||||
interactionSource: {
|
||||
subscribe: vi.fn((_space, listener) => {
|
||||
interactionListener = listener
|
||||
return vi.fn()
|
||||
}),
|
||||
},
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
now = 16 + pass * 16
|
||||
scheduledCallbacks.forEach(callback => callback(now))
|
||||
}
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
|
||||
;(interactionListener as ((event: PointerEvent) => void) | null)?.({
|
||||
clientX: 200,
|
||||
clientY: 180,
|
||||
pointerType: 'mouse',
|
||||
timeStamp: 100,
|
||||
type: 'pointermove',
|
||||
} as PointerEvent)
|
||||
const [interactionFrame] = callbacks.values()
|
||||
callbacks.clear()
|
||||
interactionFrame(116.667)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(1)
|
||||
|
||||
visibilityState = 'hidden'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(callbacks.size).toBe(0)
|
||||
|
||||
now = 1000
|
||||
visibilityState = 'visible'
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
await vi.waitFor(() => expect(uniforms.uHasRippleTexture.value).toBe(0))
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
for (let pass = 0; pass < 8 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
now = 1016 + pass * 16
|
||||
scheduledCallbacks.forEach(callback => callback(now))
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
}
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
surface.remove()
|
||||
})
|
||||
|
||||
it('coalesces visibility, focus, and pageshow into one renderer resume', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
@@ -2556,6 +2664,442 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps off mode static, unsubscribed and reversible without changing material rendering', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const unsubscribe = vi.fn()
|
||||
const interactionSource = {
|
||||
subscribe: vi.fn(() => unsubscribe),
|
||||
}
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength: ref(80),
|
||||
dynamicsMode,
|
||||
flowStrength: ref(80),
|
||||
interactionSource,
|
||||
quality: ref('high'),
|
||||
reflectionStrength: ref(80),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
transmissionStrength: ref(80),
|
||||
translationStrength: ref(80),
|
||||
transparencyStrength: ref(80),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(interactionSource.subscribe).not.toHaveBeenCalled()
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(
|
||||
call =>
|
||||
call[0] as unknown as {
|
||||
children?: Array<{ material?: { uniforms?: Record<string, { value: unknown }> } }>
|
||||
},
|
||||
)
|
||||
.find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode)
|
||||
const uniforms = mainScene?.children?.[0]?.material?.uniforms
|
||||
expect(uniforms?.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms?.uTranslationStrength.value).toBe(0)
|
||||
expect(uniforms?.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms?.uFlowStrength.value).toBe(0)
|
||||
expect(uniforms?.uTrailCount.value).toBe(0)
|
||||
expect(uniforms?.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms?.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms?.uReflectionStrength.value).toBeGreaterThan(0)
|
||||
|
||||
const renderedFrames = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 300, clientY: 300 }))
|
||||
dispatchTouchEvent('touchstart', [{ clientX: 300, clientY: 300, identifier: 7 }])
|
||||
dispatchTouchEvent('touchmove', [{ clientX: 320, clientY: 320, identifier: 7 }])
|
||||
expect(renderer?.renderedFrames.value).toBe(renderedFrames)
|
||||
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => expect(interactionSource.subscribe).toHaveBeenCalledOnce())
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
|
||||
expect(interactionSource.subscribe).toHaveBeenCalledOnce()
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('preserves the baseline first fluid velocity but suppresses the first velocity after a mode reset', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
const surface = appendOpticalSurface('app-hover-lift-card', { height: 320, width: 520, x: 40, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
expect(Math.hypot(uniforms.uPointerVelocity.value.x, uniforms.uPointerVelocity.value.y)).toBeGreaterThan(0)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(2))
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(0))
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 260, clientY: 180 }))
|
||||
expect(uniforms.uPointerVelocity.value).toMatchObject({ x: 0, y: 0 })
|
||||
scope.stop()
|
||||
surface.remove()
|
||||
})
|
||||
|
||||
it('allocates only the selected temporal field and releases it on every mode change', async () => {
|
||||
const three = await import('three')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
expect(baselineCompileCalls).toBeGreaterThan(0)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls)
|
||||
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 4)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 6))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('reinitializes a disposed fallback once under the latest mode when explicitly retried', async () => {
|
||||
const three = await import('three')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockRejectedValueOnce(new Error('ripple unavailable'))
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('fallback'))
|
||||
|
||||
dynamicsMode.value = 'fluid'
|
||||
await nextTick()
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
|
||||
await renderer?.retryAfterFailure()
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 2)
|
||||
expect(warn).toHaveBeenCalledWith('玻璃动态策略切换失败,已回退标准材质:', expect.any(Error))
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('disposes a late ripple compilation result after a newer off selection wins', async () => {
|
||||
const three = await import('three')
|
||||
let finishCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishCompilation = resolve
|
||||
}),
|
||||
)
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(
|
||||
call =>
|
||||
call[0] as unknown as {
|
||||
children?: Array<{ material?: { uniforms?: Record<string, { value: unknown }> } }>
|
||||
},
|
||||
)
|
||||
.find(scene => scene.children?.[0]?.material?.uniforms?.uDynamicsMode)
|
||||
const uniforms = mainScene?.children?.[0]?.material?.uniforms
|
||||
expect(uniforms?.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms?.uRippleTexture.value).toBeNull()
|
||||
expect(uniforms?.uHasRippleTexture.value).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores a late ripple compilation failure after a newer off selection wins', async () => {
|
||||
const three = await import('three')
|
||||
let rejectCompilation: ((error: Error) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode,
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const baselineCompileCalls = compileAsync.mock.calls.length
|
||||
compileAsync.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<never>((_, reject) => {
|
||||
rejectCompilation = reject
|
||||
}),
|
||||
)
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledTimes(baselineCompileCalls + 1))
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
;(rejectCompilation as ((error: Error) => void) | null)?.(new Error('stale ripple compile failed'))
|
||||
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('does not attach renderer observers or events after initial ripple compilation outlives its scope', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let finishCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishCompilation = resolve
|
||||
}),
|
||||
)
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
||||
calls.filter(([event]) => String(event) === eventName).length
|
||||
const scope = effectScope()
|
||||
scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce())
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
|
||||
scope.stop()
|
||||
;(finishCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
expect(ResizeObserverMock.instances).toHaveLength(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(0)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextrestored')).toBe(1)
|
||||
})
|
||||
|
||||
it('recovers once when context loss occurs during initial ripple compilation', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
let finishFirstCompilation: ((result: Object3D) => void) | null = null
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Object3D>(resolve => {
|
||||
finishFirstCompilation = resolve
|
||||
}),
|
||||
)
|
||||
const disposeTarget = vi.spyOn(three.WebGLRenderTarget.prototype, 'dispose')
|
||||
const addWindowListener = vi.spyOn(window, 'addEventListener')
|
||||
const addCanvasListener = vi.spyOn(canvas, 'addEventListener')
|
||||
const countListenerAdds = (calls: readonly (readonly unknown[])[], eventName: string) =>
|
||||
calls.filter(([event]) => String(event) === eventName).length
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(compileAsync).toHaveBeenCalledOnce())
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(1)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(0)
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(compileAsync).toHaveBeenCalledTimes(3)
|
||||
const listenerCountsBeforeLateResult = {
|
||||
contextLost: countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost'),
|
||||
pointerMove: countListenerAdds(addWindowListener.mock.calls, 'pointermove'),
|
||||
resize: countListenerAdds(addWindowListener.mock.calls, 'resize'),
|
||||
}
|
||||
const baselineDisposeCalls = disposeTarget.mock.calls.length
|
||||
|
||||
;(finishFirstCompilation as ((result: Object3D) => void) | null)?.({} as Object3D)
|
||||
await vi.waitFor(() => expect(disposeTarget).toHaveBeenCalledTimes(baselineDisposeCalls + 2))
|
||||
|
||||
expect(renderer?.state.value).toBe('ready')
|
||||
expect(ResizeObserverMock.instances).toHaveLength(1)
|
||||
expect(countListenerAdds(addCanvasListener.mock.calls, 'webglcontextlost')).toBe(
|
||||
listenerCountsBeforeLateResult.contextLost,
|
||||
)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'pointermove')).toBe(
|
||||
listenerCountsBeforeLateResult.pointerMove,
|
||||
)
|
||||
expect(countListenerAdds(addWindowListener.mock.calls, 'resize')).toBe(listenerCountsBeforeLateResult.resize)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('restores the latest off mode after an active ripple renderer loses context', async () => {
|
||||
const three = await import('three')
|
||||
const canvas = document.createElement('canvas')
|
||||
const compileAsync = vi.spyOn(three.WebGLRenderer.prototype, 'compileAsync')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const unsubscribe = vi.fn()
|
||||
const subscribe = vi.fn(() => unsubscribe)
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('ripple')
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(canvas),
|
||||
dynamicsMode,
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
const compileCallsBeforeLoss = compileAsync.mock.calls.length
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextlost', { cancelable: true }))
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
await nextTick()
|
||||
await renderer?.retryAfterFailure()
|
||||
expect(renderer?.state.value).toBe('fallback')
|
||||
expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss)
|
||||
|
||||
canvas.dispatchEvent(new Event('webglcontextrestored'))
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
|
||||
expect(compileAsync).toHaveBeenCalledTimes(compileCallsBeforeLoss + 1)
|
||||
expect(subscribe).toHaveBeenCalledOnce()
|
||||
const mainScene = [...render.mock.calls]
|
||||
.reverse()
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
const framesBeforePointer = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
await nextTick()
|
||||
expect(renderer?.renderedFrames.value).toBe(framesBeforePointer)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps scroll-space surface geometry stable while updating the visible viewport offset', async () => {
|
||||
stubMediaPreferences({ coarsePointer: true })
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(390)
|
||||
@@ -2794,6 +3338,67 @@ describe('glass optical surface discovery', () => {
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('clears ripple state before native scroll presentation takes ownership', 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 callbacks = new Map<number, FrameRequestCallback>()
|
||||
let frameId = 0
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frameId += 1
|
||||
callbacks.set(frameId, callback)
|
||||
|
||||
return frameId
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => callbacks.delete(id))
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 300, width: 400, x: 40, y: 120 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
dynamicsMode: ref('ripple'),
|
||||
quality: ref('balanced'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
wallpaperUrl: ref('/api/v1/login/wallpapers/opaque-id'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
for (let pass = 0; pass < 4 && callbacks.size > 0; pass += 1) {
|
||||
const scheduledCallbacks = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
scheduledCallbacks.forEach(callback => callback(performance.now() + pass * 16))
|
||||
}
|
||||
const mainScene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(scene => scene.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!mainScene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = mainScene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(1)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 180 }))
|
||||
expect(callbacks.size).toBe(1)
|
||||
const rippleFrames = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
rippleFrames.forEach(callback => callback(performance.now() + 16))
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(1)
|
||||
expect(callbacks.size).toBe(1)
|
||||
|
||||
window.dispatchEvent(new WheelEvent('wheel', { deltaY: 80 }))
|
||||
|
||||
expect(callbacks.size).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uRippleTexture.value).toBeNull()
|
||||
expect(document.documentElement.dataset.glassScrollPresentation).toBe('native')
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('ignores scroll intent and movement that cannot move a managed glass surface', async () => {
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1200)
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800)
|
||||
@@ -3516,6 +4121,15 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uTranslationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDeformationStrength')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uDynamicsOnly')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float rippleGradientEnergy = smoothstep(0.003, 0.08, rippleGradientLength)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'rippleGradient * mix(230.0, 335.0, uQuality) * uRippleDeformationStrength',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'uAppearance > 1.5 ? 1.25 : (uAppearance > 0.5 ? 0.86 : 0.72)',
|
||||
)
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float sharedWaveDensity = mix(2.81, 1.63')
|
||||
expect(scene.children[0].material.fragmentShader).toContain(
|
||||
'float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan)',
|
||||
@@ -3535,8 +4149,8 @@ describe('glass optical surface discovery', () => {
|
||||
expect(scene.children[0].material.fragmentShader).toContain('uniform float uReflectionStrength')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('uWakeProgress')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain('temporalEnergy')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeScale = 0.40')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeDensity = 6.250')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeScale = 0.52')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('const float dynamicRangeDensity = 3.698')
|
||||
expect(scene.children[0].material.fragmentShader).toContain('float pointerSpread = mix(26.0, 17.0, uQuality)')
|
||||
expect(scene.children[0].material.fragmentShader).not.toContain(
|
||||
'mix(mix(26.0, 17.0, uQuality), mix(12.0, 8.0, uQuality), frosted)',
|
||||
@@ -3750,4 +4364,122 @@ describe('glass optical surface discovery', () => {
|
||||
expect(callbacks.size).toBe(0)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps static material rendering while off owns no interaction subscription or dynamic output', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const subscribe = vi.fn(() => vi.fn())
|
||||
const deformationStrength = ref(80)
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('off')
|
||||
const reflectionStrength = ref(40)
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength,
|
||||
dynamicsMode,
|
||||
flowStrength: ref(80),
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('high'),
|
||||
reflectionStrength,
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength: ref(80),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = scene.children[0].material!.uniforms
|
||||
|
||||
expect(subscribe).not.toHaveBeenCalled()
|
||||
expect(uniforms.uDynamicsMode.value).toBe(2)
|
||||
expect(uniforms.uTranslationStrength.value).toBe(0)
|
||||
expect(uniforms.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uFlowStrength.value).toBe(0)
|
||||
expect(uniforms.uRippleDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uTrailCount.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(0)
|
||||
|
||||
const framesBeforePointer = renderer?.renderedFrames.value
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 160, clientY: 160 }))
|
||||
await nextTick()
|
||||
expect(renderer?.renderedFrames.value).toBe(framesBeforePointer)
|
||||
|
||||
deformationStrength.value = 100
|
||||
reflectionStrength.value = 100
|
||||
await nextTick()
|
||||
expect(uniforms.uDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uRippleDeformationStrength.value).toBe(0)
|
||||
expect(uniforms.uReflectionStrength.value).toBeGreaterThan(1)
|
||||
scope.stop()
|
||||
})
|
||||
|
||||
it('keeps fluid and ripple resources mutually exclusive across rapid mode switches', async () => {
|
||||
const three = await import('three')
|
||||
const render = vi.spyOn(three.WebGLRenderer.prototype, 'render')
|
||||
const subscribe = vi.fn(() => vi.fn())
|
||||
const dynamicsMode = ref<'fluid' | 'off' | 'ripple'>('fluid')
|
||||
appendOpticalSurface('app-hover-lift-card', { height: 240, width: 320, x: 20, y: 80 })
|
||||
const scope = effectScope()
|
||||
const renderer = scope.run(() =>
|
||||
useGlassOpticalRenderer({
|
||||
active: ref(true),
|
||||
appearance: ref('clear'),
|
||||
canvas: ref(document.createElement('canvas')),
|
||||
deformationStrength: ref(70),
|
||||
dynamicsMode,
|
||||
flowStrength: ref(70),
|
||||
interactionSource: { subscribe },
|
||||
quality: ref('high'),
|
||||
routeKey: ref('/dashboard'),
|
||||
surfaceSpace: 'scroll',
|
||||
tintColor: ref('#8D51F9'),
|
||||
translationStrength: ref(70),
|
||||
wallpaperUrl: ref('https://example.com/wallpaper.jpg'),
|
||||
}),
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(renderer?.state.value).toBe('ready'))
|
||||
const scene = render.mock.calls
|
||||
.map(call => call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> })
|
||||
.find(candidate => candidate.children[0]?.material?.uniforms.uDynamicsMode)
|
||||
if (!scene) throw new Error('main optical scene was not rendered')
|
||||
const uniforms = scene.children[0].material!.uniforms
|
||||
expect(uniforms.uDynamicsMode.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(1)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
|
||||
dynamicsMode.value = 'ripple'
|
||||
await vi.waitFor(() => expect(uniforms.uDynamicsMode.value).toBe(1))
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(0)
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(uniforms.uMaxRefractionPixels.value).toBeCloseTo(28)
|
||||
expect(
|
||||
render.mock.calls.some(call => {
|
||||
const rippleScene = call[0] as unknown as { children: Array<{ material?: ShaderMaterial }> }
|
||||
return rippleScene.children[0]?.material?.fragmentShader.includes('uImpulseSigma')
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
dynamicsMode.value = 'off'
|
||||
dynamicsMode.value = 'fluid'
|
||||
await vi.waitFor(() => {
|
||||
expect(uniforms.uDynamicsMode.value).toBe(0)
|
||||
expect(uniforms.uHasFlowTexture.value).toBe(1)
|
||||
})
|
||||
expect(uniforms.uHasRippleTexture.value).toBe(0)
|
||||
expect(subscribe).toHaveBeenCalled()
|
||||
scope.stop()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,20 +36,25 @@ describe('useLlmProviderDirectory', () => {
|
||||
mocks.apiGet.mockReset()
|
||||
})
|
||||
|
||||
it('仅为 OpenAI 兼容 runtime 显示 API 协议字段', async () => {
|
||||
it('只为 OpenAI 兼容 runtime 或声明 Responses 工具能力的模型显示 API 协议字段', async () => {
|
||||
mocks.apiGet.mockResolvedValue({
|
||||
success: true,
|
||||
data: [createProvider('openai', 'openai_compatible'), createProvider('deepseek', 'deepseek')],
|
||||
data: [
|
||||
createProvider('openai', 'openai_compatible'),
|
||||
createProvider('deepseek', 'deepseek'),
|
||||
createProvider('google', 'google'),
|
||||
],
|
||||
})
|
||||
|
||||
const Harness = defineComponent({
|
||||
setup() {
|
||||
const provider = ref('openai')
|
||||
const model = ref('')
|
||||
const directory = useLlmProviderDirectory({
|
||||
provider,
|
||||
apiKey: ref(''),
|
||||
baseUrl: ref(''),
|
||||
model: ref(''),
|
||||
model,
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -57,7 +62,12 @@ describe('useLlmProviderDirectory', () => {
|
||||
selectProvider: (value: string) => {
|
||||
provider.value = value
|
||||
},
|
||||
selectModel: (value: string) => {
|
||||
model.value = value
|
||||
},
|
||||
loadModels: directory.loadModels,
|
||||
showApiProtocolField: directory.showApiProtocolField,
|
||||
supportsBuiltinWebSearch: directory.supportsBuiltinWebSearch,
|
||||
}
|
||||
},
|
||||
template: '<div />',
|
||||
@@ -71,6 +81,58 @@ describe('useLlmProviderDirectory', () => {
|
||||
wrapper.vm.selectProvider('deepseek')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.showApiProtocolField).toBe(false)
|
||||
|
||||
mocks.apiGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
id: 'deepseek-v4-flash',
|
||||
name: 'deepseek-v4-flash',
|
||||
server_tools: [
|
||||
{
|
||||
id: 'web_search',
|
||||
required_api_protocol: 'responses',
|
||||
client_adapter: 'openai_responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await wrapper.vm.loadModels()
|
||||
wrapper.vm.selectModel('deepseek-v4-flash')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
|
||||
expect(wrapper.vm.showApiProtocolField).toBe(true)
|
||||
|
||||
wrapper.vm.selectProvider('google')
|
||||
await nextTick()
|
||||
mocks.apiGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-3.6-flash-preview',
|
||||
name: 'gemini-3.6-flash-preview',
|
||||
server_tools: [
|
||||
{
|
||||
id: 'web_search',
|
||||
required_api_protocol: 'native',
|
||||
client_adapter: 'google_native',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await wrapper.vm.loadModels()
|
||||
wrapper.vm.selectModel('gemini-3.6-flash-preview')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.supportsBuiltinWebSearch).toBe(true)
|
||||
expect(wrapper.vm.showApiProtocolField).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDeformationStrength).toBe(48)
|
||||
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||
expect(settings.glassFlowStrength).toBe(48)
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toEqual({})
|
||||
@@ -71,6 +72,14 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(true)
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
await customizer.setGlassDynamicsMode('off')
|
||||
expect(isDefaultThemeCustomizerSettings(customizer.settings.value)).toBe(false)
|
||||
expect(customizer.isCustomized.value).toBe(true)
|
||||
|
||||
await customizer.resetSettings()
|
||||
expect(customizer.settings.value.glassDynamicsMode).toBe('ripple')
|
||||
expect(customizer.isCustomized.value).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -78,6 +87,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(getDefaultGlassCustomizerSettings('css')).toEqual({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 48,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 48,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -101,15 +111,27 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe(glassAppearance)
|
||||
})
|
||||
|
||||
it.each(['fluid', 'ripple', 'off'] as const)('preserves the %s dynamics mode contract', glassDynamicsMode => {
|
||||
localStorage.setItem(THEME_CUSTOMIZER_STORAGE_KEY, JSON.stringify({ glassDynamicsMode }))
|
||||
|
||||
expect(readThemeCustomizerSettings().glassDynamicsMode).toBe(glassDynamicsMode)
|
||||
})
|
||||
|
||||
it('falls back when stored glass settings are invalid', () => {
|
||||
localStorage.setItem(
|
||||
THEME_CUSTOMIZER_STORAGE_KEY,
|
||||
JSON.stringify({ glassAppearance: 'opaque', glassPreset: 'elastic', glassQuality: 'ultra' }),
|
||||
JSON.stringify({
|
||||
glassAppearance: 'opaque',
|
||||
glassDynamicsMode: 'elastic',
|
||||
glassPreset: 'elastic',
|
||||
glassQuality: 'ultra',
|
||||
}),
|
||||
)
|
||||
|
||||
const settings = readThemeCustomizerSettings()
|
||||
|
||||
expect(settings.glassAppearance).toBe('clear')
|
||||
expect(settings.glassDynamicsMode).toBe('ripple')
|
||||
expect(settings.glassPreset).toBe('natural')
|
||||
expect(settings.glassPresetOverrides).toHaveProperty('clear:balanced:natural')
|
||||
expect(settings.glassQuality).toBe('balanced')
|
||||
@@ -184,9 +206,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(document.body.style.getPropertyValue('--glass-reflection')).toBe('0.42')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-transmission'))).toBeCloseTo(65 / 70)
|
||||
expect(document.body.style.getPropertyValue('--glass-transmission-brightness')).not.toBe('')
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(
|
||||
0.48,
|
||||
)
|
||||
expect(Number(document.documentElement.style.getPropertyValue('--glass-background-visibility'))).toBeCloseTo(0.48)
|
||||
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)
|
||||
@@ -195,10 +215,12 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
it('previews glass settings without persisting them', () => {
|
||||
const storedBeforePreview = localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)
|
||||
|
||||
previewGlassSettings({ glassAppearance: 'tinted' })
|
||||
previewGlassSettings({ glassAppearance: 'tinted', glassDynamicsMode: 'ripple' })
|
||||
|
||||
expect(document.documentElement.dataset.glassAppearance).toBe('tinted')
|
||||
expect(readThemeCustomizerSettings().glassAppearance).toBe('clear')
|
||||
expect(readThemeCustomizerSettings().glassDynamicsMode).toBe('ripple')
|
||||
expect(useEffectiveGlassSettings().value.glassDynamicsMode).toBe('ripple')
|
||||
expect(localStorage.getItem(THEME_CUSTOMIZER_STORAGE_KEY)).toBe(storedBeforePreview)
|
||||
})
|
||||
|
||||
@@ -208,6 +230,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -232,6 +255,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(readThemeCustomizerSettings()).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 74,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 63,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {
|
||||
@@ -288,6 +312,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
previewGlassSettings({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 88,
|
||||
glassPresetOverrides: {
|
||||
'clear:balanced:natural': {
|
||||
@@ -307,6 +332,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: 90,
|
||||
glassDynamicsMode: 'off',
|
||||
glassFlowStrength: 88,
|
||||
glassReflectionStrength: 12,
|
||||
glassTransmissionStrength: 92,
|
||||
@@ -320,6 +346,7 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
expect(effective.value).toMatchObject({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 42,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: 44,
|
||||
glassPresetOverrides: {
|
||||
'tinted:balanced:natural': {
|
||||
@@ -338,6 +365,40 @@ describe('useThemeCustomizer glass settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('switches dynamics mode without changing preset ownership or optical parameters', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'tinted',
|
||||
glassDeformationStrength: 73,
|
||||
glassFlowStrength: 61,
|
||||
glassPreset: 'glide',
|
||||
glassPresetOverrides: {
|
||||
'tinted:high:glide': {
|
||||
deformation: 73,
|
||||
flow: 61,
|
||||
reflection: 47,
|
||||
transmission: 68,
|
||||
translation: 82,
|
||||
transparency: 59,
|
||||
},
|
||||
},
|
||||
glassQuality: 'high',
|
||||
glassReflectionStrength: 47,
|
||||
glassTransmissionStrength: 68,
|
||||
glassTranslationStrength: 82,
|
||||
glassTransparencyStrength: 59,
|
||||
})
|
||||
const { customizer, wrapper } = mountThemeCustomizer()
|
||||
const before = readThemeCustomizerSettings()
|
||||
|
||||
await customizer.setGlassDynamicsMode('off')
|
||||
expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'off' })
|
||||
|
||||
await customizer.setGlassDynamicsMode('ripple')
|
||||
expect(readThemeCustomizerSettings()).toEqual({ ...before, glassDynamicsMode: 'ripple' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('applies the same preset for a new material and quality while preset-managed', async () => {
|
||||
persistPartialThemeCustomizerSettings({
|
||||
glassAppearance: 'clear',
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { ThemeCustomizerGlassAppearance, ThemeCustomizerGlassQuality } from
|
||||
import type { LoginBackgroundLayer } from '@/utils/loginPresentation'
|
||||
|
||||
export interface GlassFixedShellBackplateLayer extends LoginBackgroundLayer {
|
||||
/** 当前槽位直接采样壁纸所需的背景图与色调变量。 */
|
||||
/** 与 tone/WebGL 一致的图片请求模式;CORS 不可用时省略并使用普通图片回退。 */
|
||||
crossOrigin?: 'anonymous'
|
||||
/** 已完成首图准备、可直接进入稳定背板槽位的图片地址。 */
|
||||
src: string
|
||||
/** 当前槽位直接采样壁纸时叠加的色调变量。 */
|
||||
style: StyleValue
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,13 @@ export interface LlmModel {
|
||||
source?: string
|
||||
release_date?: string | null
|
||||
status?: string | null
|
||||
server_tools?: LlmServerToolCapability[]
|
||||
}
|
||||
|
||||
export interface LlmServerToolCapability {
|
||||
id: string
|
||||
required_api_protocol?: string
|
||||
client_adapter?: string
|
||||
}
|
||||
|
||||
export interface LlmProviderAuthSession {
|
||||
@@ -113,6 +120,10 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
const selectedModel = computed(
|
||||
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
|
||||
)
|
||||
const builtinWebSearchCapability = computed(() =>
|
||||
selectedModel.value?.server_tools?.find(tool => tool.id === 'web_search'),
|
||||
)
|
||||
const supportsBuiltinWebSearch = computed(() => Boolean(builtinWebSearchCapability.value))
|
||||
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
|
||||
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
|
||||
(selectedProvider.value?.base_url_presets || []).map(item => ({
|
||||
@@ -127,8 +138,12 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
|
||||
)
|
||||
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
|
||||
// OpenAI 兼容接口才需要选择 API 协议(Chat Completions / Responses)。
|
||||
const showApiProtocolField = computed(() => selectedProvider.value?.runtime === 'openai_compatible')
|
||||
// 通用 OpenAI 兼容入口或要求 Responses 的服务端工具需要显示协议选项。
|
||||
const showApiProtocolField = computed(
|
||||
() =>
|
||||
selectedProvider.value?.runtime === 'openai_compatible' ||
|
||||
builtinWebSearchCapability.value?.required_api_protocol === 'responses',
|
||||
)
|
||||
const hasUsableCredential = computed(() => {
|
||||
if (providerConnected.value) return true
|
||||
return Boolean(normalizeValue(options.apiKey.value))
|
||||
@@ -390,6 +405,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
|
||||
models,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
supportsBuiltinWebSearch,
|
||||
loadingProviders,
|
||||
loadingModels,
|
||||
providerConnected,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { User } from '@/api/types'
|
||||
import type { ApiResponse, User } from '@/api/types'
|
||||
|
||||
export interface WizardData {
|
||||
basic: {
|
||||
@@ -57,6 +57,7 @@ export interface WizardData {
|
||||
model: string
|
||||
thinkingLevel: string
|
||||
apiProtocol: string
|
||||
webSearchMode: string
|
||||
supportImageInput: boolean
|
||||
supportAudioInput: boolean
|
||||
supportAudioOutput: boolean
|
||||
@@ -249,6 +250,7 @@ const wizardData = ref<WizardData>({
|
||||
model: 'deepseek-chat',
|
||||
thinkingLevel: 'off',
|
||||
apiProtocol: 'auto',
|
||||
webSearchMode: 'local',
|
||||
supportImageInput: true,
|
||||
supportAudioInput: false,
|
||||
supportAudioOutput: false,
|
||||
@@ -1451,6 +1453,7 @@ export function useSetupWizard() {
|
||||
LLM_MODEL: wizardData.value.agent.model,
|
||||
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
|
||||
LLM_API_PROTOCOL: wizardData.value.agent.apiProtocol || 'auto',
|
||||
LLM_WEB_SEARCH_MODE: wizardData.value.agent.webSearchMode || 'local',
|
||||
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
|
||||
LLM_SUPPORT_AUDIO_INPUT: wizardData.value.agent.supportAudioInput,
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
|
||||
@@ -1479,7 +1482,11 @@ export function useSetupWizard() {
|
||||
AI_RECOMMEND_MAX_ITEMS: wizardData.value.agent.recommendMaxItems,
|
||||
}
|
||||
|
||||
await api.post('system/env', agentSettings)
|
||||
const response: Pick<ApiResponse<unknown>, 'success' | 'message'> = await api.post('system/env', agentSettings)
|
||||
if (!response.success) {
|
||||
$toast.error(response.message || t('setupWizard.saveAgentSettingsFailed'))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Save agent settings failed:', error)
|
||||
@@ -1567,6 +1574,7 @@ export function useSetupWizard() {
|
||||
wizardData.value.agent.model = result.data.LLM_MODEL || ''
|
||||
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
|
||||
wizardData.value.agent.apiProtocol = result.data.LLM_API_PROTOCOL || 'auto'
|
||||
wizardData.value.agent.webSearchMode = result.data.LLM_WEB_SEARCH_MODE || 'local'
|
||||
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
|
||||
wizardData.value.agent.supportAudioInput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT)
|
||||
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
|
||||
|
||||
@@ -70,6 +70,8 @@ export const themeCustomizerShadowLevels = [
|
||||
|
||||
export type ThemeCustomizerLayout = 'collapsed' | 'horizontal' | 'vertical'
|
||||
export type ThemeCustomizerGlassAppearance = 'clear' | 'frosted' | 'tinted'
|
||||
/** 玻璃动态效果的持久化选择;关闭模式仍保留用户配置的动态参数。 */
|
||||
export type ThemeCustomizerGlassDynamicsMode = 'fluid' | 'ripple' | 'off'
|
||||
export type ThemeCustomizerGlassQuality = 'balanced' | 'css' | 'high'
|
||||
export type ThemeCustomizerRadius = 'default' | 'extra' | 'large' | 'none' | 'small'
|
||||
export type ThemeCustomizerShadow = (typeof themeCustomizerShadowLevels)[number]
|
||||
@@ -79,6 +81,8 @@ export type ThemeCustomizerTheme = 'auto' | 'dark' | 'glass' | 'light' | 'purple
|
||||
export interface ThemeCustomizerSettings {
|
||||
/** 玻璃主题的材质语义,与渲染质量保持独立。 */
|
||||
glassAppearance: ThemeCustomizerGlassAppearance
|
||||
/** 玻璃动态效果模式,与六参数预设矩阵保持独立。 */
|
||||
glassDynamicsMode: ThemeCustomizerGlassDynamicsMode
|
||||
/** 局部非均匀折射与内容弯曲强度,范围 0 到 100。 */
|
||||
glassDeformationStrength: number
|
||||
/** 轨迹、尾波、惯性与收敛强度,范围 0 到 100。 */
|
||||
@@ -117,6 +121,7 @@ type VuetifyThemeApi = ReturnType<typeof useTheme>
|
||||
|
||||
const defaultPrimaryColor = themeCustomizerPrimaryColors[0].value
|
||||
const validGlassAppearances: ThemeCustomizerGlassAppearance[] = ['clear', 'tinted', 'frosted']
|
||||
const validGlassDynamicsModes: ThemeCustomizerGlassDynamicsMode[] = ['fluid', 'ripple', 'off']
|
||||
const validGlassPresets: GlassOpticalPreset[] = ['natural', 'glide', 'liquid']
|
||||
const validGlassQualities: ThemeCustomizerGlassQuality[] = ['css', 'balanced', 'high']
|
||||
const defaultGlassQuality: ThemeCustomizerGlassQuality = 'balanced'
|
||||
@@ -138,6 +143,7 @@ type DefaultGlassCustomizerSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassDynamicsMode'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
@@ -176,6 +182,7 @@ export function getDefaultGlassCustomizerSettings(
|
||||
return {
|
||||
glassAppearance: 'clear',
|
||||
glassDeformationStrength: glassParameters.deformation,
|
||||
glassDynamicsMode: 'ripple',
|
||||
glassFlowStrength: glassParameters.flow,
|
||||
glassPreset: 'natural',
|
||||
glassPresetOverrides: {},
|
||||
@@ -287,6 +294,9 @@ function normalizeThemeCustomizerSettings(
|
||||
settings.glassMotionStrength,
|
||||
fallback.glassDeformationStrength,
|
||||
),
|
||||
glassDynamicsMode: validGlassDynamicsModes.includes(settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||
? (settings.glassDynamicsMode as ThemeCustomizerGlassDynamicsMode)
|
||||
: fallback.glassDynamicsMode,
|
||||
glassFlowStrength: normalizeMigratedGlassStrength(
|
||||
settings.glassFlowStrength,
|
||||
settings.glassMotionStrength,
|
||||
@@ -368,6 +378,7 @@ type ThemeCustomizerGlassSettings = Pick<
|
||||
ThemeCustomizerSettings,
|
||||
| 'glassAppearance'
|
||||
| 'glassDeformationStrength'
|
||||
| 'glassDynamicsMode'
|
||||
| 'glassFlowStrength'
|
||||
| 'glassPreset'
|
||||
| 'glassPresetOverrides'
|
||||
@@ -382,6 +393,7 @@ const effectiveGlassSettings = computed(() => ({
|
||||
glassAppearance: glassPreviewState.value?.glassAppearance ?? settingsState.value.glassAppearance,
|
||||
glassDeformationStrength:
|
||||
glassPreviewState.value?.glassDeformationStrength ?? settingsState.value.glassDeformationStrength,
|
||||
glassDynamicsMode: glassPreviewState.value?.glassDynamicsMode ?? settingsState.value.glassDynamicsMode,
|
||||
glassFlowStrength: glassPreviewState.value?.glassFlowStrength ?? settingsState.value.glassFlowStrength,
|
||||
glassPreset: glassPreviewState.value?.glassPreset ?? settingsState.value.glassPreset,
|
||||
glassPresetOverrides: glassPreviewState.value?.glassPresetOverrides ?? settingsState.value.glassPresetOverrides,
|
||||
@@ -591,6 +603,7 @@ export function previewGlassSettings(patch: Partial<ThemeCustomizerGlassSettings
|
||||
glassPreviewState.value = {
|
||||
glassAppearance: previewSettings.glassAppearance,
|
||||
glassDeformationStrength: previewSettings.glassDeformationStrength,
|
||||
glassDynamicsMode: previewSettings.glassDynamicsMode,
|
||||
glassFlowStrength: previewSettings.glassFlowStrength,
|
||||
glassPreset: previewSettings.glassPreset,
|
||||
glassPresetOverrides: previewSettings.glassPresetOverrides,
|
||||
@@ -645,6 +658,7 @@ export function isDefaultThemeCustomizerSettings(settings: ThemeCustomizerSettin
|
||||
return (
|
||||
settings.glassAppearance === defaults.glassAppearance &&
|
||||
settings.glassDeformationStrength === defaults.glassDeformationStrength &&
|
||||
settings.glassDynamicsMode === defaults.glassDynamicsMode &&
|
||||
settings.glassFlowStrength === defaults.glassFlowStrength &&
|
||||
settings.glassPreset === defaults.glassPreset &&
|
||||
JSON.stringify(settings.glassPresetOverrides) === JSON.stringify(defaults.glassPresetOverrides) &&
|
||||
@@ -762,6 +776,11 @@ export function useThemeCustomizer() {
|
||||
return updateGlassPresetOverride({ deformation: normalizeGlassOpticalStrength(glassDeformationStrength) })
|
||||
}
|
||||
|
||||
/** 切换玻璃动态效果,不改写当前预设归属或六个具体参数。 */
|
||||
function setGlassDynamicsMode(glassDynamicsMode: ThemeCustomizerGlassDynamicsMode) {
|
||||
return updateSettings({ glassDynamicsMode })
|
||||
}
|
||||
|
||||
/** 更新玻璃轨迹、尾波与惯性强度。 */
|
||||
function setGlassFlowStrength(glassFlowStrength: number) {
|
||||
return updateGlassPresetOverride({ flow: normalizeGlassOpticalStrength(glassFlowStrength) })
|
||||
@@ -901,6 +920,7 @@ export function useThemeCustomizer() {
|
||||
resetSettings,
|
||||
setGlassAppearance,
|
||||
setGlassDeformationStrength,
|
||||
setGlassDynamicsMode,
|
||||
setGlassFlowStrength,
|
||||
setGlassPreset,
|
||||
setGlassQuality,
|
||||
|
||||
+40
-1
@@ -152,6 +152,8 @@ export default {
|
||||
glassAppearanceClear: 'Clear',
|
||||
glassAppearanceTinted: 'Tinted',
|
||||
glassAppearanceFrosted: 'Frosted',
|
||||
glassAppearanceHint:
|
||||
'Clear emphasizes wallpaper detail, Tinted adds color coverage, and Frosted uses blur diffusion for a denser glass surface.',
|
||||
glassQuality: 'Quality',
|
||||
glassQualityCss: 'Standard',
|
||||
glassQualityBalanced: 'Balanced',
|
||||
@@ -165,6 +167,14 @@ export default {
|
||||
glassPresetNatural: 'Natural',
|
||||
glassPresetGlide: 'Glide',
|
||||
glassPresetLiquid: 'Liquid',
|
||||
glassPresetHint: 'Natural stays balanced, Glide favors smooth movement, and Liquid adds deformation and inertia.',
|
||||
glassDynamicsMode: 'Motion Effect',
|
||||
glassDynamicsModeFluid: 'Fluid',
|
||||
glassDynamicsModeRipple: 'Ripple',
|
||||
glassDynamicsModeOff: 'Off',
|
||||
glassDynamicsModeFluidHint: 'Creates continuous flow and refraction that follow the pointer.',
|
||||
glassDynamicsModeRippleHint: 'Creates ripples that spread across nearby glass surfaces as the pointer moves.',
|
||||
glassDynamicsModeOffHint: 'Keeps the static material without pointer-driven motion.',
|
||||
glassMaterialTuning: 'Material',
|
||||
glassDynamicTuning: 'Motion',
|
||||
glassTranslationStrength: 'Sample Translation',
|
||||
@@ -802,6 +812,7 @@ export default {
|
||||
edit: 'Edit Task',
|
||||
editFlow: 'Edit Flow',
|
||||
share: 'Share',
|
||||
moreActions: 'More actions',
|
||||
continue: 'Continue',
|
||||
restart: 'Restart',
|
||||
run: 'Run Now',
|
||||
@@ -824,7 +835,7 @@ export default {
|
||||
running: 'Running',
|
||||
failed: 'Failed',
|
||||
paused: 'Paused',
|
||||
waiting: 'Waiting',
|
||||
waiting: 'Pending',
|
||||
},
|
||||
info: {
|
||||
trigger: 'Trigger',
|
||||
@@ -833,8 +844,15 @@ export default {
|
||||
actionCount: 'Action Count',
|
||||
runCount: 'Run Count',
|
||||
progress: 'Progress',
|
||||
actionProgress: 'Action Progress',
|
||||
error: 'Error Message',
|
||||
manualTrigger: 'Manual',
|
||||
lastExecuted: 'Last run {time}',
|
||||
neverExecuted: 'Never run',
|
||||
executionIncomplete: 'Previous run is incomplete',
|
||||
executingAction: 'Running {name}',
|
||||
preparingAction: 'Preparing action {current}',
|
||||
noActions: 'No actions',
|
||||
},
|
||||
},
|
||||
scanFile: {
|
||||
@@ -985,6 +1003,14 @@ export default {
|
||||
timer: 'Timer',
|
||||
manualTrigger: 'Manual Trigger',
|
||||
actionCount: 'Action Count',
|
||||
preview: {
|
||||
title: 'Flow Overview',
|
||||
actionCount: '{count} actions',
|
||||
flowCount: '{count} connections',
|
||||
moreActions: '{count} more',
|
||||
hasBranches: 'Branched',
|
||||
unknownAction: 'Unknown action',
|
||||
},
|
||||
normalFork: 'Fork Workflow',
|
||||
cancelShare: 'Cancel Share',
|
||||
cancelSuccess: 'Share cancelled successfully',
|
||||
@@ -1627,6 +1653,9 @@ export default {
|
||||
title: 'Meta Info',
|
||||
caption: 'Parsed name, episodes, and resource terms',
|
||||
},
|
||||
classification: {
|
||||
title: 'Media Classification',
|
||||
},
|
||||
source: {
|
||||
title: 'Recognition Source',
|
||||
caption: 'The media source matched by this recognition',
|
||||
@@ -1814,6 +1843,15 @@ export default {
|
||||
llmApiProtocolAuto: 'Auto (auto)',
|
||||
llmApiProtocolChatCompletions: 'Chat Completions',
|
||||
llmApiProtocolResponses: 'Responses',
|
||||
llmWebSearchMode: 'Web Search',
|
||||
llmWebSearchModeHint:
|
||||
'Use MoviePilot local search, model-hosted search, automatic fallback, or disable web access. Hosted search is available only for models that declare support.',
|
||||
llmWebSearchModeBuiltinSupportedHint:
|
||||
'This model supports provider-hosted search. Built-in and Auto use the required protocol without a separate search API key.',
|
||||
llmWebSearchModeLocal: 'MoviePilot local search',
|
||||
llmWebSearchModeBuiltin: 'Model-hosted search',
|
||||
llmWebSearchModeAuto: 'Auto (hosted first)',
|
||||
llmWebSearchModeDisabled: 'Disable web search',
|
||||
llmTemperature: 'Temperature',
|
||||
llmTemperatureHint:
|
||||
'Controls response randomness. Lower values are steadier and higher values are more varied. Backend default is 0.3; 0-2 is usually recommended.',
|
||||
@@ -3452,6 +3490,7 @@ export default {
|
||||
plugin: {
|
||||
sort: {
|
||||
popular: 'Popular',
|
||||
rating: 'Rating',
|
||||
name: 'Plugin Name',
|
||||
author: 'Author',
|
||||
repository: 'Plugin Repository',
|
||||
|
||||
+39
-1
@@ -150,6 +150,7 @@ export default {
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色调',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassAppearanceHint: '透明突出壁纸纹理,色调增加颜色覆盖,磨砂通过模糊扩散呈现更厚的玻璃质感。',
|
||||
glassQuality: '质量',
|
||||
glassQualityCss: '标准',
|
||||
glassQualityBalanced: '均衡',
|
||||
@@ -162,6 +163,14 @@ export default {
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液态',
|
||||
glassPresetHint: '自然均衡克制,滑移强调顺畅移动,液态增强形变与惯性。',
|
||||
glassDynamicsMode: '动态效果',
|
||||
glassDynamicsModeFluid: '流体',
|
||||
glassDynamicsModeRipple: '水漾',
|
||||
glassDynamicsModeOff: '关闭',
|
||||
glassDynamicsModeFluidHint: '跟随指针形成连续的流动与折射反馈。',
|
||||
glassDynamicsModeRippleHint: '指针经过玻璃时产生向相邻表面扩散的水纹。',
|
||||
glassDynamicsModeOffHint: '保留静态材质,不响应指针动态。',
|
||||
glassMaterialTuning: '材质参数',
|
||||
glassDynamicTuning: '动态参数',
|
||||
glassTranslationStrength: '采样平移',
|
||||
@@ -792,6 +801,7 @@ export default {
|
||||
edit: '编辑任务',
|
||||
editFlow: '编辑流程',
|
||||
share: '分享',
|
||||
moreActions: '更多操作',
|
||||
continue: '继续执行',
|
||||
restart: '重新执行',
|
||||
run: '立即执行',
|
||||
@@ -814,7 +824,7 @@ export default {
|
||||
running: '运行中',
|
||||
failed: '失败',
|
||||
paused: '暂停',
|
||||
waiting: '等待',
|
||||
waiting: '待执行',
|
||||
},
|
||||
info: {
|
||||
trigger: '触发方式',
|
||||
@@ -823,8 +833,15 @@ export default {
|
||||
actionCount: '动作数',
|
||||
runCount: '已执行次数',
|
||||
progress: '进度',
|
||||
actionProgress: '动作进度',
|
||||
error: '错误信息',
|
||||
manualTrigger: '手动',
|
||||
lastExecuted: '上次执行 {time}',
|
||||
neverExecuted: '从未执行',
|
||||
executionIncomplete: '上次执行尚未完成',
|
||||
executingAction: '正在执行 {name}',
|
||||
preparingAction: '正在准备第 {current} 个动作',
|
||||
noActions: '暂无动作',
|
||||
},
|
||||
},
|
||||
scanFile: {
|
||||
@@ -975,6 +992,14 @@ export default {
|
||||
timer: '定时器',
|
||||
manualTrigger: '手动触发',
|
||||
actionCount: '动作数量',
|
||||
preview: {
|
||||
title: '流程概览',
|
||||
actionCount: '{count} 个动作',
|
||||
flowCount: '{count} 条连接',
|
||||
moreActions: '另有 {count} 个',
|
||||
hasBranches: '含分支',
|
||||
unknownAction: '未知动作',
|
||||
},
|
||||
normalFork: '复用工作流',
|
||||
cancelShare: '取消分享',
|
||||
cancelSuccess: '取消分享成功',
|
||||
@@ -1617,6 +1642,9 @@ export default {
|
||||
title: '元信息',
|
||||
caption: '解析出的名称、季集和资源信息',
|
||||
},
|
||||
classification: {
|
||||
title: '媒体分类',
|
||||
},
|
||||
source: {
|
||||
title: '识别数据源',
|
||||
caption: '本次识别匹配到的媒体数据源',
|
||||
@@ -1799,6 +1827,15 @@ export default {
|
||||
llmApiProtocolAuto: '自动 (auto)',
|
||||
llmApiProtocolChatCompletions: 'Chat Completions',
|
||||
llmApiProtocolResponses: 'Responses',
|
||||
llmWebSearchMode: '联网搜索',
|
||||
llmWebSearchModeHint:
|
||||
'选择 MoviePilot 本地搜索、模型服务端搜索、自动回退或完全关闭;服务端搜索仅在当前模型声明支持时可用',
|
||||
llmWebSearchModeBuiltinSupportedHint:
|
||||
'当前模型支持官方托管搜索;选择“模型服务端”或“自动”时会按所需协议调用,无需额外搜索密钥',
|
||||
llmWebSearchModeLocal: 'MoviePilot 本地搜索',
|
||||
llmWebSearchModeBuiltin: '模型服务端搜索',
|
||||
llmWebSearchModeAuto: '自动(服务端优先)',
|
||||
llmWebSearchModeDisabled: '关闭联网搜索',
|
||||
llmTemperature: '温度参数',
|
||||
llmTemperatureHint: '控制回复随机性,数值越低越稳定,越高越发散;后端默认 0.3,通常建议 0-2',
|
||||
llmProviderAuth: '提供商授权',
|
||||
@@ -3395,6 +3432,7 @@ export default {
|
||||
plugin: {
|
||||
sort: {
|
||||
popular: '热门',
|
||||
rating: '评分',
|
||||
name: '插件名称',
|
||||
author: '作者',
|
||||
repository: '插件仓库',
|
||||
|
||||
+39
-1
@@ -150,6 +150,7 @@ export default {
|
||||
glassAppearanceClear: '透明',
|
||||
glassAppearanceTinted: '色調',
|
||||
glassAppearanceFrosted: '磨砂',
|
||||
glassAppearanceHint: '透明強調桌布紋理,色調增加色彩覆蓋,磨砂透過模糊擴散呈現更厚實的玻璃質感。',
|
||||
glassQuality: '品質',
|
||||
glassQualityCss: '標準',
|
||||
glassQualityBalanced: '均衡',
|
||||
@@ -162,6 +163,14 @@ export default {
|
||||
glassPresetNatural: '自然',
|
||||
glassPresetGlide: '滑移',
|
||||
glassPresetLiquid: '液態',
|
||||
glassPresetHint: '自然均衡克制,滑移強調順暢移動,液態增強形變與慣性。',
|
||||
glassDynamicsMode: '動態效果',
|
||||
glassDynamicsModeFluid: '流體',
|
||||
glassDynamicsModeRipple: '水漾',
|
||||
glassDynamicsModeOff: '關閉',
|
||||
glassDynamicsModeFluidHint: '跟隨指標形成連續的流動與折射回饋。',
|
||||
glassDynamicsModeRippleHint: '指標經過玻璃時產生向相鄰表面擴散的水紋。',
|
||||
glassDynamicsModeOffHint: '保留靜態材質,不回應指標動態。',
|
||||
glassMaterialTuning: '材質參數',
|
||||
glassDynamicTuning: '動態參數',
|
||||
glassTranslationStrength: '採樣平移',
|
||||
@@ -792,6 +801,7 @@ export default {
|
||||
edit: '編輯任務',
|
||||
editFlow: '編輯流程',
|
||||
share: '分享',
|
||||
moreActions: '更多操作',
|
||||
continue: '繼續',
|
||||
restart: '重新開始',
|
||||
run: '立即執行',
|
||||
@@ -814,7 +824,7 @@ export default {
|
||||
running: '執行中',
|
||||
failed: '失敗',
|
||||
paused: '已暫停',
|
||||
waiting: '等待中',
|
||||
waiting: '待執行',
|
||||
},
|
||||
info: {
|
||||
trigger: '觸發方式',
|
||||
@@ -823,8 +833,15 @@ export default {
|
||||
actionCount: '動作數量',
|
||||
runCount: '執行次數',
|
||||
progress: '進度',
|
||||
actionProgress: '動作進度',
|
||||
error: '錯誤訊息',
|
||||
manualTrigger: '手動',
|
||||
lastExecuted: '上次執行 {time}',
|
||||
neverExecuted: '從未執行',
|
||||
executionIncomplete: '上次執行尚未完成',
|
||||
executingAction: '正在執行 {name}',
|
||||
preparingAction: '正在準備第 {current} 個動作',
|
||||
noActions: '暫無動作',
|
||||
},
|
||||
},
|
||||
scanFile: {
|
||||
@@ -975,6 +992,14 @@ export default {
|
||||
timer: '定時器',
|
||||
manualTrigger: '手動觸發',
|
||||
actionCount: '動作數量',
|
||||
preview: {
|
||||
title: '流程概覽',
|
||||
actionCount: '{count} 個動作',
|
||||
flowCount: '{count} 條連接',
|
||||
moreActions: '另有 {count} 個',
|
||||
hasBranches: '含分支',
|
||||
unknownAction: '未知動作',
|
||||
},
|
||||
normalFork: '復用工作流',
|
||||
cancelShare: '取消分享',
|
||||
cancelSuccess: '取消分享成功',
|
||||
@@ -1616,6 +1641,9 @@ export default {
|
||||
title: '元資訊',
|
||||
caption: '解析出的名稱、季集和資源資訊',
|
||||
},
|
||||
classification: {
|
||||
title: '媒體分類',
|
||||
},
|
||||
source: {
|
||||
title: '識別資料源',
|
||||
caption: '本次識別匹配到的媒體資料源',
|
||||
@@ -1798,6 +1826,15 @@ export default {
|
||||
llmApiProtocolAuto: '自動 (auto)',
|
||||
llmApiProtocolChatCompletions: 'Chat Completions',
|
||||
llmApiProtocolResponses: 'Responses',
|
||||
llmWebSearchMode: '聯網搜尋',
|
||||
llmWebSearchModeHint:
|
||||
'選擇 MoviePilot 本地搜尋、模型服務端搜尋、自動回退或完全關閉;服務端搜尋僅在目前模型宣告支援時可用',
|
||||
llmWebSearchModeBuiltinSupportedHint:
|
||||
'目前模型支援官方代管搜尋;選擇「模型服務端」或「自動」時會按所需協議呼叫,無需額外搜尋金鑰',
|
||||
llmWebSearchModeLocal: 'MoviePilot 本地搜尋',
|
||||
llmWebSearchModeBuiltin: '模型服務端搜尋',
|
||||
llmWebSearchModeAuto: '自動(服務端優先)',
|
||||
llmWebSearchModeDisabled: '關閉聯網搜尋',
|
||||
llmTemperature: '溫度參數',
|
||||
llmTemperatureHint: '控制回覆隨機性,數值越低越穩定,越高越發散;後端預設 0.3,通常建議 0-2',
|
||||
llmProviderAuth: '提供商授權',
|
||||
@@ -3394,6 +3431,7 @@ export default {
|
||||
plugin: {
|
||||
sort: {
|
||||
popular: '熱門',
|
||||
rating: '評分',
|
||||
name: '插件名稱',
|
||||
author: '作者',
|
||||
repository: '插件倉庫',
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createGlassFluidDynamics,
|
||||
GLASS_FLUID_DYNAMIC_RANGE_DENSITY,
|
||||
GLASS_FLUID_DYNAMIC_RANGE_SCALE,
|
||||
GLASS_FLUID_FIELD_FRAGMENT_SHADER,
|
||||
GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION,
|
||||
GLASS_FLUID_FRAGMENT_SURFACE_SHAPE,
|
||||
} from '@/rendering/glass/glassFluidDynamics'
|
||||
|
||||
class FakeVector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0,
|
||||
) {}
|
||||
|
||||
set(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRenderTarget {
|
||||
static instances: FakeRenderTarget[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
height: number
|
||||
readonly setSize = vi.fn((width: number, height: number) => {
|
||||
this.width = width
|
||||
this.height = height
|
||||
})
|
||||
readonly texture = {}
|
||||
width: number
|
||||
|
||||
constructor(
|
||||
width: number,
|
||||
height: number,
|
||||
readonly options: Record<string, unknown>,
|
||||
) {
|
||||
this.height = height
|
||||
this.width = width
|
||||
FakeRenderTarget.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShaderMaterial {
|
||||
static instances: FakeShaderMaterial[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
readonly fragmentShader: string
|
||||
readonly uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
|
||||
this.fragmentShader = options.fragmentShader
|
||||
this.uniforms = options.uniforms
|
||||
FakeShaderMaterial.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeScene {
|
||||
readonly children: FakeMesh[] = []
|
||||
|
||||
add(mesh: FakeMesh) {
|
||||
this.children.push(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMesh {
|
||||
frustumCulled = true
|
||||
|
||||
constructor(
|
||||
readonly geometry: unknown,
|
||||
readonly material: FakeShaderMaterial,
|
||||
) {}
|
||||
}
|
||||
|
||||
function createFluidHarness() {
|
||||
let currentTarget: FakeRenderTarget | null = null
|
||||
const pointer = new FakeVector2(0.25, 0.75)
|
||||
const velocity = new FakeVector2(0.1, -0.2)
|
||||
const renderer = {
|
||||
render: vi.fn(),
|
||||
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
|
||||
currentTarget = target
|
||||
}),
|
||||
setScissorTest: vi.fn(),
|
||||
}
|
||||
const three = {
|
||||
LinearFilter: 1001,
|
||||
Mesh: FakeMesh,
|
||||
Scene: FakeScene,
|
||||
ShaderMaterial: FakeShaderMaterial,
|
||||
Vector2: FakeVector2,
|
||||
WebGLRenderTarget: FakeRenderTarget,
|
||||
} as unknown as typeof import('three')
|
||||
|
||||
return {
|
||||
create: () =>
|
||||
createGlassFluidDynamics({
|
||||
camera: {} as never,
|
||||
geometry: {} as never,
|
||||
pointer: pointer as never,
|
||||
renderer: renderer as never,
|
||||
three,
|
||||
velocity: velocity as never,
|
||||
}),
|
||||
getCurrentTarget: () => currentTarget,
|
||||
pointer,
|
||||
renderer,
|
||||
velocity,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRenderTarget.instances = []
|
||||
FakeShaderMaterial.instances = []
|
||||
})
|
||||
|
||||
describe('glass fluid dynamics', () => {
|
||||
it('owns exactly one two-target field and reuses the shared pointer vectors', () => {
|
||||
const harness = createFluidHarness()
|
||||
const dynamics = harness.create()
|
||||
const material = FakeShaderMaterial.instances[0]
|
||||
|
||||
expect(FakeRenderTarget.instances).toHaveLength(2)
|
||||
expect(FakeRenderTarget.instances.map(target => target.options)).toEqual([
|
||||
{
|
||||
depthBuffer: false,
|
||||
magFilter: 1001,
|
||||
minFilter: 1001,
|
||||
stencilBuffer: false,
|
||||
},
|
||||
{
|
||||
depthBuffer: false,
|
||||
magFilter: 1001,
|
||||
minFilter: 1001,
|
||||
stencilBuffer: false,
|
||||
},
|
||||
])
|
||||
expect(material.fragmentShader).toBe(GLASS_FLUID_FIELD_FRAGMENT_SHADER)
|
||||
expect(material.uniforms.uPointer.value).toBe(harness.pointer)
|
||||
expect(material.uniforms.uVelocity.value).toBe(harness.velocity)
|
||||
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('resizes, advances, swaps and clears its private temporal field', () => {
|
||||
const harness = createFluidHarness()
|
||||
const dynamics = harness.create()
|
||||
const material = FakeShaderMaterial.instances[0]
|
||||
const [firstTarget, secondTarget] = FakeRenderTarget.instances
|
||||
|
||||
dynamics.resize(800, 600, 1200, 600)
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[200, 150],
|
||||
[200, 150],
|
||||
])
|
||||
expect(material.uniforms.uTexelSize.value).toMatchObject({ x: 1 / 200, y: 1 / 150 })
|
||||
expect(material.uniforms.uViewportAspect.value).toBe(2)
|
||||
|
||||
dynamics.setFrameParameters(0.8, 0.6)
|
||||
const texture = dynamics.step()
|
||||
|
||||
expect(harness.renderer.setScissorTest).toHaveBeenCalledWith(false)
|
||||
expect(material.uniforms.uPrevious.value).toBe(firstTarget.texture)
|
||||
expect(harness.renderer.setRenderTarget.mock.calls).toEqual([[secondTarget], [null]])
|
||||
expect(harness.renderer.render).toHaveBeenCalledOnce()
|
||||
expect(harness.getCurrentTarget()).toBeNull()
|
||||
expect(texture).toBe(secondTarget.texture)
|
||||
|
||||
dynamics.finishFrame()
|
||||
expect(material.uniforms.uInjection.value).toBe(0)
|
||||
expect(material.uniforms.uDecay.value).toBe(0.8)
|
||||
|
||||
dynamics.clearInput()
|
||||
expect(material.uniforms.uDecay.value).toBe(0)
|
||||
expect(material.uniforms.uInjection.value).toBe(0)
|
||||
|
||||
dynamics.dispose()
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(material.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the established field injection and decay equations', () => {
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('previousEnergy * uDecay')
|
||||
expect(GLASS_FLUID_DYNAMIC_RANGE_SCALE).toBe(0.52)
|
||||
expect(GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toBeCloseTo(3.698, 3)
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 258.876')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('distanceSquared * 155.325')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).toContain('injection * 0.44')
|
||||
expect(GLASS_FLUID_FIELD_FRAGMENT_SHADER).not.toContain('uImpulse')
|
||||
})
|
||||
|
||||
it('narrows directional material coverage without replacing fluid refraction energy', () => {
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain('smoothstep(0.001, 0.012, length(uPointerVelocity))')
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain('directionalCoverageShape')
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain('pointerCoverageEnergy')
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain('pow(clamp(pointerCoverageShape * uMotion, 0.0, 1.0), 1.15)')
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION).toContain('pointerDelta * pointerEnergy * pointerStrength')
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION).not.toContain('pointerCoverageEnergy')
|
||||
})
|
||||
|
||||
it('squares the signed coverage offset without GLSL pow', () => {
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain(
|
||||
'float directionalCoverageAlong = pointerAlong + coverageWakeTravel * 0.45;',
|
||||
)
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).toContain(
|
||||
'directionalCoverageAlong * directionalCoverageAlong * pointerSpread * 0.55',
|
||||
)
|
||||
expect(GLASS_FLUID_FRAGMENT_SURFACE_SHAPE).not.toContain('pow(pointerAlong + coverageWakeTravel * 0.45, 2.0)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,385 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createGlassRippleDynamics,
|
||||
RIPPLE_FRAGMENT_SHADER,
|
||||
type GlassRippleQuality,
|
||||
} from '@/rendering/glass/glassRippleDynamics'
|
||||
|
||||
class FakeVector2 {
|
||||
constructor(
|
||||
public x = 0,
|
||||
public y = 0,
|
||||
) {}
|
||||
|
||||
copy(value: FakeVector2) {
|
||||
return this.set(value.x, value.y)
|
||||
}
|
||||
|
||||
set(x: number, y: number) {
|
||||
this.x = x
|
||||
this.y = y
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
class FakeRenderTarget {
|
||||
static instances: FakeRenderTarget[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
height = 1
|
||||
readonly setSize = vi.fn((width: number, height: number) => {
|
||||
this.width = width
|
||||
this.height = height
|
||||
})
|
||||
readonly texture: Record<string, unknown>
|
||||
width = 1
|
||||
|
||||
constructor(
|
||||
_width: number,
|
||||
_height: number,
|
||||
readonly options: Record<string, unknown>,
|
||||
) {
|
||||
this.texture = {
|
||||
format: options.format,
|
||||
magFilter: options.magFilter,
|
||||
minFilter: options.minFilter,
|
||||
type: options.type,
|
||||
wrapS: options.wrapS,
|
||||
wrapT: options.wrapT,
|
||||
}
|
||||
FakeRenderTarget.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShaderMaterial {
|
||||
static instances: FakeShaderMaterial[] = []
|
||||
|
||||
readonly dispose = vi.fn()
|
||||
readonly fragmentShader: string
|
||||
readonly uniforms: Record<string, { value: unknown }>
|
||||
|
||||
constructor(options: { fragmentShader: string; uniforms: Record<string, { value: unknown }> }) {
|
||||
this.fragmentShader = options.fragmentShader
|
||||
this.uniforms = options.uniforms
|
||||
FakeShaderMaterial.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeScene {
|
||||
readonly children: FakeMesh[] = []
|
||||
|
||||
add(mesh: FakeMesh) {
|
||||
this.children.push(mesh)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMesh {
|
||||
frustumCulled = true
|
||||
|
||||
constructor(
|
||||
readonly geometry: unknown,
|
||||
readonly material: FakeShaderMaterial,
|
||||
) {}
|
||||
}
|
||||
|
||||
interface RenderSnapshot {
|
||||
direction: { x: number; y: number }
|
||||
energyDecay: number
|
||||
heightDecay: number
|
||||
impulse: number
|
||||
impulseCenter: { x: number; y: number }
|
||||
impulseOffset: number
|
||||
impulseSigma: number
|
||||
impulseSpeed: number
|
||||
reset: number
|
||||
step: number
|
||||
target: FakeRenderTarget | null
|
||||
velocityDecay: number
|
||||
}
|
||||
|
||||
function createRippleHarness(
|
||||
quality: GlassRippleQuality = 'balanced',
|
||||
compileAsync = vi.fn().mockResolvedValue(undefined),
|
||||
supportsHalfFloatTarget = true,
|
||||
) {
|
||||
let currentTarget: FakeRenderTarget | null = null
|
||||
const snapshots: RenderSnapshot[] = []
|
||||
const renderer = {
|
||||
compileAsync,
|
||||
extensions: {
|
||||
has: vi.fn(() => supportsHalfFloatTarget),
|
||||
},
|
||||
getRenderTarget: vi.fn(() => currentTarget),
|
||||
render: vi.fn((scene: FakeScene) => {
|
||||
const uniforms = scene.children[0].material.uniforms
|
||||
snapshots.push({
|
||||
direction: {
|
||||
x: (uniforms.uImpulseDirection.value as FakeVector2).x,
|
||||
y: (uniforms.uImpulseDirection.value as FakeVector2).y,
|
||||
},
|
||||
energyDecay: uniforms.uEnergyDecay.value as number,
|
||||
heightDecay: uniforms.uHeightDecay.value as number,
|
||||
impulse: uniforms.uImpulse.value as number,
|
||||
impulseCenter: {
|
||||
x: (uniforms.uImpulseCenter.value as FakeVector2).x,
|
||||
y: (uniforms.uImpulseCenter.value as FakeVector2).y,
|
||||
},
|
||||
impulseOffset: uniforms.uImpulseOffset.value as number,
|
||||
impulseSigma: uniforms.uImpulseSigma.value as number,
|
||||
impulseSpeed: uniforms.uImpulseSpeed.value as number,
|
||||
reset: uniforms.uReset.value as number,
|
||||
step: uniforms.uStep.value as number,
|
||||
target: currentTarget,
|
||||
velocityDecay: uniforms.uVelocityDecay.value as number,
|
||||
})
|
||||
}),
|
||||
setRenderTarget: vi.fn((target: FakeRenderTarget | null) => {
|
||||
currentTarget = target
|
||||
}),
|
||||
setScissorTest: vi.fn(),
|
||||
}
|
||||
const three = {
|
||||
ClampToEdgeWrapping: 1001,
|
||||
HalfFloatType: 1005,
|
||||
LinearFilter: 1002,
|
||||
Mesh: FakeMesh,
|
||||
RGBAFormat: 1003,
|
||||
Scene: FakeScene,
|
||||
ShaderMaterial: FakeShaderMaterial,
|
||||
UnsignedByteType: 1004,
|
||||
Vector2: FakeVector2,
|
||||
WebGLRenderTarget: FakeRenderTarget,
|
||||
} as unknown as typeof import('three')
|
||||
|
||||
return {
|
||||
create: () =>
|
||||
createGlassRippleDynamics({
|
||||
camera: {} as never,
|
||||
geometry: {} as never,
|
||||
quality,
|
||||
renderer: renderer as never,
|
||||
three,
|
||||
viewportHeight: 800,
|
||||
viewportWidth: 1200,
|
||||
}),
|
||||
renderer,
|
||||
snapshots,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeRenderTarget.instances = []
|
||||
FakeShaderMaterial.instances = []
|
||||
})
|
||||
|
||||
describe('glass ripple dynamics', () => {
|
||||
it('uses one bounded half-float ping-pong field when the renderer supports it', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
|
||||
expect(FakeRenderTarget.instances).toHaveLength(2)
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[192, 128],
|
||||
[192, 128],
|
||||
])
|
||||
for (const target of FakeRenderTarget.instances) {
|
||||
expect(target.options).toMatchObject({
|
||||
depthBuffer: false,
|
||||
format: 1003,
|
||||
magFilter: 1002,
|
||||
minFilter: 1002,
|
||||
stencilBuffer: false,
|
||||
type: 1005,
|
||||
wrapS: 1001,
|
||||
wrapT: 1001,
|
||||
})
|
||||
expect(target.texture.generateMipmaps).toBe(false)
|
||||
}
|
||||
expect(harness.renderer.compileAsync).toHaveBeenCalledOnce()
|
||||
expect(harness.snapshots).toHaveLength(2)
|
||||
expect(harness.snapshots.every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(new Set(harness.snapshots.map(snapshot => snapshot.target))).toEqual(new Set(FakeRenderTarget.instances))
|
||||
expect(dynamics.texture).toBeNull()
|
||||
expect(dynamics.texelSize.x).toBeCloseTo(1 / 192)
|
||||
expect(dynamics.texelSize.y).toBeCloseTo(1 / 128)
|
||||
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the documented neutral encoding, stencil weights and bounded impulse kernel', () => {
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('vec4(0.5, 0.5, 0.0, 1.0)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('sampleValue.b < (1.0 / 255.0)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('cardinal1 * 0.72 + cardinal2 * 0.28')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain(
|
||||
'cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12',
|
||||
)
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalRadius = length(vec2(along * 0.72, across * 1.24))')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float centerRelease = smoothstep(0.0, 0.55, normalizedRadius)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float annularCore = normalizedRadius * core')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain(
|
||||
'float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse',
|
||||
)
|
||||
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('0.58 * core')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('float directionalImpulse = clamp(')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('mix(radialImpulse, directionalImpulse')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).toContain('impulse * mix(0.52, 0.82, speedResponse)')
|
||||
expect(RIPPLE_FRAGMENT_SHADER).not.toContain('uHeightDecay + impulse')
|
||||
})
|
||||
|
||||
it('resizes in viewport space, clears the field and releases every owned resource', async () => {
|
||||
const harness = createRippleHarness('high')
|
||||
const dynamics = await harness.create()
|
||||
harness.snapshots.length = 0
|
||||
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.4, y: 0.6 },
|
||||
speed: 0.8,
|
||||
timestamp: 100,
|
||||
})
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(dynamics.texture).not.toBeNull()
|
||||
|
||||
dynamics.resize(1600, 900)
|
||||
|
||||
expect(FakeRenderTarget.instances.map(target => [target.width, target.height])).toEqual([
|
||||
[400, 225],
|
||||
[400, 225],
|
||||
])
|
||||
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(dynamics.texture).toBeNull()
|
||||
expect(dynamics.texelSize.x).toBeCloseTo(1 / 400)
|
||||
expect(dynamics.texelSize.y).toBeCloseTo(1 / 225)
|
||||
|
||||
dynamics.dispose()
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('falls back to an 8-bit field when half-float color targets are unavailable', async () => {
|
||||
const harness = createRippleHarness('balanced', vi.fn().mockResolvedValue(undefined), false)
|
||||
const dynamics = await harness.create()
|
||||
|
||||
expect(FakeRenderTarget.instances.every(target => target.options.type === 1004)).toBe(true)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('clears flow-zero feedback on the next frame and then stops all GPU work', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
dynamics.setParameters(50, 0)
|
||||
dynamics.inject({
|
||||
direction: { x: 0.8, y: 0.2 },
|
||||
point: { x: 0.35, y: 0.65 },
|
||||
speed: 0.7,
|
||||
timestamp: 100,
|
||||
})
|
||||
harness.snapshots.length = 0
|
||||
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(harness.snapshots).toHaveLength(1)
|
||||
expect(dynamics.texture).not.toBeNull()
|
||||
expect(dynamics.step(133.334)).toBe(false)
|
||||
expect(harness.snapshots).toHaveLength(3)
|
||||
expect(harness.snapshots.slice(-2).every(snapshot => snapshot.reset === 1)).toBe(true)
|
||||
expect(dynamics.texture).toBeNull()
|
||||
|
||||
expect(dynamics.step(150)).toBe(false)
|
||||
expect(harness.snapshots).toHaveLength(3)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('caps propagation at two substeps while applying decay over the full elapsed time', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
dynamics.setParameters(50, 50)
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.5, y: 0.5 },
|
||||
speed: 0.5,
|
||||
timestamp: 100,
|
||||
})
|
||||
dynamics.step(116.667)
|
||||
harness.snapshots.length = 0
|
||||
|
||||
expect(dynamics.step(166.667)).toBe(true)
|
||||
|
||||
expect(harness.snapshots).toHaveLength(2)
|
||||
const velocityHalfLife = 145
|
||||
const expectedSubstepDecay = 2 ** (-25 / velocityHalfLife)
|
||||
expect(harness.snapshots.every(snapshot => snapshot.step === 1)).toBe(true)
|
||||
expect(
|
||||
harness.snapshots.every(snapshot => Math.abs(snapshot.velocityDecay - expectedSubstepDecay) < 0.000001),
|
||||
).toBe(true)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('integrates directions while retaining the latest point and maximum impulse within one frame', async () => {
|
||||
const harness = createRippleHarness()
|
||||
const dynamics = await harness.create()
|
||||
harness.snapshots.length = 0
|
||||
|
||||
dynamics.inject({
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.25, y: 0.35 },
|
||||
speed: 1,
|
||||
timestamp: 100,
|
||||
})
|
||||
dynamics.inject({
|
||||
direction: { x: 0, y: 1 },
|
||||
point: { x: 0.7, y: 0.8 },
|
||||
speed: 0.2,
|
||||
timestamp: 104,
|
||||
})
|
||||
|
||||
expect(dynamics.step(116.667)).toBe(true)
|
||||
expect(harness.snapshots).toHaveLength(1)
|
||||
expect(harness.snapshots[0].direction.x).toBeCloseTo(Math.SQRT1_2)
|
||||
expect(harness.snapshots[0].direction.y).toBeCloseTo(Math.SQRT1_2)
|
||||
expect(harness.snapshots[0].impulseCenter).toEqual({ x: 0.7, y: 0.8 })
|
||||
expect(harness.snapshots[0].impulse).toBeCloseTo(0.8)
|
||||
expect(harness.snapshots[0].impulseOffset).toBe(28)
|
||||
expect(harness.snapshots[0].impulseSigma).toBeCloseTo(75.6)
|
||||
expect(harness.snapshots[0].impulseSpeed).toBe(1)
|
||||
dynamics.dispose()
|
||||
})
|
||||
|
||||
it('keeps the ripple footprint stable across quality levels', async () => {
|
||||
const balancedHarness = createRippleHarness('balanced')
|
||||
const balancedDynamics = await balancedHarness.create()
|
||||
const highHarness = createRippleHarness('high')
|
||||
const highDynamics = await highHarness.create()
|
||||
const interaction = {
|
||||
direction: { x: 1, y: 0 },
|
||||
point: { x: 0.5, y: 0.5 },
|
||||
speed: 0.5,
|
||||
timestamp: 100,
|
||||
}
|
||||
balancedHarness.snapshots.length = 0
|
||||
highHarness.snapshots.length = 0
|
||||
|
||||
balancedDynamics.setParameters(75, 50)
|
||||
highDynamics.setParameters(75, 50)
|
||||
balancedDynamics.inject(interaction)
|
||||
highDynamics.inject(interaction)
|
||||
balancedDynamics.step(116.667)
|
||||
highDynamics.step(116.667)
|
||||
|
||||
expect(balancedHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
|
||||
expect(highHarness.snapshots[0].impulseSigma).toBeCloseTo(86.4)
|
||||
balancedDynamics.dispose()
|
||||
highDynamics.dispose()
|
||||
})
|
||||
|
||||
it('disposes partially created resources when shader compilation fails', async () => {
|
||||
const compileAsync = vi.fn().mockRejectedValue(new Error('compile failed'))
|
||||
const harness = createRippleHarness('balanced', compileAsync)
|
||||
|
||||
await expect(harness.create()).rejects.toThrow('compile failed')
|
||||
expect(FakeRenderTarget.instances.every(target => target.dispose.mock.calls.length === 1)).toBe(true)
|
||||
expect(FakeShaderMaterial.instances[0].dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,353 @@
|
||||
import type {
|
||||
BufferGeometry,
|
||||
IUniform,
|
||||
OrthographicCamera,
|
||||
Texture,
|
||||
Vector2,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
|
||||
type ThreeModule = typeof import('three')
|
||||
|
||||
interface GlassFluidFieldUniforms extends Record<string, IUniform> {
|
||||
uDecay: IUniform<number>
|
||||
uInjection: IUniform<number>
|
||||
uPointer: IUniform<Vector2>
|
||||
uPrevious: IUniform<Texture | null>
|
||||
uTexelSize: IUniform<Vector2>
|
||||
uVelocity: IUniform<Vector2>
|
||||
uViewportAspect: IUniform<number>
|
||||
}
|
||||
|
||||
interface CreateGlassFluidDynamicsOptions {
|
||||
camera: OrthographicCamera
|
||||
geometry: BufferGeometry
|
||||
pointer: Vector2
|
||||
renderer: WebGLRenderer
|
||||
three: ThreeModule
|
||||
velocity: Vector2
|
||||
}
|
||||
|
||||
export interface GlassFluidDynamics {
|
||||
/** 清除当前输入包络;下一帧会把时序场收敛到中性值。 */
|
||||
clearInput(): void
|
||||
/** 释放 fluid 私有 shader 和两个 ping-pong target。 */
|
||||
dispose(): void
|
||||
/** 清除当前帧注入,避免非输入绘制重复写入同一能量。 */
|
||||
finishFrame(): void
|
||||
/** 调整 fluid field;尺寸只来自主 renderer 已提交的 buffer。 */
|
||||
resize(bufferWidth: number, bufferHeight: number, viewportWidth: number, viewportHeight: number): void
|
||||
/** 更新当前帧的衰减与注入参数,不自行调度动画。 */
|
||||
setFrameParameters(decay: number, injection: number): void
|
||||
/** 推进一次 field 并返回主材质应采样的最新纹理。 */
|
||||
step(): Texture
|
||||
}
|
||||
|
||||
export const GLASS_FLUID_DYNAMIC_RANGE_SCALE = 0.52
|
||||
export const GLASS_FLUID_DYNAMIC_RANGE_DENSITY = 1 / GLASS_FLUID_DYNAMIC_RANGE_SCALE ** 2
|
||||
const GLASS_FLUID_BUFFER_SCALE = 0.25
|
||||
|
||||
const GLASS_FLUID_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = position.xy * 0.5 + 0.5;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const GLASS_FLUID_FIELD_FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPrevious;
|
||||
uniform vec2 uPointer;
|
||||
uniform vec2 uVelocity;
|
||||
uniform vec2 uTexelSize;
|
||||
uniform float uInjection;
|
||||
uniform float uDecay;
|
||||
uniform float uViewportAspect;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 previous = (
|
||||
texture2D(uPrevious, vUv) * 0.5 +
|
||||
texture2D(uPrevious, vUv + vec2(uTexelSize.x, 0.0)) * 0.125 +
|
||||
texture2D(uPrevious, vUv - vec2(uTexelSize.x, 0.0)) * 0.125 +
|
||||
texture2D(uPrevious, vUv + vec2(0.0, uTexelSize.y)) * 0.125 +
|
||||
texture2D(uPrevious, vUv - vec2(0.0, uTexelSize.y)) * 0.125
|
||||
);
|
||||
float previousEnergy = previous.z;
|
||||
vec2 flow = previousEnergy < 0.001 ? vec2(0.0) : (previous.xy * 2.0 - 1.0) * uDecay;
|
||||
float energy = previousEnergy * uDecay;
|
||||
vec2 delta = vUv - uPointer;
|
||||
delta.x *= uViewportAspect;
|
||||
float distanceSquared = dot(delta, delta);
|
||||
float injection = exp(-distanceSquared * ${(70 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)}) * uInjection;
|
||||
float speed = length(uVelocity);
|
||||
vec2 direction = speed > 0.0001 ? uVelocity / speed : vec2(0.0, -1.0);
|
||||
vec2 perpendicular = vec2(-direction.y, direction.x);
|
||||
float shear = dot(delta, perpendicular) * exp(-distanceSquared * ${(42 * GLASS_FLUID_DYNAMIC_RANGE_DENSITY).toFixed(3)});
|
||||
|
||||
flow += (direction * min(speed * 9.0, 0.9) - perpendicular * shear * 0.85) * injection * 0.44;
|
||||
energy = max(energy, injection);
|
||||
|
||||
gl_FragColor = vec4(flow * 0.5 + 0.5, energy, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
/** fluid 主材质的全局临时量;由共享 shader 在原位置逐字拼装。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SETUP = ` vec2 wakeDirection = length(uWakeDirection) > 0.0001 ? normalize(uWakeDirection) : vec2(0.0, -1.0);
|
||||
vec2 wakePerpendicular = vec2(-wakeDirection.y, wakeDirection.x);
|
||||
vec2 trailRefraction = vec2(0.0);
|
||||
float trailEnergy = 0.0;
|
||||
float trailSpatialSpan = 0.0;
|
||||
float motionRangeCompression = mix(1.0, 1.34, uMotionExpansion);
|
||||
const float dynamicRangeScale = ${GLASS_FLUID_DYNAMIC_RANGE_SCALE.toFixed(2)};
|
||||
const float dynamicRangeDensity = ${GLASS_FLUID_DYNAMIC_RANGE_DENSITY.toFixed(3)};`
|
||||
|
||||
/** fluid 的 trail 与高质量 temporal field 响应。 */
|
||||
export const GLASS_FLUID_FRAGMENT_TRAIL_AND_FIELD = ` for (int trailIndex = 0; trailIndex < 4; trailIndex++) {
|
||||
if (trailIndex >= uTrailCount) break;
|
||||
|
||||
vec4 trail = uTrail[trailIndex];
|
||||
vec2 trailDelta = vUv - trail.xy;
|
||||
trailDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
vec2 trailSpanDelta = trail.xy - uPointer;
|
||||
trailSpanDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
trailSpatialSpan = max(trailSpatialSpan, length(trailSpanDelta) * trail.z);
|
||||
float along = dot(trailDelta, wakeDirection);
|
||||
float across = dot(trailDelta, wakePerpendicular);
|
||||
float trailAlongDensity = mix(42.0, 22.0, uMotionExpansion) * dynamicRangeDensity;
|
||||
float trailAcrossDensity = mix(210.0, 86.0, uMotionExpansion) * dynamicRangeDensity;
|
||||
float lobe =
|
||||
exp(-(along * along * trailAlongDensity + across * across * trailAcrossDensity)) * trail.z * uMotion;
|
||||
float wake = mix(0.88, 0.58, float(trailIndex) / 3.0);
|
||||
|
||||
trailRefraction +=
|
||||
(wakeDirection * 0.0048 + wakePerpendicular * across * 0.018) *
|
||||
lobe *
|
||||
uDeformationStrength *
|
||||
uFlowStrength;
|
||||
trailEnergy += lobe * wake * mix(0.72, 0.42, float(trailIndex) / 3.0);
|
||||
}
|
||||
|
||||
vec4 flowSample = uHasFlowTexture > 0.5 ? texture2D(uFlowTexture, vUv) : vec4(0.5, 0.5, 0.0, 1.0);
|
||||
vec2 temporalFlow =
|
||||
uHasFlowTexture > 0.5
|
||||
? (flowSample.xy * 2.0 - 1.0) *
|
||||
flowSample.z *
|
||||
uMotion *
|
||||
uDeformationStrength *
|
||||
uFlowStrength
|
||||
: vec2(0.0);
|
||||
float flowSurfaceDetail = 0.0;
|
||||
if (uQuality > 0.5 && uHasFlowTexture > 0.5) {
|
||||
vec2 flowTexel = vec2(3.0) / max(uPresentationSize, vec2(1.0));
|
||||
vec3 flowLeft = texture2D(uFlowTexture, vUv - vec2(flowTexel.x, 0.0)).xyz;
|
||||
vec3 flowRight = texture2D(uFlowTexture, vUv + vec2(flowTexel.x, 0.0)).xyz;
|
||||
vec3 flowBottom = texture2D(uFlowTexture, vUv - vec2(0.0, flowTexel.y)).xyz;
|
||||
vec3 flowTop = texture2D(uFlowTexture, vUv + vec2(0.0, flowTexel.y)).xyz;
|
||||
float flowGradient = length(flowRight.xy - flowLeft.xy) + length(flowTop.xy - flowBottom.xy);
|
||||
float energyGradient = abs(flowRight.z - flowLeft.z) + abs(flowTop.z - flowBottom.z);
|
||||
flowSurfaceDetail = smoothstep(0.015, 0.24, flowGradient + energyGradient * 0.72) * uMotion;
|
||||
}`
|
||||
|
||||
/** 单个 surface 内的 fluid 指针、方向、wake 与能量形态。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_SHAPE = ` vec2 pointerDelta = uPointer - vUv;
|
||||
vec2 pointerDeltaAspect = pointerDelta;
|
||||
pointerDeltaAspect *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
// 三材质共享指针几何足迹;磨砂身份由位移幅度、低通扩散和材质合成表达。
|
||||
float pointerSpread = mix(26.0, 17.0, uQuality);
|
||||
pointerSpread *= dynamicRangeDensity * mix(1.0, 0.46, uMotionExpansion);
|
||||
float sharedDirectionality = smoothstep(0.015, 0.18, trailSpatialSpan);
|
||||
float pointerAlong = dot(-pointerDeltaAspect, wakeDirection);
|
||||
float pointerAcross = dot(-pointerDeltaAspect, wakePerpendicular);
|
||||
float sharedWakeTravel =
|
||||
0.08 * sharedDirectionality * mix(0.86, 1.18, uMotionExpansion);
|
||||
float radialPointerShape = exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * pointerSpread);
|
||||
float directionalPointerShape =
|
||||
exp(-(
|
||||
pow(pointerAlong + sharedWakeTravel * 0.45, 2.0) * pointerSpread * 0.72 +
|
||||
pointerAcross * pointerAcross * pointerSpread * 1.35
|
||||
));
|
||||
float pointerEnergy =
|
||||
clamp(mix(radialPointerShape, directionalPointerShape, sharedDirectionality) * uMotion, 0.0, 1.0);
|
||||
float sharedWaveDensity = mix(2.81, 1.63, uMotionExpansion);
|
||||
float radialSharedWave =
|
||||
exp(-dot(pointerDeltaAspect, pointerDeltaAspect) * sharedWaveDensity);
|
||||
float directionalSharedWave =
|
||||
exp(-(
|
||||
pow(pointerAlong + sharedWakeTravel, 2.0) * sharedWaveDensity * 0.62 +
|
||||
pointerAcross * pointerAcross * sharedWaveDensity * 2.2
|
||||
));
|
||||
float sharedWaveEnergy =
|
||||
mix(radialSharedWave, directionalSharedWave, sharedDirectionality) *
|
||||
clamp(length(uPointerVelocity) * 14.0 * uTranslationStrength, 0.0, 1.0) *
|
||||
mix(1.0, 0.78, sharedDirectionality) *
|
||||
uMotion *
|
||||
uMotion;
|
||||
vec2 wakeDelta = vUv - uPointer;
|
||||
wakeDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
float wakeAlong = dot(wakeDelta, wakeDirection);
|
||||
float wakeAcross = dot(wakeDelta, wakePerpendicular);
|
||||
float wakeTravel =
|
||||
0.014 * dynamicRangeScale *
|
||||
mix(0.82, 1.18, uQuality) *
|
||||
mix(1.0, 1.45, uMotionExpansion);
|
||||
float wakeWidth =
|
||||
mix(0.027, 0.044, uQuality) * dynamicRangeScale * mix(1.0, 1.72, uMotionExpansion);
|
||||
float wakeCoordinate = (wakeAlong + wakeTravel) / wakeWidth;
|
||||
float wakeShape = wakeCoordinate * exp(-0.5 * wakeCoordinate * wakeCoordinate);
|
||||
float wakeEnvelope =
|
||||
exp(
|
||||
-wakeAcross *
|
||||
wakeAcross *
|
||||
mix(280.0, 145.0, uQuality) *
|
||||
dynamicRangeDensity *
|
||||
mix(1.0, 0.44, uMotionExpansion)
|
||||
);
|
||||
vec2 wakeRefraction =
|
||||
wakeDirection *
|
||||
wakeShape *
|
||||
wakeEnvelope *
|
||||
mix(0.0045, 0.0075, uQuality) *
|
||||
uMotion *
|
||||
uDeformationStrength *
|
||||
uFlowStrength;
|
||||
float wakeEnergy = abs(wakeShape) * wakeEnvelope * uMotion;
|
||||
// 覆盖能量比位移核更快收敛,避免高斯尾部把真实折射扩成整块色调覆盖。
|
||||
float coverageDirectionality = max(
|
||||
sharedDirectionality,
|
||||
smoothstep(0.001, 0.012, length(uPointerVelocity))
|
||||
);
|
||||
float coverageWakeTravel =
|
||||
0.08 * coverageDirectionality * mix(0.86, 1.18, uMotionExpansion);
|
||||
float directionalCoverageAlong = pointerAlong + coverageWakeTravel * 0.45;
|
||||
float directionalCoverageShape = exp(-(
|
||||
directionalCoverageAlong * directionalCoverageAlong * pointerSpread * 0.55 +
|
||||
pointerAcross * pointerAcross * pointerSpread * 2.8
|
||||
));
|
||||
float pointerCoverageShape = mix(
|
||||
radialPointerShape,
|
||||
directionalCoverageShape,
|
||||
coverageDirectionality
|
||||
);
|
||||
float pointerCoverageEnergy = pow(clamp(pointerCoverageShape * uMotion, 0.0, 1.0), 1.15);
|
||||
float liquidEnergy = clamp(max(
|
||||
pointerCoverageEnergy,
|
||||
max(min(1.0, trailEnergy) * 0.68, wakeEnergy * 0.82)
|
||||
), 0.0, 1.0);`
|
||||
|
||||
/** 单个 surface 内的 fluid 高光与焦散响应。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_OPTICS = ` float pointerStrength = mix(mix(0.0055, 0.008, uQuality), mix(0.0085, 0.012, uQuality), frosted);
|
||||
float trailStrength = mix(mix(0.78, 1.08, uQuality), mix(0.96, 1.3, uQuality), frosted);
|
||||
float temporalStrength = mix(0.032, 0.042, frosted) * uQuality * (1.0 + flowSurfaceDetail * 0.5);
|
||||
vec2 specularDelta =
|
||||
vUv - (uPointer - wakeDirection * mix(0.006, 0.022, uMotionExpansion) * dynamicRangeScale);
|
||||
specularDelta *= uPresentationSize / max(uVisibleViewportSize.y, 1.0) * motionRangeCompression;
|
||||
float specularAlong = dot(specularDelta, wakeDirection);
|
||||
float specularAcross = dot(specularDelta, wakePerpendicular);
|
||||
float singleSpecular =
|
||||
exp(-(
|
||||
specularAlong * specularAlong * mix(58.0, 25.0, uMotionExpansion) * dynamicRangeDensity +
|
||||
specularAcross * specularAcross * mix(190.0, 78.0, uMotionExpansion) * dynamicRangeDensity
|
||||
)) *
|
||||
uMotion *
|
||||
mix(1.0, 1.24, uMotionExpansion);
|
||||
float localCaustic = singleSpecular * rectMask * surfaceDynamic * interactionMask;`
|
||||
|
||||
/** fluid 对共享 dynamicRefraction 的贡献;静态透镜和 ripple 响应仍由主材质合成。 */
|
||||
export const GLASS_FLUID_FRAGMENT_SURFACE_REFRACTION = ` vec2 sampleTranslation =
|
||||
uPointerVelocity *
|
||||
mix(0.055, 0.075, uQuality) *
|
||||
uMotion *
|
||||
uTranslationStrength;
|
||||
dynamicRefraction += (
|
||||
sampleTranslation +
|
||||
// 收紧高斯半径时补偿向量峰值,避免范围缩小同时削弱用户设置的形变强度。
|
||||
pointerDelta * pointerEnergy * pointerStrength * uDeformationStrength / dynamicRangeScale +
|
||||
trailRefraction * trailStrength +
|
||||
temporalFlow * temporalStrength +
|
||||
wakeRefraction
|
||||
) * rectMask * surfaceDynamic * interactionMask;`
|
||||
|
||||
/** 创建仅由高质量 fluid 模式持有的时序位移场。 */
|
||||
export function createGlassFluidDynamics(options: CreateGlassFluidDynamicsOptions): GlassFluidDynamics {
|
||||
const { camera, geometry, pointer, renderer, three, velocity } = options
|
||||
let disposed = false
|
||||
const createTarget = () =>
|
||||
new three.WebGLRenderTarget(1, 1, {
|
||||
depthBuffer: false,
|
||||
magFilter: three.LinearFilter,
|
||||
minFilter: three.LinearFilter,
|
||||
stencilBuffer: false,
|
||||
})
|
||||
let readTarget: WebGLRenderTarget = createTarget()
|
||||
let writeTarget: WebGLRenderTarget = createTarget()
|
||||
const uniforms: GlassFluidFieldUniforms = {
|
||||
uDecay: { value: 1 },
|
||||
uInjection: { value: 0 },
|
||||
uPointer: { value: pointer },
|
||||
uPrevious: { value: null },
|
||||
uTexelSize: { value: new three.Vector2(1, 1) },
|
||||
uVelocity: { value: velocity },
|
||||
uViewportAspect: { value: window.innerWidth / Math.max(window.innerHeight, 1) },
|
||||
}
|
||||
const material = new three.ShaderMaterial({
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragmentShader: GLASS_FLUID_FIELD_FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
vertexShader: GLASS_FLUID_VERTEX_SHADER,
|
||||
})
|
||||
const scene = new three.Scene()
|
||||
const mesh = new three.Mesh(geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
|
||||
return {
|
||||
clearInput() {
|
||||
uniforms.uDecay.value = 0
|
||||
uniforms.uInjection.value = 0
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
},
|
||||
finishFrame() {
|
||||
uniforms.uInjection.value = 0
|
||||
},
|
||||
resize(bufferWidth, bufferHeight, viewportWidth, viewportHeight) {
|
||||
if (disposed) return
|
||||
const width = Math.max(96, Math.round(bufferWidth * GLASS_FLUID_BUFFER_SCALE))
|
||||
const height = Math.max(96, Math.round(bufferHeight * GLASS_FLUID_BUFFER_SCALE))
|
||||
if (readTarget.width !== width || readTarget.height !== height) {
|
||||
readTarget.setSize(width, height)
|
||||
writeTarget.setSize(width, height)
|
||||
}
|
||||
uniforms.uTexelSize.value.set(1 / width, 1 / height)
|
||||
uniforms.uViewportAspect.value = viewportWidth / Math.max(viewportHeight, 1)
|
||||
},
|
||||
setFrameParameters(decay, injection) {
|
||||
uniforms.uDecay.value = decay
|
||||
uniforms.uInjection.value = injection
|
||||
},
|
||||
step() {
|
||||
if (disposed) return readTarget.texture
|
||||
renderer.setScissorTest(false)
|
||||
uniforms.uPrevious.value = readTarget.texture
|
||||
renderer.setRenderTarget(writeTarget)
|
||||
renderer.render(scene, camera)
|
||||
renderer.setRenderTarget(null)
|
||||
const previousReadTarget = readTarget
|
||||
readTarget = writeTarget
|
||||
writeTarget = previousReadTarget
|
||||
|
||||
return readTarget.texture
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import type {
|
||||
BufferGeometry,
|
||||
IUniform,
|
||||
OrthographicCamera,
|
||||
Texture,
|
||||
Vector2,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget,
|
||||
} from 'three'
|
||||
import type { GlassOpticalQuality } from '@/utils/glassOptics'
|
||||
|
||||
type ThreeModule = typeof import('three')
|
||||
|
||||
export type GlassRippleQuality = Exclude<GlassOpticalQuality, 'css'>
|
||||
|
||||
interface GlassRippleUniforms extends Record<string, IUniform> {
|
||||
uEnergyDecay: IUniform<number>
|
||||
uHeightDecay: IUniform<number>
|
||||
uImpulse: IUniform<number>
|
||||
uImpulseCenter: IUniform<Vector2>
|
||||
uImpulseDirection: IUniform<Vector2>
|
||||
uImpulseOffset: IUniform<number>
|
||||
uImpulseSigma: IUniform<number>
|
||||
uImpulseSpeed: IUniform<number>
|
||||
uPrevious: IUniform<Texture | null>
|
||||
uPropagation: IUniform<number>
|
||||
uQuality: IUniform<number>
|
||||
uReset: IUniform<number>
|
||||
uRestoring: IUniform<number>
|
||||
uStep: IUniform<number>
|
||||
uTexelSize: IUniform<Vector2>
|
||||
uVelocityDecay: IUniform<number>
|
||||
uViewportSize: IUniform<Vector2>
|
||||
}
|
||||
|
||||
export interface GlassRippleInteraction {
|
||||
/** CSS viewport 中归一化后的输入位置,Y 轴以 WebGL 底部为原点。 */
|
||||
point: { x: number; y: number }
|
||||
/** 归一化后的指针移动方向。 */
|
||||
direction: { x: number; y: number }
|
||||
/** 现有 renderer 归一化后的速度强度,范围 0 到 1。 */
|
||||
speed: number
|
||||
/** 与 performance timeline 一致的事件时间。 */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GlassRippleDynamics {
|
||||
/** 释放波场及其 GPU 资源。 */
|
||||
dispose(): void
|
||||
/** 将输入合并到下一次 GPU step,不为每个事件立即绘制。 */
|
||||
inject(interaction: GlassRippleInteraction): void
|
||||
/** 当前可供主材质采样的波场纹理;空场返回 null。 */
|
||||
readonly texture: Texture | null
|
||||
/** 当前波场单 texel 的 UV 尺寸,供主材质计算高度梯度。 */
|
||||
readonly texelSize: Vector2
|
||||
/** 更新共享动态参数,不重建 GPU 资源。 */
|
||||
setParameters(translationStrength: number, flowStrength: number): void
|
||||
/** 调整 viewport-space 波场;尺寸变化会恢复为空场。 */
|
||||
resize(viewportWidth: number, viewportHeight: number): void
|
||||
/** 立即清空两个 ping-pong target,并停止 CPU 生命周期。 */
|
||||
reset(): void
|
||||
/** 推进一步波场;返回 false 表示已提交清场并停止。 */
|
||||
step(timestamp: number): boolean
|
||||
}
|
||||
|
||||
interface CreateGlassRippleDynamicsOptions {
|
||||
camera: OrthographicCamera
|
||||
geometry: BufferGeometry
|
||||
quality: GlassRippleQuality
|
||||
renderer: WebGLRenderer
|
||||
three: ThreeModule
|
||||
viewportHeight: number
|
||||
viewportWidth: number
|
||||
}
|
||||
|
||||
const RIPPLE_VERTEX_SHADER = `
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vUv = position.xy * 0.5 + 0.5;
|
||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export const RIPPLE_FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPrevious;
|
||||
uniform vec2 uTexelSize;
|
||||
uniform vec2 uViewportSize;
|
||||
uniform vec2 uImpulseCenter;
|
||||
uniform vec2 uImpulseDirection;
|
||||
uniform float uImpulse;
|
||||
uniform float uImpulseOffset;
|
||||
uniform float uImpulseSigma;
|
||||
uniform float uImpulseSpeed;
|
||||
uniform float uPropagation;
|
||||
uniform float uRestoring;
|
||||
uniform float uVelocityDecay;
|
||||
uniform float uHeightDecay;
|
||||
uniform float uEnergyDecay;
|
||||
uniform float uStep;
|
||||
uniform float uQuality;
|
||||
uniform float uReset;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec3 decodeState(vec4 sampleValue) {
|
||||
if (sampleValue.b < (1.0 / 255.0)) return vec3(0.0);
|
||||
|
||||
return vec3(sampleValue.rg * 2.0 - 1.0, sampleValue.b);
|
||||
}
|
||||
|
||||
float sampleHeight(vec2 offset) {
|
||||
return decodeState(texture2D(uPrevious, clamp(vUv + offset, vec2(0.0), vec2(1.0)))).x;
|
||||
}
|
||||
|
||||
void main() {
|
||||
if (uReset > 0.5) {
|
||||
gl_FragColor = vec4(0.5, 0.5, 0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
vec3 previous = decodeState(texture2D(uPrevious, vUv));
|
||||
float h = previous.x;
|
||||
float velocity = previous.y;
|
||||
float energy = previous.z;
|
||||
float cardinal1 = (
|
||||
sampleHeight(vec2(uTexelSize.x, 0.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, 0.0)) +
|
||||
sampleHeight(vec2(0.0, uTexelSize.y)) +
|
||||
sampleHeight(vec2(0.0, -uTexelSize.y))
|
||||
) * 0.25;
|
||||
float cardinal2 = (
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, 0.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, 0.0)) +
|
||||
sampleHeight(vec2(0.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(0.0, -uTexelSize.y * 2.0))
|
||||
) * 0.25;
|
||||
float diagonal1 = (
|
||||
sampleHeight(vec2(uTexelSize.x, uTexelSize.y)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, uTexelSize.y)) +
|
||||
sampleHeight(vec2(uTexelSize.x, -uTexelSize.y)) +
|
||||
sampleHeight(vec2(-uTexelSize.x, -uTexelSize.y))
|
||||
) * 0.25;
|
||||
float diagonal2 = (
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(uTexelSize.x * 2.0, -uTexelSize.y * 2.0)) +
|
||||
sampleHeight(vec2(-uTexelSize.x * 2.0, -uTexelSize.y * 2.0))
|
||||
) * 0.25;
|
||||
float balancedMean = cardinal1 * 0.72 + cardinal2 * 0.28;
|
||||
float highMean = cardinal1 * 0.46 + diagonal1 * 0.22 + cardinal2 * 0.20 + diagonal2 * 0.12;
|
||||
float curvature = mix(balancedMean, highMean, uQuality) - h;
|
||||
|
||||
float speedResponse = smoothstep(0.0, 1.0, uImpulseSpeed);
|
||||
vec2 shiftedCenter = uImpulseCenter +
|
||||
uImpulseDirection * uImpulseOffset * mix(0.35, 1.0, speedResponse) / max(uViewportSize, vec2(1.0));
|
||||
vec2 impulseDelta = (vUv - shiftedCenter) * uViewportSize;
|
||||
float directionLength = length(uImpulseDirection);
|
||||
vec2 flowDirection = directionLength > 0.0001 ? uImpulseDirection / directionLength : vec2(0.0, 1.0);
|
||||
vec2 flowPerpendicular = vec2(-flowDirection.y, flowDirection.x);
|
||||
float along = dot(impulseDelta, flowDirection);
|
||||
float across = dot(impulseDelta, flowPerpendicular);
|
||||
float directionalRadius = length(vec2(along * 0.72, across * 1.24));
|
||||
float directionality = step(0.0001, directionLength) * mix(0.32, 0.72, speedResponse);
|
||||
float radius = mix(length(impulseDelta), directionalRadius, directionality);
|
||||
float sigma = max(uImpulseSigma * mix(0.86, 1.05, speedResponse), 1.0);
|
||||
float normalizedRadius = radius / sigma;
|
||||
float core = exp(-0.5 * pow(normalizedRadius, 2.0));
|
||||
float ring = exp(-0.5 * pow((radius - 1.6 * sigma) / (0.55 * sigma), 2.0));
|
||||
float centerRelease = smoothstep(0.0, 0.55, normalizedRadius);
|
||||
float annularCore = normalizedRadius * core;
|
||||
float radialImpulse = (0.72 * annularCore - 0.3 * ring) * centerRelease * uImpulse;
|
||||
float wakeEnvelope = exp(-0.5 * (
|
||||
pow(along / (1.25 * sigma), 2.0) +
|
||||
pow(across / (0.72 * sigma), 2.0)
|
||||
));
|
||||
float directionalImpulse = clamp((-along / sigma) * wakeEnvelope * uImpulse * 0.9, -0.62, 0.62);
|
||||
float impulse = clamp(
|
||||
mix(radialImpulse, directionalImpulse, directionality),
|
||||
-0.62,
|
||||
0.62
|
||||
);
|
||||
|
||||
velocity = clamp(
|
||||
(
|
||||
velocity +
|
||||
curvature * uPropagation * uStep -
|
||||
h * uRestoring * uStep +
|
||||
impulse * mix(0.52, 0.82, speedResponse)
|
||||
) * uVelocityDecay,
|
||||
-1.0,
|
||||
1.0
|
||||
);
|
||||
h = clamp((h + velocity * uStep) * uHeightDecay, -1.0, 1.0);
|
||||
energy = clamp(max(max(energy * uEnergyDecay, abs(h)), abs(impulse)), 0.0, 1.0);
|
||||
gl_FragColor = vec4(h * 0.5 + 0.5, velocity * 0.5 + 0.5, energy, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const FRESHNESS_MS = 40
|
||||
const ENVELOPE_THRESHOLD = 0.006
|
||||
const MAX_STEP_MS = 16.667
|
||||
const MIN_STEP_MS = 4
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
function mix(start: number, end: number, progress: number) {
|
||||
return start + (end - start) * progress
|
||||
}
|
||||
|
||||
/** 创建仅由单个 renderer context 持有的 viewport-space 水漾场。 */
|
||||
export async function createGlassRippleDynamics(
|
||||
options: CreateGlassRippleDynamicsOptions,
|
||||
): Promise<GlassRippleDynamics> {
|
||||
const { camera, geometry, renderer, three } = options
|
||||
const quality = options.quality
|
||||
let viewportWidth = Math.max(1, options.viewportWidth)
|
||||
let viewportHeight = Math.max(1, options.viewportHeight)
|
||||
let translation = 0.5
|
||||
let flow = 0.5
|
||||
let energyAtInput = 0
|
||||
let lastInputAt = Number.NEGATIVE_INFINITY
|
||||
let deadlineAt = Number.NEGATIVE_INFINITY
|
||||
let lastStepAt = 0
|
||||
let pendingImpulse = 0
|
||||
let pendingSpeed = 0
|
||||
let impulseCenter = { x: 0.5, y: 0.5 }
|
||||
let impulseDirection = { x: 0, y: 1 }
|
||||
let pendingDirection = { x: 0, y: 0 }
|
||||
let clearOnNextFrame = false
|
||||
let fieldActive = false
|
||||
let disposed = false
|
||||
const targetType = renderer.extensions?.has?.('EXT_color_buffer_float') ? three.HalfFloatType : three.UnsignedByteType
|
||||
|
||||
const createTarget = () => {
|
||||
const target = new three.WebGLRenderTarget(1, 1, {
|
||||
depthBuffer: false,
|
||||
format: three.RGBAFormat,
|
||||
magFilter: three.LinearFilter,
|
||||
minFilter: three.LinearFilter,
|
||||
stencilBuffer: false,
|
||||
type: targetType,
|
||||
wrapS: three.ClampToEdgeWrapping,
|
||||
wrapT: three.ClampToEdgeWrapping,
|
||||
})
|
||||
target.texture.generateMipmaps = false
|
||||
|
||||
return target
|
||||
}
|
||||
let readTarget = createTarget()
|
||||
let writeTarget = createTarget()
|
||||
const uniforms: GlassRippleUniforms = {
|
||||
uEnergyDecay: { value: 1 },
|
||||
uHeightDecay: { value: 1 },
|
||||
uImpulse: { value: 0 },
|
||||
uImpulseCenter: { value: new three.Vector2(0.5, 0.5) },
|
||||
uImpulseDirection: { value: new three.Vector2(0, 1) },
|
||||
uImpulseOffset: { value: 0 },
|
||||
uImpulseSigma: { value: 24 },
|
||||
uImpulseSpeed: { value: 0 },
|
||||
uPrevious: { value: null },
|
||||
uPropagation: { value: 0.18 },
|
||||
uQuality: { value: quality === 'high' ? 1 : 0 },
|
||||
uReset: { value: 1 },
|
||||
uRestoring: { value: quality === 'high' ? 0.028 : 0.035 },
|
||||
uStep: { value: 1 },
|
||||
uTexelSize: { value: new three.Vector2(1, 1) },
|
||||
uVelocityDecay: { value: 1 },
|
||||
uViewportSize: { value: new three.Vector2(viewportWidth, viewportHeight) },
|
||||
}
|
||||
const material = new three.ShaderMaterial({
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fragmentShader: RIPPLE_FRAGMENT_SHADER,
|
||||
uniforms,
|
||||
vertexShader: RIPPLE_VERTEX_SHADER,
|
||||
})
|
||||
const scene = new three.Scene()
|
||||
const mesh = new three.Mesh(geometry, material)
|
||||
mesh.frustumCulled = false
|
||||
scene.add(mesh)
|
||||
|
||||
const renderTarget = (target: WebGLRenderTarget) => {
|
||||
const previousTarget = renderer.getRenderTarget()
|
||||
|
||||
try {
|
||||
renderer.setScissorTest(false)
|
||||
renderer.setRenderTarget(target)
|
||||
renderer.render(scene, camera)
|
||||
} finally {
|
||||
renderer.setRenderTarget(previousTarget)
|
||||
}
|
||||
}
|
||||
|
||||
const writeNeutralTargets = () => {
|
||||
uniforms.uReset.value = 1
|
||||
uniforms.uPrevious.value = null
|
||||
renderTarget(readTarget)
|
||||
renderTarget(writeTarget)
|
||||
uniforms.uReset.value = 0
|
||||
}
|
||||
|
||||
const getTargetSize = (width: number, height: number) => {
|
||||
const scale =
|
||||
quality === 'high'
|
||||
? Math.min(1, Math.max(0.25, 192 / width, 128 / height))
|
||||
: Math.min(1, Math.max(0.16, 128 / width, 96 / height))
|
||||
|
||||
return {
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
const resize = (width: number, height: number) => {
|
||||
if (disposed) return false
|
||||
const nextViewportWidth = Math.max(1, width)
|
||||
const nextViewportHeight = Math.max(1, height)
|
||||
const target = getTargetSize(nextViewportWidth, nextViewportHeight)
|
||||
const viewportChanged = viewportWidth !== nextViewportWidth || viewportHeight !== nextViewportHeight
|
||||
const targetChanged = readTarget.width !== target.width || readTarget.height !== target.height
|
||||
if (!viewportChanged && !targetChanged) return false
|
||||
|
||||
viewportWidth = nextViewportWidth
|
||||
viewportHeight = nextViewportHeight
|
||||
if (targetChanged) {
|
||||
readTarget.setSize(target.width, target.height)
|
||||
writeTarget.setSize(target.width, target.height)
|
||||
}
|
||||
uniforms.uTexelSize.value.set(1 / target.width, 1 / target.height)
|
||||
uniforms.uViewportSize.value.set(viewportWidth, viewportHeight)
|
||||
reset()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getVelocityHalfLife = () => (quality === 'high' ? mix(90, 280, flow) : mix(70, 220, flow))
|
||||
|
||||
const getDeadlineDuration = () => FRESHNESS_MS + (quality === 'high' ? mix(220, 920, flow) : mix(160, 680, flow))
|
||||
|
||||
const settleEnvelope = (timestamp: number) => {
|
||||
if (!Number.isFinite(lastInputAt)) return 0
|
||||
const freshReleaseAge = Math.max(0, timestamp - lastInputAt - FRESHNESS_MS)
|
||||
const deadlineTaper = clamp01((deadlineAt - timestamp) / FRESHNESS_MS)
|
||||
|
||||
return energyAtInput * 2 ** (-freshReleaseAge / getVelocityHalfLife()) * deadlineTaper
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
if (disposed) return
|
||||
writeNeutralTargets()
|
||||
energyAtInput = 0
|
||||
lastInputAt = Number.NEGATIVE_INFINITY
|
||||
deadlineAt = Number.NEGATIVE_INFINITY
|
||||
lastStepAt = 0
|
||||
pendingImpulse = 0
|
||||
pendingSpeed = 0
|
||||
pendingDirection = { x: 0, y: 0 }
|
||||
impulseDirection = { x: 0, y: 1 }
|
||||
clearOnNextFrame = false
|
||||
fieldActive = false
|
||||
}
|
||||
|
||||
try {
|
||||
const initializedByResize = resize(viewportWidth, viewportHeight)
|
||||
await renderer.compileAsync(scene, camera)
|
||||
if (disposed) throw new Error('Ripple resources were disposed during compilation')
|
||||
if (!initializedByResize) reset()
|
||||
} catch (error) {
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
material.dispose()
|
||||
readTarget.dispose()
|
||||
writeTarget.dispose()
|
||||
},
|
||||
inject(interaction) {
|
||||
if (disposed) return
|
||||
const timestamp = interaction.timestamp
|
||||
const previousEnvelope = settleEnvelope(timestamp)
|
||||
const inputAmplitude = Math.min(0.8, Math.max(0.22, 0.22 + clamp01(interaction.speed) * 0.58))
|
||||
energyAtInput = Math.max(previousEnvelope, inputAmplitude)
|
||||
lastInputAt = timestamp
|
||||
deadlineAt = timestamp + getDeadlineDuration()
|
||||
pendingImpulse = Math.max(pendingImpulse, inputAmplitude)
|
||||
pendingSpeed = Math.max(pendingSpeed, clamp01(interaction.speed))
|
||||
impulseCenter = { x: clamp01(interaction.point.x), y: clamp01(interaction.point.y) }
|
||||
const directionLength = Math.hypot(interaction.direction.x, interaction.direction.y)
|
||||
if (directionLength > 0.0001) {
|
||||
pendingDirection.x += interaction.direction.x / directionLength
|
||||
pendingDirection.y += interaction.direction.y / directionLength
|
||||
}
|
||||
fieldActive = true
|
||||
clearOnNextFrame = false
|
||||
},
|
||||
get texture() {
|
||||
return fieldActive && !disposed ? readTarget.texture : null
|
||||
},
|
||||
get texelSize() {
|
||||
return uniforms.uTexelSize.value
|
||||
},
|
||||
setParameters(translationStrength, flowStrength) {
|
||||
translation = clamp01(translationStrength / 100)
|
||||
flow = clamp01(flowStrength / 100)
|
||||
},
|
||||
resize,
|
||||
reset,
|
||||
step(timestamp) {
|
||||
if (disposed || !fieldActive) return false
|
||||
if (clearOnNextFrame || timestamp >= deadlineAt || settleEnvelope(timestamp) < ENVELOPE_THRESHOLD) {
|
||||
reset()
|
||||
return false
|
||||
}
|
||||
|
||||
const elapsed = lastStepAt > 0 ? Math.max(0, timestamp - lastStepAt) : MAX_STEP_MS
|
||||
const simulatedElapsed = Math.min(MAX_STEP_MS * 2, Math.max(MIN_STEP_MS, elapsed))
|
||||
const substeps = simulatedElapsed > MAX_STEP_MS ? 2 : 1
|
||||
const stepMs = Math.min(MAX_STEP_MS, Math.max(MIN_STEP_MS, simulatedElapsed / substeps))
|
||||
const decayStepMs = elapsed / substeps
|
||||
const velocityHalfLife = getVelocityHalfLife()
|
||||
const heightHalfLife = velocityHalfLife * 0.82
|
||||
const energyHalfLife = velocityHalfLife * 0.72
|
||||
const targetCssPerTexel = Math.sqrt(
|
||||
(viewportWidth / Math.max(readTarget.width, 1)) * (viewportHeight / Math.max(readTarget.height, 1)),
|
||||
)
|
||||
const referenceCssPerTexel = quality === 'high' ? 4 : 6.25
|
||||
const basePropagation = quality === 'high' ? mix(0.11, 0.16, translation) : mix(0.12, 0.18, translation)
|
||||
|
||||
if (pendingImpulse > 0) {
|
||||
const directionLength = Math.hypot(pendingDirection.x, pendingDirection.y)
|
||||
impulseDirection =
|
||||
directionLength > 0.0001
|
||||
? { x: pendingDirection.x / directionLength, y: pendingDirection.y / directionLength }
|
||||
: { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
uniforms.uImpulseCenter.value.set(impulseCenter.x, impulseCenter.y)
|
||||
uniforms.uImpulseDirection.value.set(impulseDirection.x, impulseDirection.y)
|
||||
uniforms.uImpulseOffset.value = 56 * translation
|
||||
uniforms.uImpulseSpeed.value = pendingSpeed
|
||||
// 质量档把额外预算用于场分辨率和衰减细节;输入范围保持稳定,避免高质量改变动态效果的空间语义。
|
||||
uniforms.uImpulseSigma.value = mix(54, 97.2, translation)
|
||||
uniforms.uPropagation.value = Math.min(
|
||||
0.18,
|
||||
Math.max(0.08, basePropagation * (referenceCssPerTexel / targetCssPerTexel) ** 2),
|
||||
)
|
||||
uniforms.uRestoring.value = quality === 'high' ? 0.028 : 0.035
|
||||
uniforms.uStep.value = stepMs / MAX_STEP_MS
|
||||
uniforms.uVelocityDecay.value = 2 ** (-decayStepMs / velocityHalfLife)
|
||||
uniforms.uHeightDecay.value = 2 ** (-decayStepMs / heightHalfLife)
|
||||
uniforms.uEnergyDecay.value = 2 ** (-decayStepMs / energyHalfLife)
|
||||
|
||||
for (let index = 0; index < substeps; index += 1) {
|
||||
uniforms.uPrevious.value = readTarget.texture
|
||||
uniforms.uImpulse.value = index === 0 ? pendingImpulse : 0
|
||||
renderTarget(writeTarget)
|
||||
const previousReadTarget = readTarget
|
||||
readTarget = writeTarget
|
||||
writeTarget = previousReadTarget
|
||||
}
|
||||
pendingImpulse = 0
|
||||
pendingSpeed = 0
|
||||
pendingDirection = { x: 0, y: 0 }
|
||||
lastStepAt = timestamp
|
||||
if (flow <= 0) clearOnNextFrame = true
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,59 @@ describe('glass overlay material styles', () => {
|
||||
expect(styles).not.toContain('background: rgba(3, 7, 18, 62%)')
|
||||
})
|
||||
|
||||
it('renders colored chips as shadowless glass without flattening their variants', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
expect(styles).toContain('--glass-chip-backdrop-filter')
|
||||
expect(styles).toContain('--glass-chip-sheen')
|
||||
expect(styles).toMatch(/\.v-chip\s*\{\s*box-shadow:\s*none\s*!important;\s*\}/)
|
||||
expect(styles).toMatch(
|
||||
/\.v-chip:is\(\.v-chip--variant-elevated, \.v-chip--variant-flat, \.v-chip--variant-tonal\)\s*\{[\s\S]*?backdrop-filter:\s*var\(--glass-chip-backdrop-filter\)\s*!important;[\s\S]*?background-image:\s*var\(--glass-chip-sheen\);/,
|
||||
)
|
||||
expect(styles).toMatch(
|
||||
/\.v-chip\[class\*='bg-'\]\s*\{[\s\S]*?--tw-bg-opacity:\s*var\(--glass-chip-tint-opacity\)\s*!important;/,
|
||||
)
|
||||
expect(styles).toContain('.v-chip.chip-resolution')
|
||||
expect(styles).toContain(
|
||||
'background-color: rgba(var(--glass-chip-tint), var(--glass-chip-tint-opacity)) !important',
|
||||
)
|
||||
expect(styles).not.toContain('.v-chip::after')
|
||||
expect(styles).not.toContain(".v-chip:not([class*='border-'])")
|
||||
expect(styles).not.toContain('.v-chip--variant-tonal > .v-chip__underlay')
|
||||
expect(styles).not.toMatch(/\.v-chip--variant-(?:outlined|text|plain)\s*\{/)
|
||||
})
|
||||
|
||||
it('keeps workflow share gradients as colored glass in every appearance', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
const card = readFileSync(resolve(cwd(), 'src/components/cards/WorkflowShareCard.vue'), 'utf8')
|
||||
|
||||
expect(card).toContain('--workflow-share-gradient-start-rgb')
|
||||
expect(card).toContain('--workflow-share-gradient-end-rgb')
|
||||
const ruleStart = styles.indexOf('.workflow-share-card {')
|
||||
const ruleEnd = styles.indexOf('\n }', ruleStart)
|
||||
const workflowShareCardRule = styles.slice(ruleStart, ruleEnd)
|
||||
const expectedLayers = [
|
||||
'background-image:',
|
||||
'var(--glass-sheen),',
|
||||
'var(--workflow-share-glass-scrim),',
|
||||
'var(--workflow-share-gradient-start-rgb,',
|
||||
'var(--workflow-share-gradient-end-rgb,',
|
||||
') !important;',
|
||||
]
|
||||
|
||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||
let previousLayerIndex = -1
|
||||
for (const layer of expectedLayers) {
|
||||
const layerIndex = workflowShareCardRule.indexOf(layer, previousLayerIndex + 1)
|
||||
|
||||
expect(layerIndex).toBeGreaterThan(previousLayerIndex)
|
||||
previousLayerIndex = layerIndex
|
||||
}
|
||||
expect(styles).toContain("&[data-glass-appearance='frosted'] .workflow-share-card")
|
||||
expect(styles).toContain("&[data-glass-appearance='tinted'] .workflow-share-card")
|
||||
})
|
||||
|
||||
it('composites glass dialogs at their final geometry instead of resampling a scaled backdrop', () => {
|
||||
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||
|
||||
|
||||
+23
-1
@@ -589,6 +589,12 @@ html[data-theme-radius='extra'] {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// 左右布局的单行字段将值放在右栏中央;整行字段会在移动端规则中恢复左对齐。
|
||||
.app-responsive-input--field .v-field__input {
|
||||
justify-content: var(--app-responsive-input-value-alignment, center);
|
||||
text-align: var(--app-responsive-input-value-alignment, center);
|
||||
}
|
||||
|
||||
// 移动端单行字段内容超出可视宽度时保持一行并显示省略号。
|
||||
.app-responsive-input--field .v-field__input {
|
||||
overflow: hidden;
|
||||
@@ -612,6 +618,19 @@ html[data-theme-radius='extra'] {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-responsive-input--field :is(input.v-field__input, .v-field__input > input),
|
||||
.app-responsive-input--field :is(input.v-field__input, .v-field__input > input)::placeholder {
|
||||
text-align: var(--app-responsive-input-value-alignment, center);
|
||||
}
|
||||
|
||||
.app-responsive-input--field
|
||||
.app-responsive-input__native:is(.v-select--single, .v-autocomplete--single, .v-combobox--single)
|
||||
:is(.v-select__selection, .v-autocomplete__selection, .v-combobox__selection) {
|
||||
inline-size: 100%;
|
||||
justify-content: var(--app-responsive-input-value-alignment, center);
|
||||
text-align: var(--app-responsive-input-value-alignment, center);
|
||||
}
|
||||
|
||||
.app-responsive-input__native:is(.v-select--multiple, .v-autocomplete--multiple, .v-combobox--multiple)
|
||||
.v-field__input {
|
||||
overflow: visible;
|
||||
@@ -663,12 +682,13 @@ html[data-theme-radius='extra'] {
|
||||
.app-responsive-input--multiline .v-field__input {
|
||||
min-block-size: 5.5rem;
|
||||
align-items: start;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.app-responsive-input--choice .app-responsive-input__control,
|
||||
.app-responsive-input--choice .v-input__control,
|
||||
.app-responsive-input--group .v-selection-control-group {
|
||||
justify-content: end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-responsive-input--choice .v-input__control {
|
||||
@@ -748,6 +768,8 @@ html[data-theme-radius='extra'] {
|
||||
.v-col-md-12:not([class*='v-col-lg-']):not([class*='v-col-xl-']):not([class*='v-col-xxl-'])
|
||||
)
|
||||
> :is(.app-responsive-input--field, .app-responsive-input--range) {
|
||||
--app-responsive-input-value-alignment: start;
|
||||
|
||||
row-gap: 0.5rem;
|
||||
align-items: start;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -63,6 +63,16 @@ html[data-theme='glass'] {
|
||||
--glass-control-shortcut-background: rgba(255, 255, 255, 8%);
|
||||
--glass-control-shortcut-border: rgba(255, 255, 255, 12%);
|
||||
--glass-control-shortcut-color: rgba(242, 245, 250, 68%);
|
||||
// Chip 面积很小,使用更明显的镜面层与色相透光,避免标签在背景采样中变成灰色。
|
||||
--glass-chip-backdrop-filter: blur(8px) saturate(150%) brightness(var(--glass-transmission-brightness));
|
||||
--glass-chip-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 12%),
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 3%) 72%,
|
||||
transparent
|
||||
);
|
||||
--glass-chip-tint-opacity: calc(0.22 + var(--glass-tint-density, 0.65) * 0.18);
|
||||
--glass-button-surface: rgba(255, 255, 255, 8%);
|
||||
--glass-button-surface-hover: rgba(255, 255, 255, 12%);
|
||||
--glass-nav-active-background: linear-gradient(
|
||||
@@ -179,6 +189,7 @@ html[data-theme='glass'] {
|
||||
--glass-overlay-blur: 12px;
|
||||
--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));
|
||||
}
|
||||
|
||||
// 磨砂材质使用更亮的散射表面,但仍只让高价值层执行实时背景采样。
|
||||
@@ -220,6 +231,14 @@ html[data-theme='glass'] {
|
||||
--glass-overlay-blur: min(var(--glass-blur-raised), 36px);
|
||||
--glass-overlay-saturate: 135%;
|
||||
--glass-overlay-scrim: rgba(3, 7, 18, 36%);
|
||||
--glass-chip-backdrop-filter: blur(14px) saturate(175%) brightness(var(--glass-transmission-brightness));
|
||||
--glass-chip-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 14%),
|
||||
transparent 36%,
|
||||
rgba(255, 255, 255, 5%) 72%,
|
||||
transparent
|
||||
);
|
||||
--glass-blur-surface: 40px;
|
||||
--glass-blur: 40px;
|
||||
--glass-blur-raised: 60px;
|
||||
@@ -821,9 +840,113 @@ html[data-theme='glass'] {
|
||||
var(--glass-control-shadow) !important;
|
||||
}
|
||||
|
||||
// 只添加材质层,不接管 Chip 的 variant、圆角、边框、尺寸或选中结构。
|
||||
.v-chip {
|
||||
border-color: rgba(255, 255, 255, 16%);
|
||||
background-color: rgba(255, 255, 255, 7%);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
// 仅填充型 Chip 增加镜面方向;outlined、text、plain 保持原来的空心或无底形态。
|
||||
.v-chip:is(.v-chip--variant-elevated, .v-chip--variant-flat, .v-chip--variant-tonal) {
|
||||
-webkit-backdrop-filter: var(--glass-chip-backdrop-filter) !important;
|
||||
backdrop-filter: var(--glass-chip-backdrop-filter) !important;
|
||||
background-image: var(--glass-chip-sheen);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
// Tailwind 的 bg-* 通过 --tw-bg-opacity 输出颜色;只调透明度即可保留每个业务色相。
|
||||
.v-chip[class*='bg-'] {
|
||||
--tw-bg-opacity: var(--glass-chip-tint-opacity) !important;
|
||||
}
|
||||
|
||||
// Vuetify 语义色使用同一套有色玻璃透明度。
|
||||
.v-chip.bg-primary {
|
||||
background-color: rgba(var(--v-theme-primary), 34%) !important;
|
||||
}
|
||||
|
||||
.v-chip.bg-secondary {
|
||||
background-color: rgba(var(--v-theme-secondary), 34%) !important;
|
||||
}
|
||||
|
||||
.v-chip.bg-success {
|
||||
background-color: rgba(var(--v-theme-success), 34%) !important;
|
||||
}
|
||||
|
||||
.v-chip.bg-info {
|
||||
background-color: rgba(var(--v-theme-info), 34%) !important;
|
||||
}
|
||||
|
||||
.v-chip.bg-warning {
|
||||
background-color: rgba(var(--v-theme-warning), 34%) !important;
|
||||
}
|
||||
|
||||
.v-chip.bg-error {
|
||||
background-color: rgba(var(--v-theme-error), 34%) !important;
|
||||
}
|
||||
|
||||
// 种子卡片沿用组件既有的业务色,只降低不透明度并补上透光与镜面材质。
|
||||
.v-chip.chip-season {
|
||||
--glass-chip-tint: 63, 81, 181;
|
||||
}
|
||||
|
||||
.v-chip.chip-web-source {
|
||||
--glass-chip-tint: 128, 0, 255;
|
||||
}
|
||||
|
||||
.v-chip.chip-edition {
|
||||
--glass-chip-tint: 244, 67, 54;
|
||||
}
|
||||
|
||||
.v-chip.chip-resolution {
|
||||
--glass-chip-tint: 123, 31, 162;
|
||||
}
|
||||
|
||||
.v-chip.chip-codec {
|
||||
--glass-chip-tint: 255, 152, 0;
|
||||
}
|
||||
|
||||
.v-chip.chip-team {
|
||||
--glass-chip-tint: 0, 137, 123;
|
||||
}
|
||||
|
||||
.v-chip.chip-label {
|
||||
--glass-chip-tint: 92, 107, 192;
|
||||
}
|
||||
|
||||
.v-chip.chip-hr {
|
||||
--glass-chip-tint: 33, 33, 33;
|
||||
}
|
||||
|
||||
.v-chip.chip-expire {
|
||||
--glass-chip-tint: 126, 87, 194;
|
||||
}
|
||||
|
||||
.v-chip.chip-free {
|
||||
--glass-chip-tint: 76, 175, 80;
|
||||
}
|
||||
|
||||
.v-chip.chip-discount {
|
||||
--glass-chip-tint: 255, 87, 34;
|
||||
}
|
||||
|
||||
.v-chip.chip-bonus {
|
||||
--glass-chip-tint: 156, 39, 176;
|
||||
}
|
||||
|
||||
.v-chip:where(
|
||||
.chip-season,
|
||||
.chip-web-source,
|
||||
.chip-edition,
|
||||
.chip-resolution,
|
||||
.chip-codec,
|
||||
.chip-team,
|
||||
.chip-label,
|
||||
.chip-hr,
|
||||
.chip-expire,
|
||||
.chip-free,
|
||||
.chip-discount,
|
||||
.chip-bonus
|
||||
) {
|
||||
background-color: rgba(var(--glass-chip-tint), var(--glass-chip-tint-opacity)) !important;
|
||||
}
|
||||
|
||||
.v-table {
|
||||
@@ -852,6 +975,49 @@ html[data-theme='glass'] {
|
||||
backdrop-filter: var(--glass-surface-backdrop-filter);
|
||||
}
|
||||
|
||||
// 工作流标题栏复用卡片的背景采样,只叠加状态色、吸收层与玻璃高光。
|
||||
.workflow-task-card {
|
||||
--workflow-card-header-content-rgb: var(--v-theme-on-surface);
|
||||
--workflow-card-header-background:
|
||||
var(--glass-sheen),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
rgba(var(--workflow-status-rgb), calc(0.16 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
||||
rgba(var(--workflow-status-rgb), calc(0.09 + var(--glass-tint-density, 0.65) * 0.22)) 58%,
|
||||
rgba(var(--workflow-status-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
rgba(var(--v-theme-background), calc(0.08 + var(--glass-surface-density, 0.62) * 0.08)),
|
||||
rgba(var(--v-theme-background), calc(0.14 + var(--glass-surface-density, 0.62) * 0.12))
|
||||
);
|
||||
--workflow-card-header-shadow: inset 0 1px 0 var(--glass-highlight), inset 0 -1px 0 rgba(2, 6, 16, 14%);
|
||||
}
|
||||
|
||||
// 分享卡保留原随机渐变的色相,以半透明染色、吸光和镜面层组成有色玻璃。
|
||||
.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))
|
||||
);
|
||||
|
||||
background-color: var(--glass-surface) !important;
|
||||
background-image:
|
||||
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-end-rgb, 45, 55, 72), var(--workflow-share-glass-end-opacity)) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
// 插件卡片头部改为染色玻璃:图标主色只作为透光色相,压暗遮罩换成玻璃吸收与高光。
|
||||
.plugin-card__banner {
|
||||
border-block-end: 1px solid var(--glass-border);
|
||||
@@ -896,6 +1062,31 @@ html[data-theme='glass'] {
|
||||
);
|
||||
}
|
||||
|
||||
&[data-glass-appearance='frosted'] .workflow-task-card {
|
||||
--workflow-card-header-background:
|
||||
var(--glass-sheen),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
rgba(var(--workflow-status-rgb), calc(0.1 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
||||
rgba(var(--workflow-status-rgb), calc(0.05 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
||||
rgba(var(--workflow-status-rgb), calc(0.02 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
rgba(var(--v-theme-background), calc(0.04 + var(--glass-surface-density, 0.86) * 0.04)),
|
||||
rgba(var(--v-theme-background), calc(0.08 + var(--glass-surface-density, 0.86) * 0.08))
|
||||
);
|
||||
}
|
||||
|
||||
&[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))
|
||||
);
|
||||
}
|
||||
|
||||
// 色调材质本身带主色语义,头部染色向主色收敛保持整页同一色温。
|
||||
&[data-glass-appearance='tinted'] .plugin-card__banner {
|
||||
--plugin-card-banner-tint: linear-gradient(
|
||||
@@ -916,6 +1107,41 @@ html[data-theme='glass'] {
|
||||
);
|
||||
}
|
||||
|
||||
&[data-glass-appearance='tinted'] .workflow-task-card {
|
||||
--workflow-card-header-background:
|
||||
var(--glass-sheen),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--workflow-status-rgb), 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))
|
||||
)
|
||||
0%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
rgba(var(--workflow-status-rgb), 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))
|
||||
)
|
||||
58%,
|
||||
rgba(var(--workflow-status-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
rgba(var(--v-theme-background), calc(0.08 + var(--glass-surface-density, 0.72) * 0.08)),
|
||||
rgba(var(--v-theme-background), calc(0.14 + var(--glass-surface-density, 0.72) * 0.12))
|
||||
);
|
||||
}
|
||||
|
||||
&[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))
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹卡片保留用户自选渐变作为色相,只降低不透明度让卡片本体的玻璃透出来。
|
||||
.plugin-folder-card__bg {
|
||||
opacity: calc(0.34 + var(--glass-surface-density, 0.62) * 0.145);
|
||||
|
||||
@@ -49,6 +49,24 @@ html[data-theme="transparent"] {
|
||||
}
|
||||
}
|
||||
|
||||
// 工作流标题栏只叠加半透明状态染色,底层模糊仍由卡片统一承担。
|
||||
.workflow-task-card {
|
||||
--workflow-card-header-content-rgb: var(--v-theme-on-surface);
|
||||
--workflow-card-header-background:
|
||||
linear-gradient(145deg, rgba(var(--v-theme-on-surface), 0.12), transparent 36%),
|
||||
linear-gradient(
|
||||
118deg,
|
||||
rgba(var(--workflow-status-rgb), var(--transparent-opacity-heavy)) 0%,
|
||||
rgba(var(--workflow-status-rgb), var(--transparent-opacity)) 56%,
|
||||
rgba(var(--workflow-status-rgb), var(--transparent-opacity-light)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
rgba(var(--v-theme-surface), var(--transparent-opacity-light)),
|
||||
rgba(var(--v-theme-surface), var(--transparent-opacity-light))
|
||||
);
|
||||
--workflow-card-header-shadow: inset 0 1px 0 rgba(var(--v-theme-on-surface), 0.14);
|
||||
}
|
||||
|
||||
// 订阅文件弹窗使用独立的玻璃表面参数,避免组件内通过全局选择器穿透主题。
|
||||
.subscribe-files-dialog {
|
||||
--sfd-accent-opacity: 0.12;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import { getPluginTabs } from '@/router/i18n-menu'
|
||||
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -43,7 +42,7 @@ interface PluginFolderConfig {
|
||||
|
||||
type PluginFolderEntry = PluginFolderConfig | string[]
|
||||
type PluginFolderMap = Record<string, PluginFolderEntry>
|
||||
type PluginSortKey = 'count' | 'plugin_name' | 'plugin_author' | 'repo_url' | 'add_time'
|
||||
type PluginSortKey = 'count' | 'average_rating' | 'plugin_name' | 'plugin_author' | 'repo_url' | 'add_time'
|
||||
|
||||
// 市场卡片、拖拽排序和市场设置只在对应标签/操作中需要,延迟到真正使用时加载。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
@@ -152,6 +151,7 @@ const orderConfig = ref<PluginOrderItem[]>([])
|
||||
// 排序选项
|
||||
const sortOptions = computed<{ title: string; value: PluginSortKey }[]>(() => [
|
||||
{ title: t('plugin.sort.popular'), value: 'count' },
|
||||
{ title: t('plugin.sort.rating'), value: 'average_rating' },
|
||||
{ title: t('plugin.sort.name'), value: 'plugin_name' },
|
||||
{ title: t('plugin.sort.author'), value: 'plugin_author' },
|
||||
{ title: t('plugin.sort.repository'), value: 'repo_url' },
|
||||
@@ -938,6 +938,7 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||
})
|
||||
if (generation !== installedWriterGeneration) return
|
||||
|
||||
mergeRatingsIntoPlugins(installedPlugins)
|
||||
dataList.value = installedPlugins
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 排序
|
||||
@@ -954,12 +955,14 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}) {
|
||||
|
||||
/** 将市场更新元数据投影到当前已安装快照。 */
|
||||
function mergeMarketMetadataIntoInstalled() {
|
||||
const marketById = new Map(uninstalledList.value.map(plugin => [plugin.id, plugin]))
|
||||
const marketById = new Map(
|
||||
uninstalledList.value.filter(plugin => plugin.has_update).map(plugin => [plugin.id, plugin]),
|
||||
)
|
||||
dataList.value.forEach(plugin => {
|
||||
const marketPlugin = marketById.get(plugin.id)
|
||||
plugin.has_update = Boolean(marketPlugin)
|
||||
if (!marketPlugin) return
|
||||
|
||||
plugin.has_update = true
|
||||
plugin.repo_url = marketPlugin.repo_url
|
||||
plugin.history = marketPlugin.history
|
||||
plugin.system_version = marketPlugin.system_version
|
||||
@@ -984,6 +987,7 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
|
||||
})
|
||||
if (generation !== marketWriterGeneration) return
|
||||
|
||||
mergeRatingsIntoPlugins(marketResponse)
|
||||
uninstalledList.value = marketResponse
|
||||
mergeMarketMetadataIntoInstalled()
|
||||
// 更新插件市场列表
|
||||
@@ -1050,29 +1054,62 @@ async function getPluginRatings() {
|
||||
|
||||
PluginRatings.value = ratings
|
||||
|
||||
for (const plugin of [...dataList.value, ...uninstalledList.value, ...marketList.value]) {
|
||||
const pluginRating = ratings[plugin.id]
|
||||
if (!pluginRating) continue
|
||||
plugin.average_rating = pluginRating.average_rating
|
||||
plugin.rating_count = pluginRating.rating_count
|
||||
plugin.user_rating = pluginRating.user_rating
|
||||
}
|
||||
mergeRatingsIntoPlugins([...dataList.value, ...uninstalledList.value, ...marketList.value], ratings, true)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载量与评分属于同一份市场指标快照,始终在同一刷新时机加载。 */
|
||||
async function getPluginMarketMetrics() {
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
}
|
||||
|
||||
/** 新列表写入前复用最近一次评分快照,避免静默刷新期间评分闪烁。 */
|
||||
function mergeRatingsIntoPlugins(
|
||||
plugins: Plugin[],
|
||||
ratings: Record<string, PluginRating> = PluginRatings.value,
|
||||
overwrite = false,
|
||||
) {
|
||||
for (const plugin of plugins) {
|
||||
const pluginRating = ratings[plugin.id]
|
||||
if (!pluginRating) continue
|
||||
if (overwrite || plugin.average_rating === undefined) plugin.average_rating = pluginRating.average_rating
|
||||
if (overwrite || plugin.rating_count === undefined) plugin.rating_count = pluginRating.rating_count
|
||||
if (overwrite || plugin.user_rating === undefined) plugin.user_rating = pluginRating.user_rating
|
||||
}
|
||||
}
|
||||
|
||||
/** 评分提交接口已返回新值,只回写当前插件,避免重新加载全部市场指标。 */
|
||||
function applyPluginRating(pluginRating: PluginRating) {
|
||||
const pluginId = pluginRating.plugin_id
|
||||
if (!pluginId) return
|
||||
|
||||
PluginRatings.value = {
|
||||
...PluginRatings.value,
|
||||
[pluginId]: pluginRating,
|
||||
}
|
||||
|
||||
mergeRatingsIntoPlugins(
|
||||
[...dataList.value, ...uninstalledList.value, ...marketList.value],
|
||||
{
|
||||
[pluginId]: pluginRating,
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
async function refreshData(context: KeepAliveRefreshContext = {}) {
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
// 重新加载文件夹配置,确保分身插件能正确显示在文件夹中
|
||||
await loadPluginFolders()
|
||||
}
|
||||
|
||||
// 对uninstalledList进行排序到sortedUninstalledList
|
||||
watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||||
watch([marketList, filterForm, activeSort, PluginStatistics, PluginRatings], () => {
|
||||
// 匹配过滤函数
|
||||
const match = (filter: Array<string>, value: unknown) => {
|
||||
const text = normalizeMarketText(value).trim()
|
||||
@@ -1107,23 +1144,27 @@ watch([marketList, filterForm, activeSort, PluginStatistics], () => {
|
||||
})
|
||||
|
||||
// 排序
|
||||
if (!isNullOrEmptyObject(PluginStatistics.value)) {
|
||||
if (!activeSort.value || activeSort.value === 'count') {
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
|
||||
})
|
||||
} else if (activeSort.value) {
|
||||
const sortKey = activeSort.value
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
if (sortKey === 'add_time') {
|
||||
return a.add_time !== undefined && b.add_time !== undefined && a.add_time > b.add_time ? 1 : -1
|
||||
}
|
||||
const sortKey = activeSort.value || 'count'
|
||||
if (sortKey === 'count') {
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
return (PluginStatistics.value[b.id || '0'] ?? 0) - (PluginStatistics.value[a.id || '0'] ?? 0)
|
||||
})
|
||||
} else if (sortKey === 'average_rating') {
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
const aRating = PluginRatings.value[a.id]?.average_rating ?? a.average_rating ?? 0
|
||||
const bRating = PluginRatings.value[b.id]?.average_rating ?? b.average_rating ?? 0
|
||||
return bRating - aRating
|
||||
})
|
||||
} else {
|
||||
sortedUninstalledList.value = sortedUninstalledList.value.sort((a, b) => {
|
||||
if (sortKey === 'add_time') {
|
||||
return a.add_time !== undefined && b.add_time !== undefined && a.add_time > b.add_time ? 1 : -1
|
||||
}
|
||||
|
||||
const aValue = a[sortKey]
|
||||
const bValue = b[sortKey]
|
||||
return aValue !== undefined && bValue !== undefined && aValue > bValue ? 1 : -1
|
||||
})
|
||||
}
|
||||
const aValue = a[sortKey]
|
||||
const bValue = b[sortKey]
|
||||
return aValue !== undefined && bValue !== undefined && aValue > bValue ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
// 显示前20个
|
||||
@@ -1150,7 +1191,7 @@ async function refreshMarket() {
|
||||
isMarketRefreshing.value = true
|
||||
try {
|
||||
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
@@ -1163,13 +1204,13 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
|
||||
|
||||
if (activeTab.value === 'market') {
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
await fetchInstalledPlugins(context)
|
||||
await fetchUninstalledPlugins(false, context)
|
||||
await Promise.all([getPluginStatistics(), getPluginRatings()])
|
||||
await getPluginMarketMetrics()
|
||||
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
|
||||
await loadPluginFolders()
|
||||
}
|
||||
@@ -1945,6 +1986,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -1973,6 +2015,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -2004,6 +2047,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
:sortable="true"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
@@ -2028,6 +2072,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
|
||||
:sortable="false"
|
||||
:show-remove-button="true"
|
||||
@refresh-data="refreshData"
|
||||
@rating="applyPluginRating"
|
||||
@action-done="
|
||||
pluginId => {
|
||||
pluginActions[pluginId] = false
|
||||
|
||||
@@ -145,6 +145,7 @@ const PluginMixedSortCardStub = defineComponent({
|
||||
'delete-folder',
|
||||
'drop-to-folder',
|
||||
'open-folder',
|
||||
'rating',
|
||||
'refresh-data',
|
||||
'rename-folder',
|
||||
'remove-from-folder',
|
||||
@@ -219,6 +220,22 @@ const PluginMixedSortCardStub = defineComponent({
|
||||
type === 'plugin'
|
||||
? h('button', { onClick: () => emit('remove-from-folder', id), type: 'button' }, `remove-plugin-${id}`)
|
||||
: null,
|
||||
type === 'plugin'
|
||||
? h(
|
||||
'button',
|
||||
{
|
||||
onClick: () =>
|
||||
emit('rating', {
|
||||
average_rating: 4.7,
|
||||
plugin_id: id,
|
||||
rating_count: 10,
|
||||
user_rating: 5,
|
||||
} satisfies PluginRating),
|
||||
type: 'button',
|
||||
},
|
||||
`rate-plugin-${id}`,
|
||||
)
|
||||
: null,
|
||||
])
|
||||
}
|
||||
},
|
||||
@@ -542,6 +559,58 @@ describe('PluginCardListView loading and request ownership', () => {
|
||||
expect(screen.getByLabelText('statistic-Installed')).toHaveTextContent('99')
|
||||
})
|
||||
|
||||
it('updates only the submitted plugin from the POST result without reloading market metrics', async () => {
|
||||
let ratingRequest = 0
|
||||
let statisticRequest = 0
|
||||
await renderList({
|
||||
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
|
||||
rating: () => {
|
||||
ratingRequest += 1
|
||||
return { Installed: { average_rating: 3, plugin_id: 'Installed', rating_count: 1 } }
|
||||
},
|
||||
statistic: () => {
|
||||
statisticRequest += 1
|
||||
return { Installed: 24 }
|
||||
},
|
||||
})
|
||||
await waitForRequestsToFinish()
|
||||
|
||||
expect(ratingRequest).toBe(1)
|
||||
expect(statisticRequest).toBe(1)
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'rate-plugin-Installed' }))
|
||||
|
||||
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.7')
|
||||
expect(ratingRequest).toBe(1)
|
||||
expect(statisticRequest).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the previous rating visible while a tab refresh loads the next rating snapshot', async () => {
|
||||
const refreshedRatings = createDeferred<Record<string, PluginRating>>()
|
||||
let ratingRequest = 0
|
||||
await renderList({
|
||||
installed: () => [createPlugin({ id: 'Installed', installed: true, plugin_name: '已安装插件' })],
|
||||
rating: () => {
|
||||
ratingRequest += 1
|
||||
if (ratingRequest === 1) {
|
||||
return { Installed: { average_rating: 4.2, plugin_id: 'Installed', rating_count: 3 } }
|
||||
}
|
||||
return refreshedRatings.promise
|
||||
},
|
||||
})
|
||||
await waitForRequestsToFinish()
|
||||
|
||||
if (!mocks.keepAliveHandler) throw new Error('未注册 keep-alive 刷新回调')
|
||||
const refresh = mocks.keepAliveHandler({ silent: true, source: 'tab' })
|
||||
await waitFor(() => expect(ratingRequest).toBe(2))
|
||||
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.2')
|
||||
|
||||
refreshedRatings.resolve({
|
||||
Installed: { average_rating: 4.8, plugin_id: 'Installed', rating_count: 4 },
|
||||
})
|
||||
await refresh
|
||||
expect(screen.getByLabelText('installed-rating-Installed')).toHaveTextContent('4.8')
|
||||
})
|
||||
|
||||
it('rejects a rating snapshot when the current plugin ID set changes before the next rating request', async () => {
|
||||
const staleRatings = createDeferred<Record<string, PluginRating>>()
|
||||
const currentMarket = createDeferred<Plugin[]>()
|
||||
@@ -625,6 +694,26 @@ describe('PluginCardListView loading and request ownership', () => {
|
||||
await waitForRequestsToFinish()
|
||||
})
|
||||
|
||||
it('clears the installed update marker after a successful update refresh removes the market entry', async () => {
|
||||
let marketRequest = 0
|
||||
await renderList({
|
||||
installed: () => [createPlugin({ id: 'Shared', installed: true, plugin_name: '已更新插件' })],
|
||||
market: () => {
|
||||
marketRequest += 1
|
||||
return marketRequest === 1
|
||||
? [createPlugin({ has_update: true, id: 'Shared', installed: true, plugin_name: '待更新插件' })]
|
||||
: []
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true'))
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
|
||||
expect(marketRequest).toBe(2)
|
||||
await waitForRequestsToFinish()
|
||||
})
|
||||
|
||||
it('keeps an initial installed failure retryable instead of presenting a successful empty list', async () => {
|
||||
await renderList({ installedStatus: 500 })
|
||||
|
||||
@@ -701,6 +790,45 @@ describe('PluginCardListView market filtering and pagination', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('sorts market entries by rating descending when asynchronous ratings arrive', async () => {
|
||||
const ratings = createDeferred<Record<string, PluginRating>>()
|
||||
await renderList({
|
||||
market: () => [
|
||||
createPlugin({ id: 'Low', plugin_name: '低评分插件' }),
|
||||
createPlugin({ id: 'Unrated', plugin_name: '未评分插件' }),
|
||||
createPlugin({ id: 'High', plugin_name: '高评分插件' }),
|
||||
createPlugin({ id: 'Medium', plugin_name: '中评分插件' }),
|
||||
],
|
||||
rating: () => ratings.promise,
|
||||
})
|
||||
|
||||
expect(await screen.findByText('market:低评分插件')).toBeInTheDocument()
|
||||
getHeaderConfig().modelValue.value = 'market'
|
||||
await nextTick()
|
||||
const marketFilterButton = getHeaderConfig().appendButtons.find(button => button.dataAttr === 'market-filter-btn')
|
||||
if (!marketFilterButton?.action) throw new Error('未注册市场过滤操作')
|
||||
marketFilterButton.action()
|
||||
await nextTick()
|
||||
await fireEvent.click(screen.getByText('评分'))
|
||||
|
||||
ratings.resolve({
|
||||
High: { average_rating: 4.8, plugin_id: 'High', rating_count: 12 },
|
||||
Low: { average_rating: 2.1, plugin_id: 'Low', rating_count: 3 },
|
||||
Medium: { average_rating: 3.6, plugin_id: 'Medium', rating_count: 6 },
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const labels = [...document.querySelectorAll('[data-testid^="market-"]')].map(node => node.textContent)
|
||||
expect(labels).toEqual([
|
||||
expect.stringContaining('market:高评分插件'),
|
||||
expect.stringContaining('market:中评分插件'),
|
||||
expect.stringContaining('market:低评分插件'),
|
||||
expect.stringContaining('market:未评分插件'),
|
||||
])
|
||||
})
|
||||
await waitForRequestsToFinish()
|
||||
})
|
||||
|
||||
it('filters and sorts market entries, labels local repos, and appends pages of 20', async () => {
|
||||
const market = Array.from({ length: 25 }, (_, index) =>
|
||||
createPlugin({
|
||||
@@ -793,6 +921,10 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
|
||||
createPlugin({ has_update: false, id: 'Beta', installed: true, plugin_name: 'Beta', state: true }),
|
||||
createPlugin({ has_update: true, id: 'Gamma', installed: true, plugin_name: 'Gamma', state: false }),
|
||||
],
|
||||
market: () => [
|
||||
createPlugin({ has_update: true, id: 'Alpha', installed: true, plugin_name: 'Alpha' }),
|
||||
createPlugin({ has_update: true, id: 'Gamma', installed: true, plugin_name: 'Gamma' }),
|
||||
],
|
||||
})
|
||||
await screen.findByText('plugin:Alpha')
|
||||
await waitForRequestsToFinish()
|
||||
|
||||
@@ -54,6 +54,7 @@ const SystemSettings = ref<any>({
|
||||
LLM_MODEL: 'deepseek-chat',
|
||||
LLM_THINKING_LEVEL: 'off',
|
||||
LLM_API_PROTOCOL: 'auto',
|
||||
LLM_WEB_SEARCH_MODE: 'local',
|
||||
LLM_SUPPORT_IMAGE_INPUT: false,
|
||||
LLM_SUPPORT_AUDIO_INPUT: false,
|
||||
LLM_SUPPORT_AUDIO_OUTPUT: false,
|
||||
@@ -223,6 +224,7 @@ type LlmSettingsSnapshot = {
|
||||
LLM_MODEL: string
|
||||
LLM_THINKING_LEVEL: string
|
||||
LLM_API_PROTOCOL: string
|
||||
LLM_WEB_SEARCH_MODE: string
|
||||
LLM_API_KEY: string
|
||||
LLM_BASE_URL: string
|
||||
LLM_USE_PROXY: boolean
|
||||
@@ -327,6 +329,7 @@ const {
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
showApiProtocolField: showLlmApiProtocolField,
|
||||
supportsBuiltinWebSearch,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
@@ -409,6 +412,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
|
||||
LLM_MODEL: String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
|
||||
LLM_API_PROTOCOL: String(SystemSettings.value.Basic.LLM_API_PROTOCOL ?? 'auto'),
|
||||
LLM_WEB_SEARCH_MODE: String(SystemSettings.value.Basic.LLM_WEB_SEARCH_MODE ?? 'local'),
|
||||
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
|
||||
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
|
||||
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
|
||||
@@ -429,6 +433,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
|
||||
model: snapshot.LLM_MODEL.trim(),
|
||||
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
|
||||
api_protocol: snapshot.LLM_API_PROTOCOL.trim() || 'auto',
|
||||
web_search_mode: snapshot.LLM_WEB_SEARCH_MODE.trim() || 'local',
|
||||
api_key: snapshot.LLM_API_KEY.trim(),
|
||||
base_url: snapshot.LLM_BASE_URL.trim(),
|
||||
use_proxy: snapshot.LLM_USE_PROXY,
|
||||
@@ -532,6 +537,23 @@ const apiProtocolItems = computed(() => [
|
||||
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' },
|
||||
])
|
||||
|
||||
const webSearchModeItems = computed(() => [
|
||||
{ title: t('setting.system.llmWebSearchModeLocal'), value: 'local' },
|
||||
{
|
||||
title: t('setting.system.llmWebSearchModeBuiltin'),
|
||||
value: 'builtin',
|
||||
disabled: !supportsBuiltinWebSearch.value,
|
||||
},
|
||||
{ title: t('setting.system.llmWebSearchModeAuto'), value: 'auto' },
|
||||
{ title: t('setting.system.llmWebSearchModeDisabled'), value: 'disabled' },
|
||||
])
|
||||
|
||||
const webSearchModeHint = computed(() =>
|
||||
supportsBuiltinWebSearch.value
|
||||
? t('setting.system.llmWebSearchModeBuiltinSupportedHint')
|
||||
: t('setting.system.llmWebSearchModeHint'),
|
||||
)
|
||||
|
||||
const activeTab = ref('system')
|
||||
|
||||
// 元数据语言
|
||||
@@ -1376,6 +1398,16 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="SystemSettings.Basic.LLM_WEB_SEARCH_MODE"
|
||||
:label="t('setting.system.llmWebSearchMode')"
|
||||
:hint="webSearchModeHint"
|
||||
persistent-hint
|
||||
:items="webSearchModeItems"
|
||||
prepend-inner-icon="mdi-web"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="SystemSettings.Basic.LLM_MAX_CONTEXT_TOKENS"
|
||||
|
||||
@@ -94,6 +94,7 @@ const {
|
||||
showBaseUrlField,
|
||||
showApiKeyField,
|
||||
showApiProtocolField,
|
||||
supportsBuiltinWebSearch,
|
||||
canRefreshModels,
|
||||
setBaseUrlPreset,
|
||||
authDialogVisible,
|
||||
@@ -198,6 +199,23 @@ const apiProtocolItems = computed(() => [
|
||||
{ title: t('setting.system.llmApiProtocolResponses'), value: 'responses' },
|
||||
])
|
||||
|
||||
const webSearchModeItems = computed(() => [
|
||||
{ title: t('setting.system.llmWebSearchModeLocal'), value: 'local' },
|
||||
{
|
||||
title: t('setting.system.llmWebSearchModeBuiltin'),
|
||||
value: 'builtin',
|
||||
disabled: !supportsBuiltinWebSearch.value,
|
||||
},
|
||||
{ title: t('setting.system.llmWebSearchModeAuto'), value: 'auto' },
|
||||
{ title: t('setting.system.llmWebSearchModeDisabled'), value: 'disabled' },
|
||||
])
|
||||
|
||||
const webSearchModeHint = computed(() =>
|
||||
supportsBuiltinWebSearch.value
|
||||
? t('setting.system.llmWebSearchModeBuiltinSupportedHint')
|
||||
: t('setting.system.llmWebSearchModeHint'),
|
||||
)
|
||||
|
||||
const audioProviderItems = computed(() => [
|
||||
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
|
||||
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
|
||||
@@ -483,6 +501,18 @@ onMounted(async () => {
|
||||
</VAlert>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="wizardData.agent.webSearchMode"
|
||||
:label="t('setting.system.llmWebSearchMode')"
|
||||
:hint="webSearchModeHint"
|
||||
:items="webSearchModeItems"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-web"
|
||||
color="primary"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model.number="wizardData.agent.maxContextTokens"
|
||||
|
||||
@@ -143,10 +143,13 @@ const mediaInfo = computed(() => nameTestResult.value?.media_info)
|
||||
const isRecognized = computed(() => Boolean(metaInfo.value?.name))
|
||||
const resultTitle = computed(() => mediaInfo.value?.title || metaInfo.value?.name || t('nameTest.unrecognized'))
|
||||
const resultSubtitle = computed(() => {
|
||||
const parts = [mediaInfo.value?.year || metaInfo.value?.year, mediaInfo.value?.type || metaInfo.value?.type]
|
||||
const parts = [mediaInfo.value?.year || metaInfo.value?.year]
|
||||
if (metaInfo.value?.season_episode) parts.push(metaInfo.value.season_episode)
|
||||
return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
|
||||
})
|
||||
const mediaClassification = computed(() => {
|
||||
return [mediaInfo.value?.type || metaInfo.value?.type, mediaInfo.value?.category].filter(Boolean).join(' · ') || '-'
|
||||
})
|
||||
const resourceChips = computed(() => {
|
||||
return [
|
||||
metaInfo.value?.web_source,
|
||||
@@ -229,6 +232,11 @@ const pipelineSteps = computed<PipelineStep[]>(() => [
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '-',
|
||||
},
|
||||
{
|
||||
icon: 'mdi-shape-outline',
|
||||
title: t('nameTest.steps.classification.title'),
|
||||
value: mediaClassification.value,
|
||||
},
|
||||
{
|
||||
icon: 'mdi-database-search-outline',
|
||||
source: recognizedMediaSource.value,
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock('vue-toastification', () => ({
|
||||
}))
|
||||
|
||||
interface RecognizedMedia {
|
||||
category?: string
|
||||
media_id: string
|
||||
source: string
|
||||
title: string
|
||||
@@ -114,6 +115,20 @@ describe('NameTestView media identity', () => {
|
||||
expect(sourceDisplay.querySelector('.media-source-logo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the recognized media type and category below metadata', async () => {
|
||||
await renderRecognizedMedia({
|
||||
category: '动漫',
|
||||
media_id: '485',
|
||||
source: 'bangumi',
|
||||
title: '测试动画',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
})
|
||||
|
||||
const classificationStep = screen.getByText('媒体分类').closest('.pipeline-step')
|
||||
expect(classificationStep).toHaveTextContent('媒体分类电视剧 · 动漫')
|
||||
})
|
||||
|
||||
it('closes the recognition dialog before navigating to the media detail', async () => {
|
||||
const eventOrder: string[] = []
|
||||
const onClose = vi.fn(() => eventOrder.push('close'))
|
||||
|
||||
@@ -370,6 +370,10 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
'src/components/cards/SiteCard.vue',
|
||||
'src/components/cards/DownloadingCard.vue',
|
||||
'src/components/cards/PluginFolderCard.vue',
|
||||
'src/components/cards/PluginCard.vue',
|
||||
'src/components/cards/PluginAppCard.vue',
|
||||
'src/components/dialog/PluginMarketDetailDialog.vue',
|
||||
'src/components/dialog/PluginVersionHistoryDialog.vue',
|
||||
'src/components/slide/VirtualSlideView.vue',
|
||||
'src/views/discover/PersonCardSlideView.vue',
|
||||
'src/views/reorganize/TransferHistoryView.vue',
|
||||
@@ -418,6 +422,30 @@ export default defineConfig(({ command, mode, isPreview }) => ({
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/PluginCard.vue': {
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80,
|
||||
},
|
||||
'src/components/cards/PluginAppCard.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/PluginMarketDetailDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/components/dialog/PluginVersionHistoryDialog.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
lines: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/views/reorganize/FileBrowserView.vue': {
|
||||
branches: 80,
|
||||
functions: 85,
|
||||
|
||||
Reference in New Issue
Block a user