Compare commits

...
35 Commits
Author SHA1 Message Date
jxxghp 0045ecf5d9 fix(site): keep chart dates compact 2026-08-05 22:13:22 +08:00
jxxghp 50fa751eb5 feat(site): enhance user data dashboard 2026-08-05 21:03:46 +08:00
jxxghp aa6dfd8569 fix(plugin): clear update marker after refresh 2026-08-05 20:47:43 +08:00
jxxghp b5ee946919 fix(test): align version statistic threshold fixture 2026-08-05 20:38:50 +08:00
jxxghp 6a21bb55c3 fix(test): avoid super-linear glass overlay regex 2026-08-05 19:52:25 +08:00
jxxghp 737d464950 更新 AboutDialog.vue 2026-08-05 19:45:23 +08:00
jxxghp 2c467335fa 更新 AboutDialog.vue 2026-08-05 19:44:16 +08:00
jxxghp 24f1580501 feat: 增加服务端联网搜索设置 2026-08-05 19:19:26 +08:00
jxxghp 1129d63fcc fix: 恢复识别结果媒体分类展示 2026-08-05 06:56:52 +08:00
jxxghp b1f3d3eacb Refine shared workflow and subscription dialogs 2026-08-04 21:37:04 +08:00
InfinityPacer 487e567d4c fix(glass): define signed coverage square (#642) 2026-08-04 19:48:44 +08:00
jxxghp 872d46132e fix MediaInfoCard chip background 2026-08-04 19:29:31 +08:00
InfinityPacer c65b79f4b8 fix(glass): refine fluid response coverage (#641) 2026-08-04 19:11:34 +08:00
jxxghp 09bbeda108 fix(glass): preserve chip variants with colored glass 2026-08-04 18:58:55 +08:00
InfinityPacer afc95ad990 fix(glass): reuse CORS wallpaper sources (#640) 2026-08-04 16:35:17 +08:00
InfinityPacer a545f88b3c feat(glass): add selectable dynamics modes (#639) 2026-08-04 16:34:39 +08:00
jxxghpandtraeagent 084b950fec feat: 修复玻璃主题下Chip标签颜色 (#638)
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-08-04 15:18:30 +08:00
jxxghp 2728896a1e Propagate plugin rating updates and preserve rating snapshots 2026-08-04 13:39:27 +08:00
jxxghp b4afba99bd Improve plugin card statuses and compact market details 2026-08-04 12:51:35 +08:00
jxxghp df1e72e871 Improve plugin market detail layout and rating sorting 2026-08-04 12:34:02 +08:00
jxxghp 7e46428721 style(workflow): adapt card headers to material themes 2026-08-04 10:12:32 +08:00
jxxghp 14a4d9dc9c Redesign workflow task cards 2026-08-04 09:20:56 +08:00
jxxghp 3ac798ac5e Disable mobile layout for plugin search field 2026-08-04 06:42:40 +08:00
InfinityPacer e3ee714732 fix(plugin): align lifecycle action state handling (#637) 2026-08-04 06:33:00 +08:00
jxxghp 41d5f579f7 Fix movie wash subscription card visuals 2026-08-03 22:42:15 +08:00
jxxghp 102abf7df9 Highlight best-version subscriptions on mobile cards 2026-08-03 22:26:04 +08:00
jxxghp 47b9633a86 Document test helpers and component stubs 2026-08-03 22:17:56 +08:00
jxxghp e2902adba8 Improve card badge layouts and expand card tests 2026-08-03 22:16:28 +08:00
jxxghp cd993e3cf4 Refine mobile subscription card layout 2026-08-03 22:00:10 +08:00
jxxghp 4097fd74dd Codex/compact downloading poster (#635)
* fix(download): compact poster cards

* fix(download): use poster artwork consistently

* Improve plugin and media ID search dialogs
2026-08-03 21:23:20 +08:00
jxxghp 37e645ed9c fix(download): compact poster cards (#634) 2026-08-03 20:09:05 +08:00
jxxghp fa254499bc fix(download): redesign downloading card (#633) 2026-08-03 19:48:45 +08:00
jxxghp be8b20da8d 更新 package.json 2026-08-03 18:50:09 +08:00
jxxghp cef44692a8 feat(download): redesign active download cards (#632) 2026-08-03 18:49:23 +08:00
jxxghp a627d63107 fix(dialog): align mobile site search actions 2026-08-03 18:19:42 +08:00
74 changed files with 8424 additions and 1360 deletions
+1 -37
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.15.3",
"version": "2.15.4",
"private": true,
"type": "module",
"bin": "dist/service.js",
+46 -2
View File
@@ -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
View File
@@ -300,8 +300,10 @@ export interface DownloadHistory {
seasons?: string
// 集 Exx
episodes?: string
// 海报或背景图
// 背景图
image?: string
// 海报
poster?: string
// 下载器 Hash
download_hash?: string
// 种子名称
+441 -63
View File
@@ -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>
+1 -2
View File
@@ -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"
+21 -24
View File
@@ -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>
+101 -34
View File
@@ -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)"
/>
+1
View File
@@ -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"
+118 -72
View File
@@ -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>
+26 -31
View File
@@ -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">
+544 -168
View File
@@ -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')
})
})
+1 -1
View File
@@ -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>
+159 -37
View File
@@ -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>
+134 -148
View File
@@ -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>
+64 -1
View File
@@ -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;
+244 -170
View File
@@ -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>
+8 -2
View File
@@ -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);
}
+371 -73
View File
@@ -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(),
)
+34 -6
View File
@@ -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 Mini2019')).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 {
+61 -7
View File
@@ -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>
+1
View File
@@ -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
+18 -2
View File
@@ -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,
+10 -2
View File
@@ -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)
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+353
View File
@@ -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
},
}
}
+480
View File
@@ -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
View File
@@ -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);
+228 -2
View File
@@ -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 增加镜面方向outlinedtextplain 保持原来的空心或无底形态
.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);
+18
View File
@@ -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;
+77 -32
View File
@@ -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()
}
// uninstalledListsortedUninstalledList
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"
+30
View File
@@ -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"
+9 -1
View File
@@ -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'))
+28
View File
@@ -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,