mirror of
https://github.com/linshenkx/prompt-optimizer.git
synced 2026-06-09 02:24:34 +08:00
feat(ui): enhance favorites with example editing, media management, and workspace apply
- Add reproducibility example editing with media support in favorite editor - Add example apply to workspace sessions (pro-variable, image modes) - Harden favorites page routing, guards, and garden deduplication - Consolidate FavoriteCard into editor form with full test coverage
This commit is contained in:
@@ -1,488 +0,0 @@
|
||||
<template>
|
||||
<NCard
|
||||
hoverable
|
||||
class="favorite-card"
|
||||
:class="{ 'is-selected': isSelected }"
|
||||
:header-style="{ padding: '12px 16px' }"
|
||||
:content-style="{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '12px' }"
|
||||
:footer-style="{ padding: '12px 16px' }"
|
||||
@click="$emit('select', favorite)"
|
||||
>
|
||||
<template #header>
|
||||
<div class="favorite-card__header">
|
||||
<NEllipsis class="favorite-card__title">
|
||||
{{ favorite.title }}
|
||||
</NEllipsis>
|
||||
|
||||
<NTag
|
||||
v-if="category"
|
||||
:color="category.color ? { color: category.color, textColor: 'white' } : undefined"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="favorite-card__category"
|
||||
>
|
||||
<NEllipsis class="favorite-card__category-text">
|
||||
{{ category.name }}
|
||||
</NEllipsis>
|
||||
</NTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cover>
|
||||
<div class="favorite-card__cover" data-testid="favorite-card-cover">
|
||||
<AppPreviewImage
|
||||
v-if="coverImageSrc"
|
||||
:src="coverImageSrc"
|
||||
:alt="favorite.title"
|
||||
object-fit="cover"
|
||||
class="favorite-card__cover-image"
|
||||
preview-disabled
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="favorite-card__cover-placeholder"
|
||||
data-testid="favorite-card-cover-placeholder"
|
||||
>
|
||||
<NThing>
|
||||
<template #header>
|
||||
<NTag
|
||||
:type="getFunctionModeTagType(normalizedFunctionMode)"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ modeLabel }}
|
||||
</NTag>
|
||||
</template>
|
||||
|
||||
<NText depth="3" class="favorite-card__cover-summary">
|
||||
{{ coverSummary }}
|
||||
</NText>
|
||||
</NThing>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="favorite-card__body">
|
||||
<NSpace :size="6" :wrap="false" align="center" class="favorite-card__mode-row">
|
||||
<NTag
|
||||
:type="getFunctionModeTagType(normalizedFunctionMode)"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ modeLabel }}
|
||||
</NTag>
|
||||
|
||||
<NTag
|
||||
v-if="subModeLabel"
|
||||
:type="getSubModeTagType(favorite)"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ subModeLabel }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
|
||||
<NEllipsis
|
||||
:line-clamp="3"
|
||||
:tooltip="false"
|
||||
class="favorite-card__summary"
|
||||
>
|
||||
<NText depth="2">
|
||||
{{ summaryText }}
|
||||
</NText>
|
||||
</NEllipsis>
|
||||
|
||||
<div class="favorite-card__tag-row">
|
||||
<NSpace v-if="displayedTags.length > 0" :size="6" :wrap="false">
|
||||
<NTag
|
||||
v-for="tag in displayedTags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
type="info"
|
||||
:bordered="false"
|
||||
>
|
||||
{{ tag }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="favorite-card__footer">
|
||||
<NText depth="3" class="favorite-card__meta">
|
||||
{{ metaText }}
|
||||
</NText>
|
||||
|
||||
<NSpace :size="8" align="center" :wrap="false" class="favorite-card__actions">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
data-testid="favorite-card-use-button"
|
||||
@click.stop="$emit('use', favorite)"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon><PlayerPlay /></NIcon>
|
||||
</template>
|
||||
{{ t('favorites.manager.card.useNow') }}
|
||||
</NButton>
|
||||
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
data-testid="favorite-card-copy-button"
|
||||
@click.stop="$emit('copy', favorite)"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon><Copy /></NIcon>
|
||||
</template>
|
||||
{{ t('favorites.manager.card.copyContent') }}
|
||||
</NButton>
|
||||
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="menuOptions"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<NButton
|
||||
size="small"
|
||||
quaternary
|
||||
circle
|
||||
data-testid="favorite-card-menu-button"
|
||||
@click.stop
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon><DotsVertical /></NIcon>
|
||||
</template>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, inject, ref, watch, type Ref } from 'vue'
|
||||
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDropdown,
|
||||
NEllipsis,
|
||||
NIcon,
|
||||
NSpace,
|
||||
NTag,
|
||||
NText,
|
||||
NThing,
|
||||
} from 'naive-ui'
|
||||
import { Copy, DotsVertical, Edit, PlayerPlay, Trash } from '@vicons/tabler'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { FavoriteCategory, FavoritePrompt } from '@prompt-optimizer/core'
|
||||
|
||||
import type { AppServices } from '../types/services'
|
||||
import { resolveAssetIdToDataUrl } from '../utils/image-asset-storage'
|
||||
import { parseFavoriteMediaMetadata } from '../utils/favorite-media'
|
||||
import { normalizeFavoriteFunctionMode } from '../utils/favorite-mode'
|
||||
import AppPreviewImage from './media/AppPreviewImage.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const services = inject<Ref<AppServices | null> | null>('services', null)
|
||||
|
||||
interface Props {
|
||||
favorite: FavoritePrompt
|
||||
category?: FavoriteCategory
|
||||
isSelected?: boolean
|
||||
cardHeight?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select': [favorite: FavoritePrompt]
|
||||
'edit': [favorite: FavoritePrompt]
|
||||
'copy': [favorite: FavoritePrompt]
|
||||
'delete': [favorite: FavoritePrompt]
|
||||
'use': [favorite: FavoritePrompt]
|
||||
}>()
|
||||
|
||||
const coverImageSrc = ref<string | null>(null)
|
||||
|
||||
const displayedTags = computed(() => props.favorite.tags.slice(0, 2))
|
||||
const normalizedFunctionMode = computed(() => normalizeFavoriteFunctionMode(props.favorite.functionMode))
|
||||
const modeLabel = computed(() => t(`favorites.manager.card.functionMode.${normalizedFunctionMode.value}`))
|
||||
|
||||
const summaryText = computed(() =>
|
||||
props.favorite.description?.trim()
|
||||
|| props.favorite.content.trim()
|
||||
)
|
||||
|
||||
const coverSummary = computed(() => summaryText.value)
|
||||
|
||||
const metaText = computed(() =>
|
||||
`${formatDate(props.favorite.updatedAt)} · ${t('favorites.manager.preview.useCountInline', { count: props.favorite.useCount })}`
|
||||
)
|
||||
|
||||
const subModeLabel = computed(() => getSubModeLabel(props.favorite))
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
label: t('favorites.manager.card.edit'),
|
||||
key: 'edit',
|
||||
icon: () => h(NIcon, null, { default: () => h(Edit) }),
|
||||
},
|
||||
{
|
||||
label: t('favorites.manager.card.delete'),
|
||||
key: 'delete',
|
||||
icon: () => h(NIcon, null, { default: () => h(Trash) }),
|
||||
},
|
||||
])
|
||||
|
||||
const handleMenuSelect = (key: string) => {
|
||||
if (key === 'edit') {
|
||||
emit('edit', props.favorite)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'delete') {
|
||||
emit('delete', props.favorite)
|
||||
}
|
||||
}
|
||||
|
||||
const getReadStorageCandidates = () => {
|
||||
const favoriteStorage = services?.value?.favoriteImageStorageService || null
|
||||
const legacyStorage = services?.value?.imageStorageService || null
|
||||
|
||||
if (favoriteStorage && legacyStorage && favoriteStorage !== legacyStorage) {
|
||||
return [favoriteStorage, legacyStorage]
|
||||
}
|
||||
|
||||
if (favoriteStorage) return [favoriteStorage]
|
||||
if (legacyStorage) return [legacyStorage]
|
||||
return []
|
||||
}
|
||||
|
||||
const resolveCoverImage = async () => {
|
||||
const media = parseFavoriteMediaMetadata(props.favorite)
|
||||
if (!media) {
|
||||
coverImageSrc.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const storageCandidates = getReadStorageCandidates()
|
||||
if (storageCandidates.length === 0) {
|
||||
coverImageSrc.value = media.coverUrl || null
|
||||
return
|
||||
}
|
||||
|
||||
if (media.coverAssetId) {
|
||||
for (const storageService of storageCandidates) {
|
||||
try {
|
||||
const dataUrl = await resolveAssetIdToDataUrl(media.coverAssetId, storageService)
|
||||
if (dataUrl) {
|
||||
coverImageSrc.value = dataUrl
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[FavoriteCard] Failed to resolve cover asset id:', media.coverAssetId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coverImageSrc.value = media.coverUrl || null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.favorite,
|
||||
() => {
|
||||
void resolveCoverImage()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [services?.value?.favoriteImageStorageService, services?.value?.imageStorageService],
|
||||
() => {
|
||||
void resolveCoverImage()
|
||||
},
|
||||
)
|
||||
|
||||
const getFunctionModeTagType = (mode: string): 'default' | 'info' | 'success' => {
|
||||
const typeMap: Record<string, 'default' | 'info' | 'success'> = {
|
||||
basic: 'default',
|
||||
context: 'info',
|
||||
image: 'success',
|
||||
}
|
||||
return typeMap[mode] || 'default'
|
||||
}
|
||||
|
||||
const getSubModeTagType = (favorite: FavoritePrompt): 'warning' | 'error' | 'success' | 'info' | 'default' => {
|
||||
if (favorite.optimizationMode) {
|
||||
return favorite.optimizationMode === 'system' ? 'warning' : 'error'
|
||||
}
|
||||
if (favorite.imageSubMode) {
|
||||
return favorite.imageSubMode === 'text2image' ? 'success' : 'info'
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const getSubModeLabel = (favorite: FavoritePrompt): string => {
|
||||
if (favorite.optimizationMode) {
|
||||
const isContextMode = normalizeFavoriteFunctionMode(favorite.functionMode) === 'context'
|
||||
if (isContextMode) {
|
||||
return favorite.optimizationMode === 'system'
|
||||
? t('contextMode.optimizationMode.message')
|
||||
: t('contextMode.optimizationMode.variable')
|
||||
}
|
||||
return t(`favorites.manager.card.optimizationMode.${favorite.optimizationMode}`)
|
||||
}
|
||||
if (favorite.imageSubMode) {
|
||||
return t(`favorites.manager.card.imageSubMode.${favorite.imageSubMode}`)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
return minutes <= 1 ? t('favorites.manager.time.justNow') : t('favorites.manager.time.minutesAgo', { minutes })
|
||||
}
|
||||
return t('favorites.manager.time.hoursAgo', { hours })
|
||||
}
|
||||
|
||||
if (days === 1) {
|
||||
return t('favorites.manager.time.yesterday')
|
||||
}
|
||||
|
||||
if (days < 7) {
|
||||
return t('favorites.manager.time.daysAgo', { days })
|
||||
}
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-card {
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.favorite-card.is-selected {
|
||||
box-shadow: inset 0 0 0 1px rgba(var(--primary-color), 0.85);
|
||||
}
|
||||
|
||||
.favorite-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.favorite-card__title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.favorite-card__category {
|
||||
max-width: 112px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.favorite-card__category-text {
|
||||
max-width: 88px;
|
||||
}
|
||||
|
||||
.favorite-card__cover {
|
||||
min-height: 168px;
|
||||
background: color-mix(in srgb, var(--n-color-embedded) 84%, transparent);
|
||||
}
|
||||
|
||||
.favorite-card__cover :deep(.n-card-cover) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.favorite-card__cover-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 168px;
|
||||
}
|
||||
|
||||
.favorite-card__cover-placeholder {
|
||||
display: flex;
|
||||
min-height: 168px;
|
||||
padding: 16px;
|
||||
align-items: flex-end;
|
||||
background:
|
||||
linear-gradient(160deg, color-mix(in srgb, var(--n-color-embedded) 88%, white 12%), var(--n-color)),
|
||||
linear-gradient(180deg, transparent, color-mix(in srgb, var(--n-color-embedded) 86%, black 14%));
|
||||
}
|
||||
|
||||
.favorite-card__cover-summary {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.favorite-card__body {
|
||||
display: flex;
|
||||
min-height: 108px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.favorite-card__mode-row {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.favorite-card__summary {
|
||||
min-height: 60px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.favorite-card__tag-row {
|
||||
min-height: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.favorite-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.favorite-card__meta {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.favorite-card__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.favorite-card__footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.favorite-card__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<NSpace :size="8" align="center" wrap>
|
||||
<NButton
|
||||
data-testid="favorite-detail-use"
|
||||
type="primary"
|
||||
@click="$emit('use', favorite)"
|
||||
>
|
||||
@@ -29,6 +30,7 @@
|
||||
{{ t('favorites.manager.card.useNow') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
data-testid="favorite-detail-copy"
|
||||
secondary
|
||||
@click="$emit('copy', favorite)"
|
||||
>
|
||||
@@ -38,6 +40,7 @@
|
||||
{{ t('favorites.manager.card.copyContent') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
data-testid="favorite-detail-fullscreen"
|
||||
quaternary
|
||||
@click="$emit('fullscreen', favorite)"
|
||||
>
|
||||
@@ -47,6 +50,7 @@
|
||||
{{ t('common.fullscreen') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
data-testid="favorite-detail-edit"
|
||||
quaternary
|
||||
@click="$emit('edit', favorite)"
|
||||
>
|
||||
@@ -56,6 +60,7 @@
|
||||
{{ t('favorites.manager.card.edit') }}
|
||||
</NButton>
|
||||
<NButton
|
||||
data-testid="favorite-detail-delete"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="$emit('delete', favorite)"
|
||||
@@ -194,11 +199,17 @@
|
||||
name="reproducibility"
|
||||
:title="t('favorites.manager.preview.reproducibility.title')"
|
||||
>
|
||||
<FavoriteReproducibilityDisplay :reproducibility="reproducibility" />
|
||||
<FavoriteReproducibilityDisplay
|
||||
:reproducibility="reproducibility"
|
||||
:example-previews="reproducibilityExamplePreviews"
|
||||
@apply-example="handleApplyExample"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
<NCollapseItem name="extra" :title="t('favorites.manager.preview.extraTitle')">
|
||||
<FavoritePreviewExtensionHost
|
||||
:favorite="favorite"
|
||||
:garden-snapshot-hidden-sections="promotedGardenSnapshotSections"
|
||||
garden-snapshot-source-only
|
||||
@favorite-updated="handleFavoriteUpdated"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
@@ -298,11 +309,17 @@
|
||||
name="reproducibility"
|
||||
:title="t('favorites.manager.preview.reproducibility.title')"
|
||||
>
|
||||
<FavoriteReproducibilityDisplay :reproducibility="reproducibility" />
|
||||
<FavoriteReproducibilityDisplay
|
||||
:reproducibility="reproducibility"
|
||||
:example-previews="reproducibilityExamplePreviews"
|
||||
@apply-example="handleApplyExample"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
<NCollapseItem name="extra" :title="t('favorites.manager.preview.extraTitle')">
|
||||
<FavoritePreviewExtensionHost
|
||||
:favorite="favorite"
|
||||
:garden-snapshot-hidden-sections="promotedGardenSnapshotSections"
|
||||
garden-snapshot-source-only
|
||||
@favorite-updated="handleFavoriteUpdated"
|
||||
/>
|
||||
</NCollapseItem>
|
||||
@@ -361,7 +378,7 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'back': []
|
||||
'use': [favorite: FavoritePrompt]
|
||||
'use': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
|
||||
'copy': [favorite: FavoritePrompt]
|
||||
'edit': [favorite: FavoritePrompt]
|
||||
'delete': [favorite: FavoritePrompt]
|
||||
@@ -374,8 +391,14 @@ const services = inject<Ref<AppServices | null> | null>('services', null)
|
||||
|
||||
const assetDataUrlCache = new Map<string, string>()
|
||||
const displayImages = ref<string[]>([])
|
||||
const promotedGardenSnapshotSections = ['metaInfo', 'cover', 'showcases', 'examples', 'variables']
|
||||
const reproducibilityExamplePreviews = ref<Array<{
|
||||
images: Array<{ assetId: string; source: string }>
|
||||
inputImages: Array<{ assetId: string; source: string }>
|
||||
}>>([])
|
||||
const activeImageIndex = ref(0)
|
||||
let resolveSequence = 0
|
||||
let reproducibilityResolveSequence = 0
|
||||
|
||||
const detailVariant = computed(() => (displayImages.value.length > 0 ? 'image' : 'text'))
|
||||
const activeImage = computed(() => displayImages.value[activeImageIndex.value] || '')
|
||||
@@ -504,10 +527,51 @@ const refreshDisplayImages = async () => {
|
||||
activeImageIndex.value = 0
|
||||
}
|
||||
|
||||
const refreshReproducibilityExamplePreviews = async () => {
|
||||
const currentSequence = ++reproducibilityResolveSequence
|
||||
const favorite = props.favorite
|
||||
if (!favorite) {
|
||||
reproducibilityExamplePreviews.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = parseFavoriteReproducibility(favorite)
|
||||
const resolveAssetPreviews = async (assetIds: string[]) => {
|
||||
const previewItems: Array<{ assetId: string; source: string }> = []
|
||||
for (const assetId of assetIds) {
|
||||
const source = (await resolveAssetIdsToDataUrls([assetId]))[0]
|
||||
if (currentSequence !== reproducibilityResolveSequence) return []
|
||||
if (source) {
|
||||
previewItems.push({ assetId, source })
|
||||
}
|
||||
}
|
||||
return previewItems
|
||||
}
|
||||
|
||||
const previews: Array<{
|
||||
images: Array<{ assetId: string; source: string }>
|
||||
inputImages: Array<{ assetId: string; source: string }>
|
||||
}> = []
|
||||
for (const example of parsed.examples) {
|
||||
const images = await resolveAssetPreviews(example.imageAssetIds)
|
||||
if (currentSequence !== reproducibilityResolveSequence) return
|
||||
const inputImages = await resolveAssetPreviews(example.inputImageAssetIds)
|
||||
if (currentSequence !== reproducibilityResolveSequence) return
|
||||
previews.push({
|
||||
images,
|
||||
inputImages,
|
||||
})
|
||||
}
|
||||
|
||||
if (currentSequence !== reproducibilityResolveSequence) return
|
||||
reproducibilityExamplePreviews.value = previews
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.favorite,
|
||||
() => {
|
||||
void refreshDisplayImages()
|
||||
void refreshReproducibilityExamplePreviews()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -516,6 +580,7 @@ watch(
|
||||
() => [services?.value?.favoriteImageStorageService, services?.value?.imageStorageService],
|
||||
() => {
|
||||
void refreshDisplayImages()
|
||||
void refreshReproducibilityExamplePreviews()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -563,6 +628,11 @@ const formatDate = (timestamp: number) => {
|
||||
const handleFavoriteUpdated = (favoriteId: string) => {
|
||||
emit('favorite-updated', favoriteId)
|
||||
}
|
||||
|
||||
const handleApplyExample = (options: { exampleId?: string; exampleIndex: number }) => {
|
||||
if (!props.favorite) return
|
||||
emit('use', props.favorite, { ...options, applyExample: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<NGridItem>
|
||||
<NFormItem :label="t('favorites.dialog.titleLabel')" required>
|
||||
<NInput
|
||||
data-testid="favorite-editor-title"
|
||||
v-model:value="formData.title"
|
||||
:placeholder="t('favorites.dialog.titlePlaceholder')"
|
||||
maxlength="100"
|
||||
@@ -71,6 +72,7 @@
|
||||
|
||||
<NFormItem :label="t('favorites.dialog.descriptionLabel')">
|
||||
<NInput
|
||||
data-testid="favorite-editor-description"
|
||||
v-model:value="formData.description"
|
||||
type="textarea"
|
||||
:placeholder="t('favorites.dialog.descriptionPlaceholder')"
|
||||
@@ -121,6 +123,7 @@
|
||||
<NText depth="3">{{ t('favorites.dialog.imagesUploadSupport') }}</NText>
|
||||
</div>
|
||||
<NUpload
|
||||
data-testid="favorite-editor-image-upload-empty"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:default-upload="false"
|
||||
@@ -128,7 +131,7 @@
|
||||
:disabled="saving"
|
||||
@before-upload="handleBeforeImageUpload"
|
||||
>
|
||||
<NButton secondary size="small">
|
||||
<NButton secondary size="small" data-testid="favorite-editor-add-images-empty">
|
||||
{{ t('favorites.dialog.addImages') }}
|
||||
</NButton>
|
||||
</NUpload>
|
||||
@@ -138,6 +141,7 @@
|
||||
<template v-else>
|
||||
<NSpace justify="space-between" align="center" wrap>
|
||||
<NUpload
|
||||
data-testid="favorite-editor-image-upload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:default-upload="false"
|
||||
@@ -145,12 +149,13 @@
|
||||
:disabled="saving"
|
||||
@before-upload="handleBeforeImageUpload"
|
||||
>
|
||||
<NButton secondary>
|
||||
<NButton secondary data-testid="favorite-editor-add-images">
|
||||
{{ t('favorites.dialog.addImages') }}
|
||||
</NButton>
|
||||
</NUpload>
|
||||
|
||||
<NButton
|
||||
data-testid="favorite-editor-clear-images"
|
||||
quaternary
|
||||
type="error"
|
||||
size="small"
|
||||
@@ -188,6 +193,7 @@
|
||||
{{ t('favorites.dialog.coverTag') }}
|
||||
</NTag>
|
||||
<NButton
|
||||
data-testid="favorite-editor-set-cover"
|
||||
v-else
|
||||
quaternary
|
||||
size="tiny"
|
||||
@@ -197,6 +203,7 @@
|
||||
</NButton>
|
||||
|
||||
<NButton
|
||||
data-testid="favorite-editor-remove-image"
|
||||
quaternary
|
||||
type="error"
|
||||
size="tiny"
|
||||
@@ -215,6 +222,7 @@
|
||||
<FavoriteReproducibilityEditor
|
||||
v-model:variables="reproducibilityVariables"
|
||||
v-model:examples="reproducibilityExamples"
|
||||
:example-previews="reproducibilityExamplePreviews"
|
||||
/>
|
||||
|
||||
<NCard
|
||||
@@ -223,6 +231,7 @@
|
||||
:segmented="{ content: true }"
|
||||
>
|
||||
<NInput
|
||||
data-testid="favorite-editor-content"
|
||||
v-model:value="formData.content"
|
||||
type="textarea"
|
||||
:placeholder="t('favorites.dialog.contentPlaceholder')"
|
||||
@@ -235,10 +244,10 @@
|
||||
|
||||
<div class="favorite-editor-form__actions" :class="{ 'favorite-editor-form__actions--embedded': embedded }">
|
||||
<NSpace justify="end">
|
||||
<NButton :disabled="saving" @click="$emit('cancel')">
|
||||
<NButton data-testid="favorite-editor-cancel" :disabled="saving" @click="$emit('cancel')">
|
||||
{{ t('favorites.dialog.cancel') }}
|
||||
</NButton>
|
||||
<NButton type="primary" :loading="saving" @click="handleSave">
|
||||
<NButton data-testid="favorite-editor-save" type="primary" :loading="saving" @click="handleSave">
|
||||
{{ t('favorites.dialog.save') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
@@ -330,6 +339,11 @@ const emit = defineEmits<{
|
||||
'saved': [favoriteId: string]
|
||||
}>()
|
||||
|
||||
type FavoriteReproducibilityExamplePreviews = {
|
||||
images: Array<{ assetId: string; source: string }>
|
||||
inputImages: Array<{ assetId: string; source: string }>
|
||||
}
|
||||
|
||||
const services = inject<Ref<AppServices | null>>('services')
|
||||
const message = useToast()
|
||||
|
||||
@@ -339,6 +353,7 @@ const mediaTouched = ref(false)
|
||||
const tagInputValue = ref('')
|
||||
const reproducibilityVariables = ref<FavoriteReproducibilityVariable[]>([])
|
||||
const reproducibilityExamples = ref<FavoriteReproducibilityExample[]>([])
|
||||
const reproducibilityExamplePreviews = ref<FavoriteReproducibilityExamplePreviews[]>([])
|
||||
|
||||
const isMobile = computed(() => viewportWidth.value < 768)
|
||||
|
||||
@@ -357,6 +372,7 @@ const mediaDraft = reactive({
|
||||
sources: [] as string[],
|
||||
coverIndex: -1,
|
||||
})
|
||||
let hydrateRequestId = 0
|
||||
|
||||
const tagSuggestions = computed(() => {
|
||||
const suggestions = filterTags(tagInputValue.value, formData.tags)
|
||||
@@ -443,6 +459,7 @@ const cloneReproducibilityExamples = (
|
||||
const resetReproducibilityDraft = () => {
|
||||
reproducibilityVariables.value = []
|
||||
reproducibilityExamples.value = []
|
||||
reproducibilityExamplePreviews.value = []
|
||||
}
|
||||
|
||||
const hydrateReproducibilityDraft = (
|
||||
@@ -455,9 +472,57 @@ const hydrateReproducibilityDraft = (
|
||||
|
||||
reproducibilityVariables.value = cloneReproducibilityVariables(reproducibility.variables)
|
||||
reproducibilityExamples.value = cloneReproducibilityExamples(reproducibility.examples)
|
||||
reproducibilityExamplePreviews.value = reproducibility.examples.map(() => ({
|
||||
images: [],
|
||||
inputImages: [],
|
||||
}))
|
||||
}
|
||||
|
||||
const hydrateMediaDraft = async (metadata?: Record<string, unknown>, favorite?: FavoritePrompt) => {
|
||||
const hydrateReproducibilityExamplePreviews = async (
|
||||
metadata?: Record<string, unknown>,
|
||||
favorite?: FavoritePrompt,
|
||||
isStale: () => boolean = () => false,
|
||||
) => {
|
||||
const reproducibility = favorite
|
||||
? parseFavoriteReproducibility(favorite)
|
||||
: parseFavoriteReproducibilityFromMetadata(metadata)
|
||||
|
||||
const previews: FavoriteReproducibilityExamplePreviews[] = []
|
||||
const resolveAssetPreviews = async (assetIds: string[]) => {
|
||||
const previewItems: Array<{ assetId: string; source: string }> = []
|
||||
for (const assetId of assetIds) {
|
||||
if (isStale()) return []
|
||||
const source = (await resolveAssetIdsToDataUrls([assetId]))[0]
|
||||
if (isStale()) return []
|
||||
if (source) {
|
||||
previewItems.push({ assetId, source })
|
||||
}
|
||||
}
|
||||
return previewItems
|
||||
}
|
||||
|
||||
for (const example of reproducibility.examples) {
|
||||
if (isStale()) return
|
||||
const images = await resolveAssetPreviews(example.imageAssetIds)
|
||||
if (isStale()) return
|
||||
const inputImages = await resolveAssetPreviews(example.inputImageAssetIds)
|
||||
if (isStale()) return
|
||||
previews.push({
|
||||
images,
|
||||
inputImages,
|
||||
})
|
||||
}
|
||||
|
||||
if (isStale()) return
|
||||
reproducibilityExamplePreviews.value = previews
|
||||
}
|
||||
|
||||
const hydrateMediaDraft = async (
|
||||
metadata?: Record<string, unknown>,
|
||||
favorite?: FavoritePrompt,
|
||||
isStale: () => boolean = () => false,
|
||||
) => {
|
||||
if (isStale()) return
|
||||
resetMediaDraft()
|
||||
const media = favorite
|
||||
? parseFavoriteMediaMetadata(favorite)
|
||||
@@ -469,7 +534,9 @@ const hydrateMediaDraft = async (metadata?: Record<string, unknown>, favorite?:
|
||||
const resolvedCover = media.coverAssetId
|
||||
? (await resolveAssetIdsToDataUrls([media.coverAssetId]))[0]
|
||||
: undefined
|
||||
if (isStale()) return
|
||||
const resolvedAssets = await resolveAssetIdsToDataUrls(media.assetIds)
|
||||
if (isStale()) return
|
||||
|
||||
const sources = dedupeStrings([
|
||||
resolvedCover || media.coverUrl || '',
|
||||
@@ -564,12 +631,87 @@ const buildMediaMetadataForSave = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const persistSourcesForFavoriteAssets = async (sources: string[]) => {
|
||||
const normalizedSources = dedupeStrings(
|
||||
sources.map((item) => String(item || '').trim()).filter(Boolean),
|
||||
)
|
||||
const preferredStorage = getPreferredStorageService()
|
||||
const assetIds: string[] = []
|
||||
const fallbackSources: string[] = []
|
||||
|
||||
for (const source of normalizedSources) {
|
||||
if (!preferredStorage) {
|
||||
fallbackSources.push(source)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const assetId = await persistImageSourceAsAssetId({
|
||||
source,
|
||||
storageService: preferredStorage,
|
||||
sourceType: 'uploaded',
|
||||
})
|
||||
|
||||
if (assetId) {
|
||||
assetIds.push(assetId)
|
||||
} else {
|
||||
fallbackSources.push(source)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[FavoriteEditorForm] Failed to persist example image source:', error)
|
||||
fallbackSources.push(source)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetIds: dedupeStrings(assetIds),
|
||||
fallbackSources: dedupeStrings(fallbackSources),
|
||||
}
|
||||
}
|
||||
|
||||
const buildReproducibilityDraftForSave = async () => {
|
||||
const examples: FavoriteReproducibilityExample[] = []
|
||||
|
||||
for (const example of toRaw(reproducibilityExamples.value)) {
|
||||
const exampleImages = await persistSourcesForFavoriteAssets(example.images || [])
|
||||
const inputImages = await persistSourcesForFavoriteAssets(example.inputImages || [])
|
||||
|
||||
examples.push({
|
||||
...example,
|
||||
parameters: { ...example.parameters },
|
||||
images: exampleImages.fallbackSources,
|
||||
imageAssetIds: dedupeStrings([
|
||||
...(example.imageAssetIds || []),
|
||||
...exampleImages.assetIds,
|
||||
]),
|
||||
inputImages: inputImages.fallbackSources,
|
||||
inputImageAssetIds: dedupeStrings([
|
||||
...(example.inputImageAssetIds || []),
|
||||
...inputImages.assetIds,
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
variables: toRaw(reproducibilityVariables.value).map((variable) => ({
|
||||
...variable,
|
||||
options: [...variable.options],
|
||||
})),
|
||||
examples,
|
||||
}
|
||||
}
|
||||
|
||||
const handleBeforeImageUpload = async (options: { file: UploadFileInfo }) => {
|
||||
const raw = (options.file as unknown as { file?: Blob | null }).file
|
||||
if (!raw) return false
|
||||
|
||||
const requestId = hydrateRequestId
|
||||
try {
|
||||
const dataUrl = await readBlobAsDataUrl(raw)
|
||||
if (requestId !== hydrateRequestId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dataUrl) {
|
||||
mediaDraft.sources = dedupeStrings([...mediaDraft.sources, dataUrl])
|
||||
mediaTouched.value = true
|
||||
@@ -760,13 +902,11 @@ const handleSave = async () => {
|
||||
}
|
||||
|
||||
const currentReproducibility = parseFavoriteReproducibilityFromMetadata(existingMetadata)
|
||||
const reproducibilityDraft = await buildReproducibilityDraftForSave()
|
||||
const hasReproducibilityDraft =
|
||||
reproducibilityVariables.value.length > 0 || reproducibilityExamples.value.length > 0
|
||||
reproducibilityDraft.variables.length > 0 || reproducibilityDraft.examples.length > 0
|
||||
if (currentReproducibility.hasData || hasReproducibilityDraft) {
|
||||
existingMetadata = applyFavoriteReproducibilityToMetadata(existingMetadata, {
|
||||
variables: toRaw(reproducibilityVariables.value),
|
||||
examples: toRaw(reproducibilityExamples.value),
|
||||
})
|
||||
existingMetadata = applyFavoriteReproducibilityToMetadata(existingMetadata, reproducibilityDraft)
|
||||
}
|
||||
|
||||
const metadata = Object.keys(existingMetadata).length > 0 ? existingMetadata : undefined
|
||||
@@ -805,9 +945,17 @@ watch(() => [
|
||||
props.currentOptimizationMode,
|
||||
props.prefill,
|
||||
props.favorite,
|
||||
], async () => {
|
||||
], async (_value, _oldValue, onCleanup) => {
|
||||
const requestId = ++hydrateRequestId
|
||||
let cancelled = false
|
||||
const isStale = () => cancelled || requestId !== hydrateRequestId
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
mediaTouched.value = false
|
||||
await loadTags()
|
||||
if (isStale()) return
|
||||
|
||||
if (props.mode === 'create') {
|
||||
formData.title = ''
|
||||
@@ -832,21 +980,26 @@ watch(() => [
|
||||
formData.functionMode = normalizeFavoriteFunctionMode(props.favorite.functionMode)
|
||||
formData.optimizationMode = props.favorite.optimizationMode
|
||||
formData.imageSubMode = props.favorite.imageSubMode
|
||||
await hydrateMediaDraft(undefined, props.favorite)
|
||||
await hydrateMediaDraft(undefined, props.favorite, isStale)
|
||||
if (isStale()) return
|
||||
hydrateReproducibilityDraft(undefined, props.favorite)
|
||||
await hydrateReproducibilityExamplePreviews(undefined, props.favorite, isStale)
|
||||
return
|
||||
}
|
||||
|
||||
const prefill = props.prefill
|
||||
const resolvedCategory = await resolvePrefillCategoryId(
|
||||
typeof prefill?.category === 'string' ? prefill.category : '',
|
||||
)
|
||||
if (isStale()) return
|
||||
|
||||
const titleSource = (typeof prefill?.title === 'string' && prefill.title.trim()
|
||||
? prefill.title
|
||||
: props.originalContent || props.content || '')
|
||||
formData.title = titleSource.replace(/\r?\n/g, ' ').substring(0, 30).trim()
|
||||
formData.content = props.content || ''
|
||||
formData.description = typeof prefill?.description === 'string' ? prefill.description : ''
|
||||
formData.category = await resolvePrefillCategoryId(
|
||||
typeof prefill?.category === 'string' ? prefill.category : '',
|
||||
)
|
||||
formData.category = resolvedCategory
|
||||
formData.tags = Array.isArray(prefill?.tags)
|
||||
? dedupeStrings(prefill.tags.map((tag) => String(tag || '').trim()).filter(Boolean))
|
||||
: []
|
||||
@@ -886,8 +1039,10 @@ watch(() => [
|
||||
prefill?.metadata && typeof prefill.metadata === 'object'
|
||||
? (prefill.metadata as Record<string, unknown>)
|
||||
: undefined
|
||||
await hydrateMediaDraft(prefillMetadata)
|
||||
await hydrateMediaDraft(prefillMetadata, undefined, isStale)
|
||||
if (isStale()) return
|
||||
hydrateReproducibilityDraft(prefillMetadata)
|
||||
await hydrateReproducibilityExamplePreviews(prefillMetadata, undefined, isStale)
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
const updateViewportWidth = () => {
|
||||
@@ -903,6 +1058,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
hydrateRequestId += 1
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', updateViewportWidth)
|
||||
}
|
||||
@@ -926,6 +1082,10 @@ onBeforeUnmount(() => {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.favorite-editor-form--embedded .favorite-editor-form__content {
|
||||
padding: 20px 20px 96px;
|
||||
}
|
||||
|
||||
.favorite-editor-form__tag-field {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -999,6 +1159,10 @@ onBeforeUnmount(() => {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.favorite-editor-form--embedded .favorite-editor-form__content {
|
||||
padding: 16px 16px 88px;
|
||||
}
|
||||
|
||||
.favorite-editor-form__media-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ const props = withDefaults(defineProps<{
|
||||
active?: boolean
|
||||
layout?: LibraryLayout
|
||||
initialModeFilter?: FavoriteModeFilterKey
|
||||
useFavorite?: (favorite: FavoritePrompt) => boolean | Promise<boolean>
|
||||
useFavorite?: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => boolean | Promise<boolean>
|
||||
}>(), {
|
||||
active: true,
|
||||
layout: 'modal',
|
||||
@@ -569,7 +569,7 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'use-favorite': [favorite: FavoritePrompt]
|
||||
'use-favorite': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
|
||||
}>()
|
||||
|
||||
const services = inject<Ref<AppServices | null> | null>('services', null)
|
||||
@@ -1306,14 +1306,17 @@ const handleDeleteFavorite = (favorite: FavoritePrompt) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseFavorite = async (favorite: FavoritePrompt) => {
|
||||
const handleUseFavorite = async (
|
||||
favorite: FavoritePrompt,
|
||||
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
|
||||
) => {
|
||||
let used = true
|
||||
|
||||
try {
|
||||
if (props.useFavorite) {
|
||||
used = await props.useFavorite(favorite)
|
||||
used = await props.useFavorite(favorite, options)
|
||||
} else {
|
||||
emit('use-favorite', favorite)
|
||||
emit('use-favorite', favorite, options)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FavoriteManager] Failed to use favorite:', error)
|
||||
|
||||
@@ -38,7 +38,7 @@ const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show?: boolean
|
||||
useFavorite?: (favorite: FavoritePrompt) => boolean | Promise<boolean>
|
||||
useFavorite?: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => boolean | Promise<boolean>
|
||||
}>(), {
|
||||
show: false,
|
||||
})
|
||||
@@ -46,7 +46,7 @@ const props = withDefaults(defineProps<{
|
||||
const emit = defineEmits<{
|
||||
// Legacy event kept for consumers that still bind it; the shared library no longer exposes this action.
|
||||
'optimize-prompt': []
|
||||
'use-favorite': [favorite: FavoritePrompt]
|
||||
'use-favorite': [favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }]
|
||||
'update:show': [value: boolean]
|
||||
'close': []
|
||||
}>()
|
||||
@@ -56,12 +56,15 @@ const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleUseFavorite = async (favorite: FavoritePrompt) => {
|
||||
const handleUseFavorite = async (
|
||||
favorite: FavoritePrompt,
|
||||
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
|
||||
) => {
|
||||
if (props.useFavorite) {
|
||||
return props.useFavorite(favorite)
|
||||
return props.useFavorite(favorite, options)
|
||||
}
|
||||
|
||||
emit('use-favorite', favorite)
|
||||
emit('use-favorite', favorite, options)
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
v-for="plugin in activePlugins"
|
||||
:key="plugin.id"
|
||||
:favorite="favorite"
|
||||
:garden-snapshot-hidden-sections="gardenSnapshotHiddenSections"
|
||||
:garden-snapshot-source-only="gardenSnapshotSourceOnly"
|
||||
@favorite-updated="handleFavoriteUpdated"
|
||||
/>
|
||||
</div>
|
||||
@@ -21,6 +23,8 @@ import {
|
||||
|
||||
const props = defineProps<{
|
||||
favorite: FavoritePrompt
|
||||
gardenSnapshotHiddenSections?: string[]
|
||||
gardenSnapshotSourceOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -80,6 +80,15 @@
|
||||
<NText strong>
|
||||
{{ example.id || t('favorites.manager.preview.reproducibility.exampleLabel', { index: index + 1 }) }}
|
||||
</NText>
|
||||
<NButton
|
||||
size="small"
|
||||
secondary
|
||||
type="primary"
|
||||
:data-testid="`favorite-repro-example-apply-${index}`"
|
||||
@click="$emit('apply-example', { exampleId: example.id, exampleIndex: index })"
|
||||
>
|
||||
{{ t('favorites.manager.preview.reproducibility.applyExample') }}
|
||||
</NButton>
|
||||
<NText v-if="example.text">{{ example.text }}</NText>
|
||||
<NText v-if="example.description" depth="3">{{ example.description }}</NText>
|
||||
|
||||
@@ -98,6 +107,44 @@
|
||||
{{ row.value }}
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
|
||||
<div
|
||||
v-if="getExampleImageSources(index, 'images', example).length > 0"
|
||||
class="favorite-reproducibility-display__image-block"
|
||||
>
|
||||
<NText strong>{{ t('favorites.manager.preview.reproducibility.images') }}</NText>
|
||||
<AppPreviewImageGroup>
|
||||
<div class="favorite-reproducibility-display__image-grid">
|
||||
<AppPreviewImage
|
||||
v-for="(source, imageIndex) in getExampleImageSources(index, 'images', example)"
|
||||
:key="`example-${index}-image-${imageIndex}-${source.slice(0, 24)}`"
|
||||
:src="source"
|
||||
:alt="t('favorites.dialog.imageAlt', { index: imageIndex + 1 })"
|
||||
object-fit="cover"
|
||||
class="favorite-reproducibility-display__image"
|
||||
/>
|
||||
</div>
|
||||
</AppPreviewImageGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="getExampleImageSources(index, 'inputImages', example).length > 0"
|
||||
class="favorite-reproducibility-display__image-block"
|
||||
>
|
||||
<NText strong>{{ t('favorites.manager.preview.reproducibility.inputImages') }}</NText>
|
||||
<AppPreviewImageGroup>
|
||||
<div class="favorite-reproducibility-display__image-grid">
|
||||
<AppPreviewImage
|
||||
v-for="(source, imageIndex) in getExampleImageSources(index, 'inputImages', example)"
|
||||
:key="`example-${index}-input-image-${imageIndex}-${source.slice(0, 24)}`"
|
||||
:src="source"
|
||||
:alt="t('favorites.dialog.imageAlt', { index: imageIndex + 1 })"
|
||||
object-fit="cover"
|
||||
class="favorite-reproducibility-display__image"
|
||||
/>
|
||||
</div>
|
||||
</AppPreviewImageGroup>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
@@ -108,6 +155,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NCard,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
@@ -123,18 +171,42 @@ import type {
|
||||
FavoriteReproducibility,
|
||||
FavoriteReproducibilityExample,
|
||||
} from '../utils/favorite-reproducibility'
|
||||
import AppPreviewImage from './media/AppPreviewImage.vue'
|
||||
import AppPreviewImageGroup from './media/AppPreviewImageGroup.vue'
|
||||
|
||||
defineProps<{
|
||||
type FavoriteReproducibilityExamplePreviews = {
|
||||
images: Array<{ assetId: string; source: string }>
|
||||
inputImages: Array<{ assetId: string; source: string }>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
reproducibility: FavoriteReproducibility
|
||||
examplePreviews?: FavoriteReproducibilityExamplePreviews[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'apply-example': [options: { exampleId?: string; exampleIndex: number }]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean)))
|
||||
|
||||
const getExampleImageSources = (
|
||||
index: number,
|
||||
field: 'images' | 'inputImages',
|
||||
example: FavoriteReproducibilityExample,
|
||||
) => {
|
||||
const resolvedSources = props.examplePreviews?.[index]?.[field] || []
|
||||
return dedupeStrings([
|
||||
...(example[field] || []),
|
||||
...resolvedSources.map((item) => item.source),
|
||||
])
|
||||
}
|
||||
|
||||
const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
|
||||
const rows: Array<{ label: string; value: string }> = []
|
||||
const parameterEntries = Object.entries(example.parameters)
|
||||
const imageCount = example.images.length + example.imageAssetIds.length
|
||||
const inputImageCount = example.inputImages.length + example.inputImageAssetIds.length
|
||||
|
||||
if (parameterEntries.length > 0) {
|
||||
rows.push({
|
||||
@@ -143,20 +215,6 @@ const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (imageCount > 0) {
|
||||
rows.push({
|
||||
label: t('favorites.manager.preview.reproducibility.images'),
|
||||
value: String(imageCount),
|
||||
})
|
||||
}
|
||||
|
||||
if (inputImageCount > 0) {
|
||||
rows.push({
|
||||
label: t('favorites.manager.preview.reproducibility.inputImages'),
|
||||
value: String(inputImageCount),
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
</script>
|
||||
@@ -197,4 +255,25 @@ const exampleSummaryRows = (example: FavoriteReproducibilityExample) => {
|
||||
.favorite-reproducibility-display :deep(.n-text) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-display__image-block {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-display__image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-display__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--n-color-embedded);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
{{ t('favorites.dialog.reproducibility.empty') }}
|
||||
</NText>
|
||||
<NSpace :size="8" wrap>
|
||||
<NButton size="small" secondary @click="handleAddVariable">
|
||||
<NButton data-testid="favorite-repro-add-variable-empty" size="small" secondary @click="handleAddVariable">
|
||||
{{ t('favorites.dialog.reproducibility.addVariable') }}
|
||||
</NButton>
|
||||
<NButton size="small" secondary @click="handleAddExample">
|
||||
<NButton data-testid="favorite-repro-add-example-empty" size="small" secondary @click="handleAddExample">
|
||||
{{ t('favorites.dialog.reproducibility.addExample') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
@@ -27,7 +27,7 @@
|
||||
<section class="favorite-reproducibility-editor__section">
|
||||
<div class="favorite-reproducibility-editor__section-header">
|
||||
<NText strong>{{ t('favorites.dialog.reproducibility.variables') }}</NText>
|
||||
<NButton size="small" secondary @click="handleAddVariable">
|
||||
<NButton data-testid="favorite-repro-add-variable" size="small" secondary @click="handleAddVariable">
|
||||
{{ t('favorites.dialog.reproducibility.addVariable') }}
|
||||
</NButton>
|
||||
</div>
|
||||
@@ -55,6 +55,7 @@
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
data-testid="favorite-repro-variable-default"
|
||||
:value="variable.defaultValue"
|
||||
:placeholder="t('favorites.dialog.reproducibility.variableDefaultPlaceholder')"
|
||||
@update:value="updateVariable(index, { defaultValue: $event })"
|
||||
@@ -62,6 +63,7 @@
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NSelect
|
||||
data-testid="favorite-repro-variable-type"
|
||||
:value="variable.type"
|
||||
clearable
|
||||
:options="variableTypeOptions"
|
||||
@@ -71,6 +73,7 @@
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
data-testid="favorite-repro-variable-options"
|
||||
:value="formatOptions(variable.options)"
|
||||
:placeholder="t('favorites.dialog.reproducibility.variableOptionsPlaceholder')"
|
||||
@update:value="updateVariable(index, { options: parseListText($event) })"
|
||||
@@ -78,6 +81,7 @@
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
data-testid="favorite-repro-variable-description"
|
||||
:value="variable.description"
|
||||
:placeholder="t('favorites.dialog.reproducibility.variableDescriptionPlaceholder')"
|
||||
@update:value="updateVariable(index, { description: $event })"
|
||||
@@ -87,12 +91,14 @@
|
||||
|
||||
<div class="favorite-reproducibility-editor__item-actions">
|
||||
<NCheckbox
|
||||
data-testid="favorite-repro-variable-required"
|
||||
:checked="variable.required"
|
||||
@update:checked="updateVariable(index, { required: Boolean($event) })"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.required') }}
|
||||
</NCheckbox>
|
||||
<NButton
|
||||
data-testid="favorite-repro-remove-variable"
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
@@ -108,7 +114,7 @@
|
||||
<section class="favorite-reproducibility-editor__section">
|
||||
<div class="favorite-reproducibility-editor__section-header">
|
||||
<NText strong>{{ t('favorites.dialog.reproducibility.examples') }}</NText>
|
||||
<NButton size="small" secondary @click="handleAddExample">
|
||||
<NButton data-testid="favorite-repro-add-example" size="small" secondary @click="handleAddExample">
|
||||
{{ t('favorites.dialog.reproducibility.addExample') }}
|
||||
</NButton>
|
||||
</div>
|
||||
@@ -123,53 +129,14 @@
|
||||
<div
|
||||
v-for="(example, index) in examples"
|
||||
:key="`${index}-${example.id || example.text || 'example'}`"
|
||||
class="favorite-reproducibility-editor__item"
|
||||
class="favorite-reproducibility-editor__item favorite-reproducibility-editor__example"
|
||||
>
|
||||
<NGrid cols="1 s:2" :x-gap="10" :y-gap="8" responsive="screen">
|
||||
<NGridItem>
|
||||
<NInput
|
||||
:value="example.id"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleIdPlaceholder')"
|
||||
@update:value="updateExample(index, { id: $event })"
|
||||
/>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
:value="example.text"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleTextPlaceholder')"
|
||||
@update:value="updateExample(index, { text: $event })"
|
||||
/>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
:value="example.description"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleDescriptionPlaceholder')"
|
||||
@update:value="updateExample(index, { description: $event })"
|
||||
/>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-parameters"
|
||||
:value="formatParameters(example.parameters)"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleParametersPlaceholder')"
|
||||
@update:value="updateExample(index, { parameters: parseParametersText($event) })"
|
||||
/>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NInput
|
||||
:value="formatList(example.inputImages)"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleInputImagesPlaceholder')"
|
||||
@update:value="updateExample(index, { inputImages: parseListText($event) })"
|
||||
/>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<div class="favorite-reproducibility-editor__item-actions favorite-reproducibility-editor__item-actions--end">
|
||||
<div class="favorite-reproducibility-editor__example-header">
|
||||
<NText strong>
|
||||
{{ example.id || t('favorites.dialog.reproducibility.exampleLabel', { index: index + 1 }) }}
|
||||
</NText>
|
||||
<NButton
|
||||
data-testid="favorite-repro-remove-example"
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
@@ -178,6 +145,224 @@
|
||||
{{ t('favorites.dialog.reproducibility.remove') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="favorite-reproducibility-editor__example-basic">
|
||||
<div>
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-id"
|
||||
:value="example.id"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleIdPlaceholder')"
|
||||
@update:value="updateExample(index, { id: $event })"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-text"
|
||||
:value="example.text"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleTextPlaceholder')"
|
||||
@update:value="updateExample(index, { text: $event })"
|
||||
/>
|
||||
</div>
|
||||
<div class="favorite-reproducibility-editor__example-description">
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-description"
|
||||
:value="example.description"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleDescriptionPlaceholder')"
|
||||
@update:value="updateExample(index, { description: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="favorite-reproducibility-editor__example-section">
|
||||
<div class="favorite-reproducibility-editor__parameter-field">
|
||||
<NText strong>{{ t('favorites.dialog.reproducibility.exampleParametersLabel') }}</NText>
|
||||
<NSpace
|
||||
v-if="getParameterEntries(example.parameters).length > 0"
|
||||
vertical
|
||||
:size="6"
|
||||
>
|
||||
<div
|
||||
v-for="[parameterKey, parameterValue] in getParameterEntries(example.parameters)"
|
||||
:key="parameterKey"
|
||||
class="favorite-reproducibility-editor__parameter-row"
|
||||
>
|
||||
<NText class="favorite-reproducibility-editor__parameter-key">
|
||||
{{ parameterKey }}
|
||||
</NText>
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-parameter-value"
|
||||
:value="parameterValue"
|
||||
:placeholder="t('favorites.dialog.reproducibility.parameterValuePlaceholder')"
|
||||
@update:value="handleUpdateExampleParameterValue(index, parameterKey, $event)"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-remove-parameter"
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
@click="handleRemoveExampleParameter(index, parameterKey)"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.remove') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</NSpace>
|
||||
<div class="favorite-reproducibility-editor__parameter-row">
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-parameter-key"
|
||||
:value="getParameterDraft(index).key"
|
||||
:placeholder="t('favorites.dialog.reproducibility.parameterNamePlaceholder')"
|
||||
@update:value="setParameterDraft(index, 'key', $event)"
|
||||
/>
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-parameter-new-value"
|
||||
:value="getParameterDraft(index).value"
|
||||
:placeholder="t('favorites.dialog.reproducibility.parameterValuePlaceholder')"
|
||||
@update:value="setParameterDraft(index, 'value', $event)"
|
||||
@keydown.enter.prevent="handleAddExampleParameter(index)"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-add-parameter"
|
||||
size="small"
|
||||
secondary
|
||||
@click="handleAddExampleParameter(index)"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.addParameter') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="favorite-reproducibility-editor__example-media-grid">
|
||||
<div class="favorite-reproducibility-editor__image-field">
|
||||
<NText strong>{{ t('favorites.dialog.reproducibility.exampleImages') }}</NText>
|
||||
<NSpace :size="6" align="center" wrap class="favorite-reproducibility-editor__image-toolbar">
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-images"
|
||||
:value="getImageUrlDraft(index, 'images')"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleImagesPlaceholder')"
|
||||
@update:value="setImageUrlDraft(index, 'images', $event)"
|
||||
@keydown.enter.prevent="handleAddExampleImageUrl(index, 'images')"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-add-image-url"
|
||||
size="small"
|
||||
secondary
|
||||
class="favorite-reproducibility-editor__image-action"
|
||||
@click="handleAddExampleImageUrl(index, 'images')"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.addImageUrl') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NUpload
|
||||
data-testid="favorite-repro-example-image-upload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@before-upload="(options) => handleBeforeExampleImageUpload(index, 'images', options)"
|
||||
>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-add-images"
|
||||
size="small"
|
||||
secondary
|
||||
class="favorite-reproducibility-editor__image-action"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.addExampleImages') }}
|
||||
</NButton>
|
||||
</NUpload>
|
||||
<AppPreviewImageGroup v-if="getExampleImageItems(index, 'images', example).length > 0">
|
||||
<div class="favorite-reproducibility-editor__image-grid">
|
||||
<div
|
||||
v-for="item in getExampleImageItems(index, 'images', example)"
|
||||
:key="item.key"
|
||||
class="favorite-reproducibility-editor__image-item"
|
||||
>
|
||||
<AppPreviewImage
|
||||
:src="item.source"
|
||||
:alt="t('favorites.dialog.imageAlt', { index: item.displayIndex + 1 })"
|
||||
object-fit="cover"
|
||||
class="favorite-reproducibility-editor__image"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-remove-image"
|
||||
size="tiny"
|
||||
type="error"
|
||||
quaternary
|
||||
class="favorite-reproducibility-editor__image-remove"
|
||||
@click="handleRemoveExampleImage(index, 'images', item)"
|
||||
>
|
||||
×
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppPreviewImageGroup>
|
||||
</div>
|
||||
|
||||
<div class="favorite-reproducibility-editor__image-field">
|
||||
<NText strong>{{ t('favorites.dialog.reproducibility.exampleInputImages') }}</NText>
|
||||
<NSpace :size="6" align="center" wrap class="favorite-reproducibility-editor__image-toolbar">
|
||||
<NInput
|
||||
data-testid="favorite-repro-example-input-images"
|
||||
:value="getImageUrlDraft(index, 'inputImages')"
|
||||
:placeholder="t('favorites.dialog.reproducibility.exampleInputImagesPlaceholder')"
|
||||
@update:value="setImageUrlDraft(index, 'inputImages', $event)"
|
||||
@keydown.enter.prevent="handleAddExampleImageUrl(index, 'inputImages')"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-add-input-image-url"
|
||||
size="small"
|
||||
secondary
|
||||
class="favorite-reproducibility-editor__image-action"
|
||||
@click="handleAddExampleImageUrl(index, 'inputImages')"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.addImageUrl') }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NUpload
|
||||
data-testid="favorite-repro-example-input-image-upload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
:default-upload="false"
|
||||
:show-file-list="false"
|
||||
@before-upload="(options) => handleBeforeExampleImageUpload(index, 'inputImages', options)"
|
||||
>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-add-input-images"
|
||||
size="small"
|
||||
secondary
|
||||
class="favorite-reproducibility-editor__image-action"
|
||||
>
|
||||
{{ t('favorites.dialog.reproducibility.addExampleInputImages') }}
|
||||
</NButton>
|
||||
</NUpload>
|
||||
<AppPreviewImageGroup v-if="getExampleImageItems(index, 'inputImages', example).length > 0">
|
||||
<div class="favorite-reproducibility-editor__image-grid">
|
||||
<div
|
||||
v-for="item in getExampleImageItems(index, 'inputImages', example)"
|
||||
:key="item.key"
|
||||
class="favorite-reproducibility-editor__image-item"
|
||||
>
|
||||
<AppPreviewImage
|
||||
:src="item.source"
|
||||
:alt="t('favorites.dialog.imageAlt', { index: item.displayIndex + 1 })"
|
||||
object-fit="cover"
|
||||
class="favorite-reproducibility-editor__image"
|
||||
/>
|
||||
<NButton
|
||||
data-testid="favorite-repro-example-remove-input-image"
|
||||
size="tiny"
|
||||
type="error"
|
||||
quaternary
|
||||
class="favorite-reproducibility-editor__image-remove"
|
||||
@click="handleRemoveExampleImage(index, 'inputImages', item)"
|
||||
>
|
||||
×
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppPreviewImageGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpace>
|
||||
</section>
|
||||
@@ -198,18 +383,44 @@ import {
|
||||
NSelect,
|
||||
NSpace,
|
||||
NText,
|
||||
NUpload,
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
FavoriteReproducibilityExample,
|
||||
FavoriteReproducibilityVariable,
|
||||
} from '../utils/favorite-reproducibility'
|
||||
import AppPreviewImage from './media/AppPreviewImage.vue'
|
||||
import AppPreviewImageGroup from './media/AppPreviewImageGroup.vue'
|
||||
|
||||
type FavoriteReproducibilityImagePreview = {
|
||||
assetId: string
|
||||
source: string
|
||||
}
|
||||
|
||||
type FavoriteReproducibilityExamplePreviews = {
|
||||
images: FavoriteReproducibilityImagePreview[]
|
||||
inputImages: FavoriteReproducibilityImagePreview[]
|
||||
}
|
||||
|
||||
type ExampleImageField = 'images' | 'inputImages'
|
||||
type ExampleAssetField = 'imageAssetIds' | 'inputImageAssetIds'
|
||||
type ExampleImageItem = {
|
||||
key: string
|
||||
source: string
|
||||
displayIndex: number
|
||||
kind: 'source' | 'asset'
|
||||
sourceIndex?: number
|
||||
assetId?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
variables: FavoriteReproducibilityVariable[]
|
||||
examples: FavoriteReproducibilityExample[]
|
||||
examplePreviews?: FavoriteReproducibilityExamplePreviews[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -230,8 +441,19 @@ const hasReproducibilityData = computed(
|
||||
() => props.variables.length > 0 || props.examples.length > 0,
|
||||
)
|
||||
|
||||
const formatList = (items: string[] | undefined) => (items || []).join('\n')
|
||||
let uploadSequence = 0
|
||||
let exampleKeySequence = 0
|
||||
let lastEmittedExamples: FavoriteReproducibilityExample[] | null = null
|
||||
const exampleDraftKeys = ref<string[]>([])
|
||||
const imageUrlDrafts = reactive<Record<string, string>>({})
|
||||
const parameterDrafts = reactive<Record<number, { key: string; value: string }>>({})
|
||||
|
||||
const formatOptions = (items: string[] | undefined) => (items || []).join(', ')
|
||||
const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean)))
|
||||
const assetFieldByImageField: Record<ExampleImageField, ExampleAssetField> = {
|
||||
images: 'imageAssetIds',
|
||||
inputImages: 'inputImageAssetIds',
|
||||
}
|
||||
|
||||
const parseListText = (value: string): string[] => {
|
||||
return String(value || '')
|
||||
@@ -240,29 +462,129 @@ const parseListText = (value: string): string[] => {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const formatParameters = (parameters: Record<string, string> | undefined) => {
|
||||
return Object.entries(parameters || {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')
|
||||
const getParameterEntries = (parameters: Record<string, string> | undefined) =>
|
||||
Object.entries(parameters || {})
|
||||
|
||||
const getParameterDraft = (index: number) => {
|
||||
parameterDrafts[index] ||= { key: '', value: '' }
|
||||
return parameterDrafts[index]
|
||||
}
|
||||
|
||||
const parseParametersText = (value: string): Record<string, string> => {
|
||||
const parameters: Record<string, string> = {}
|
||||
String(value || '')
|
||||
.split(/\r?\n/u)
|
||||
.forEach((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) return
|
||||
const separatorIndex = trimmed.indexOf('=')
|
||||
if (separatorIndex < 0) {
|
||||
parameters[trimmed] = ''
|
||||
return
|
||||
const setParameterDraft = (
|
||||
index: number,
|
||||
field: 'key' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const draft = getParameterDraft(index)
|
||||
draft[field] = value
|
||||
}
|
||||
|
||||
const getImageUrlDraftKey = (index: number, field: ExampleImageField) => `${index}:${field}`
|
||||
|
||||
const createExampleDraftKey = () => `example-${++exampleKeySequence}`
|
||||
|
||||
const clearExampleDrafts = () => {
|
||||
Object.keys(parameterDrafts).forEach((key) => {
|
||||
delete parameterDrafts[Number(key)]
|
||||
})
|
||||
Object.keys(imageUrlDrafts).forEach((key) => {
|
||||
delete imageUrlDrafts[key]
|
||||
})
|
||||
}
|
||||
|
||||
const emitExamples = (
|
||||
examples: FavoriteReproducibilityExample[],
|
||||
draftKeys = exampleDraftKeys.value,
|
||||
) => {
|
||||
exampleDraftKeys.value = draftKeys
|
||||
lastEmittedExamples = examples
|
||||
emit('update:examples', examples)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.examples,
|
||||
(examples) => {
|
||||
if (examples === lastEmittedExamples) {
|
||||
lastEmittedExamples = null
|
||||
if (exampleDraftKeys.value.length !== examples.length) {
|
||||
exampleDraftKeys.value = examples.map(() => createExampleDraftKey())
|
||||
}
|
||||
const key = trimmed.slice(0, separatorIndex).trim()
|
||||
if (!key) return
|
||||
parameters[key] = trimmed.slice(separatorIndex + 1).trim()
|
||||
})
|
||||
return parameters
|
||||
return
|
||||
}
|
||||
|
||||
uploadSequence += 1
|
||||
exampleDraftKeys.value = examples.map(() => createExampleDraftKey())
|
||||
clearExampleDrafts()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleAddExampleParameter = (index: number) => {
|
||||
const draft = getParameterDraft(index)
|
||||
const key = draft.key.trim()
|
||||
if (!key) return
|
||||
|
||||
const example = props.examples[index]
|
||||
if (!example) return
|
||||
|
||||
updateExample(index, {
|
||||
parameters: {
|
||||
...example.parameters,
|
||||
[key]: draft.value,
|
||||
},
|
||||
})
|
||||
parameterDrafts[index] = { key: '', value: '' }
|
||||
}
|
||||
|
||||
const handleUpdateExampleParameterValue = (
|
||||
index: number,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
const example = props.examples[index]
|
||||
if (!example) return
|
||||
|
||||
updateExample(index, {
|
||||
parameters: {
|
||||
...example.parameters,
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveExampleParameter = (index: number, key: string) => {
|
||||
const example = props.examples[index]
|
||||
if (!example) return
|
||||
|
||||
const nextParameters = { ...example.parameters }
|
||||
delete nextParameters[key]
|
||||
updateExample(index, { parameters: nextParameters })
|
||||
}
|
||||
|
||||
const removeExampleDrafts = (removedIndex: number) => {
|
||||
const nextParameterDrafts: Record<number, { key: string; value: string }> = {}
|
||||
Object.entries(parameterDrafts).forEach(([indexText, draft]) => {
|
||||
const index = Number(indexText)
|
||||
if (!Number.isInteger(index) || index === removedIndex) return
|
||||
nextParameterDrafts[index > removedIndex ? index - 1 : index] = draft
|
||||
})
|
||||
Object.keys(parameterDrafts).forEach((key) => {
|
||||
delete parameterDrafts[Number(key)]
|
||||
})
|
||||
Object.assign(parameterDrafts, nextParameterDrafts)
|
||||
|
||||
const nextImageUrlDrafts: Record<string, string> = {}
|
||||
Object.entries(imageUrlDrafts).forEach(([key, value]) => {
|
||||
const match = key.match(/^(\d+):(images|inputImages)$/u)
|
||||
if (!match) return
|
||||
const index = Number(match[1])
|
||||
if (index === removedIndex) return
|
||||
nextImageUrlDrafts[getImageUrlDraftKey(index > removedIndex ? index - 1 : index, match[2] as ExampleImageField)] = value
|
||||
})
|
||||
Object.keys(imageUrlDrafts).forEach((key) => {
|
||||
delete imageUrlDrafts[key]
|
||||
})
|
||||
Object.assign(imageUrlDrafts, nextImageUrlDrafts)
|
||||
}
|
||||
|
||||
const handleAddVariable = () => {
|
||||
@@ -293,7 +615,7 @@ const handleRemoveVariable = (index: number) => {
|
||||
}
|
||||
|
||||
const handleAddExample = () => {
|
||||
emit('update:examples', [
|
||||
emitExamples([
|
||||
...props.examples,
|
||||
{
|
||||
parameters: {},
|
||||
@@ -302,6 +624,9 @@ const handleAddExample = () => {
|
||||
inputImages: [],
|
||||
inputImageAssetIds: [],
|
||||
},
|
||||
], [
|
||||
...exampleDraftKeys.value,
|
||||
createExampleDraftKey(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -309,16 +634,123 @@ const updateExample = (
|
||||
index: number,
|
||||
patch: Partial<FavoriteReproducibilityExample>,
|
||||
) => {
|
||||
emit(
|
||||
'update:examples',
|
||||
emitExamples(
|
||||
props.examples.map((example, currentIndex) =>
|
||||
currentIndex === index ? { ...example, ...patch } : example,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const getImageUrlDraft = (index: number, field: ExampleImageField) =>
|
||||
imageUrlDrafts[getImageUrlDraftKey(index, field)] || ''
|
||||
|
||||
const setImageUrlDraft = (index: number, field: ExampleImageField, value: string) => {
|
||||
imageUrlDrafts[getImageUrlDraftKey(index, field)] = value
|
||||
}
|
||||
|
||||
const handleAddExampleImageUrl = (index: number, field: ExampleImageField) => {
|
||||
const value = getImageUrlDraft(index, field).trim()
|
||||
const example = props.examples[index]
|
||||
if (!example || !value) return
|
||||
|
||||
updateExample(index, {
|
||||
[field]: dedupeStrings([...(example[field] || []), value]),
|
||||
})
|
||||
imageUrlDrafts[getImageUrlDraftKey(index, field)] = ''
|
||||
}
|
||||
|
||||
const getExampleImageItems = (
|
||||
index: number,
|
||||
field: ExampleImageField,
|
||||
example: FavoriteReproducibilityExample,
|
||||
) => {
|
||||
const assetField = assetFieldByImageField[field]
|
||||
const existingAssetIds = new Set(example[assetField] || [])
|
||||
const sourceItems: ExampleImageItem[] = (example[field] || []).map((source, sourceIndex) => ({
|
||||
key: `${field}-source-${sourceIndex}-${source.slice(0, 24)}`,
|
||||
source,
|
||||
displayIndex: sourceIndex,
|
||||
kind: 'source',
|
||||
sourceIndex,
|
||||
}))
|
||||
const assetItems: ExampleImageItem[] = (props.examplePreviews?.[index]?.[field] || [])
|
||||
.filter((item) => existingAssetIds.has(item.assetId))
|
||||
.map((item, previewIndex) => ({
|
||||
key: `${field}-asset-${item.assetId}`,
|
||||
source: item.source,
|
||||
displayIndex: sourceItems.length + previewIndex,
|
||||
kind: 'asset',
|
||||
assetId: item.assetId,
|
||||
}))
|
||||
|
||||
return [...sourceItems, ...assetItems]
|
||||
}
|
||||
|
||||
const handleRemoveExampleImage = (
|
||||
index: number,
|
||||
field: ExampleImageField,
|
||||
item: ExampleImageItem,
|
||||
) => {
|
||||
const example = props.examples[index]
|
||||
if (!example) return
|
||||
|
||||
if (item.kind === 'asset' && item.assetId) {
|
||||
const assetField = assetFieldByImageField[field]
|
||||
updateExample(index, {
|
||||
[assetField]: (example[assetField] || []).filter((assetId) => assetId !== item.assetId),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof item.sourceIndex === 'number') {
|
||||
updateExample(index, {
|
||||
[field]: (example[field] || []).filter((_, currentIndex) => currentIndex !== item.sourceIndex),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveExample = (index: number) => {
|
||||
emit('update:examples', props.examples.filter((_, currentIndex) => currentIndex !== index))
|
||||
removeExampleDrafts(index)
|
||||
emitExamples(
|
||||
props.examples.filter((_, currentIndex) => currentIndex !== index),
|
||||
exampleDraftKeys.value.filter((_, currentIndex) => currentIndex !== index),
|
||||
)
|
||||
}
|
||||
|
||||
const readBlobAsDataUrl = (blob: Blob) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(new Error('Failed to read image file'))
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
|
||||
const handleBeforeExampleImageUpload = async (
|
||||
index: number,
|
||||
field: ExampleImageField,
|
||||
options: { file: UploadFileInfo },
|
||||
) => {
|
||||
const raw = (options.file as unknown as { file?: Blob | null }).file
|
||||
if (!raw) return false
|
||||
|
||||
const requestId = uploadSequence
|
||||
const targetDraftKey = exampleDraftKeys.value[index]
|
||||
try {
|
||||
const dataUrl = await readBlobAsDataUrl(raw)
|
||||
if (requestId !== uploadSequence) return false
|
||||
if (targetDraftKey !== exampleDraftKeys.value[index]) return false
|
||||
|
||||
const example = props.examples[index]
|
||||
if (!example || !dataUrl) return false
|
||||
|
||||
updateExample(index, {
|
||||
[field]: dedupeStrings([...(example[field] || []), dataUrl]),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[FavoriteReproducibilityEditor] Failed to read selected example image:', error)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -358,6 +790,36 @@ const handleRemoveExample = (index: number) => {
|
||||
background: var(--n-color);
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__example {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__example-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__example-basic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__example-description,
|
||||
.favorite-reproducibility-editor__example-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__example-media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__item-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -365,4 +827,80 @@ const handleRemoveExample = (index: number) => {
|
||||
.favorite-reproducibility-editor__item-actions--end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__parameter-field,
|
||||
.favorite-reproducibility-editor__image-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__parameter-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(92px, 0.8fr) minmax(0, 1.2fr) auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__parameter-key {
|
||||
min-width: 0;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--n-color-embedded);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image-toolbar :deep(.n-input) {
|
||||
min-width: 160px;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image-action {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image-item {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--n-color-embedded);
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__image-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
background: var(--n-color);
|
||||
border-radius: 999px;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.favorite-reproducibility-editor__example-basic,
|
||||
.favorite-reproducibility-editor__example-media-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.favorite-reproducibility-editor__parameter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
'favorite-workspace-list-item--with-actions': showQuickActions,
|
||||
'favorite-workspace-list-item--card': variant === 'card',
|
||||
}"
|
||||
data-testid="favorite-workspace-list-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleItemClick"
|
||||
@@ -80,7 +81,7 @@
|
||||
</NSpace>
|
||||
|
||||
<NDropdown trigger="click" :options="menuOptions" @select="handleMenuSelect">
|
||||
<NButton quaternary circle size="small" @click.stop>
|
||||
<NButton data-testid="favorite-workspace-item-menu" quaternary circle size="small" @click.stop>
|
||||
<template #icon>
|
||||
<NIcon><DotsVertical /></NIcon>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,46 @@
|
||||
<template>
|
||||
<div data-testid="favorite-garden-snapshot-preview">
|
||||
<NSpace vertical size="medium">
|
||||
<NDivider style="margin: 0;" />
|
||||
<NDivider v-if="!sourceOnly" style="margin: 0;" />
|
||||
|
||||
<NSpace vertical size="small">
|
||||
<NSpace v-if="!sourceOnly" vertical size="small">
|
||||
<NText strong>{{ t('favorites.manager.preview.garden.snapshotTitle') }}</NText>
|
||||
<NText depth="3">{{ t('favorites.manager.preview.garden.snapshotHint') }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<div
|
||||
v-if="sourceOnly && showBasicInfo"
|
||||
data-testid="favorite-garden-basic-info"
|
||||
>
|
||||
<NDescriptions :column="1" size="small" bordered label-placement="left">
|
||||
<NDescriptionsItem
|
||||
v-if="snapshot.importCode"
|
||||
:label="t('favorites.manager.preview.garden.importCode')"
|
||||
>
|
||||
{{ snapshot.importCode }}
|
||||
</NDescriptionsItem>
|
||||
|
||||
<NDescriptionsItem
|
||||
v-if="snapshot.gardenBaseUrl"
|
||||
:label="t('favorites.manager.preview.garden.gardenBaseUrl')"
|
||||
>
|
||||
{{ snapshot.gardenBaseUrl }}
|
||||
</NDescriptionsItem>
|
||||
|
||||
<NDescriptionsItem
|
||||
v-if="snapshot.schema"
|
||||
:label="t('favorites.manager.preview.garden.schema')"
|
||||
>
|
||||
{{ snapshot.schema }}
|
||||
<NText v-if="snapshot.schemaVersion !== undefined" depth="3">
|
||||
v{{ snapshot.schemaVersion }}
|
||||
</NText>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</div>
|
||||
|
||||
<NCollapse
|
||||
v-else
|
||||
:expanded-names="expandedSections"
|
||||
@update:expanded-names="handleExpandedNamesUpdate"
|
||||
>
|
||||
@@ -205,7 +237,7 @@
|
||||
</NCollapseItem>
|
||||
|
||||
<NCollapseItem
|
||||
v-if="snapshot.examples.length > 0"
|
||||
v-if="showExamplesSection"
|
||||
name="examples"
|
||||
:title="t('favorites.manager.preview.garden.examples')"
|
||||
>
|
||||
@@ -284,7 +316,7 @@
|
||||
</NCollapseItem>
|
||||
|
||||
<NCollapseItem
|
||||
v-if="snapshot.variables.length > 0"
|
||||
v-if="showVariablesSection"
|
||||
name="variables"
|
||||
:title="t('favorites.manager.preview.garden.variables')"
|
||||
>
|
||||
@@ -369,9 +401,13 @@ const props = withDefaults(defineProps<{
|
||||
snapshot: GardenSnapshotPreview
|
||||
editable?: boolean
|
||||
busy?: boolean
|
||||
hiddenSections?: SectionKey[]
|
||||
sourceOnly?: boolean
|
||||
}>(), {
|
||||
editable: false,
|
||||
busy: false,
|
||||
hiddenSections: () => [],
|
||||
sourceOnly: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -382,13 +418,16 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
|
||||
const expandedSections = ref<SectionKey[]>([...SECTION_KEYS])
|
||||
const hiddenSectionSet = computed(() => new Set(props.hiddenSections))
|
||||
|
||||
const isSectionHidden = (section: SectionKey) => hiddenSectionSet.value.has(section)
|
||||
|
||||
const showBasicInfo = computed(() => {
|
||||
return Boolean(props.snapshot.importCode || props.snapshot.gardenBaseUrl || props.snapshot.schema)
|
||||
return !isSectionHidden('basicInfo') && Boolean(props.snapshot.importCode || props.snapshot.gardenBaseUrl || props.snapshot.schema)
|
||||
})
|
||||
|
||||
const showMeta = computed(() => {
|
||||
return Boolean(
|
||||
return !isSectionHidden('metaInfo') && Boolean(
|
||||
props.snapshot.meta.title ||
|
||||
props.snapshot.meta.description ||
|
||||
props.snapshot.meta.tags.length > 0,
|
||||
@@ -396,11 +435,19 @@ const showMeta = computed(() => {
|
||||
})
|
||||
|
||||
const showCoverSection = computed(() => {
|
||||
return Boolean(props.snapshot.coverUrl || props.editable)
|
||||
return !isSectionHidden('cover') && Boolean(props.snapshot.coverUrl || props.editable)
|
||||
})
|
||||
|
||||
const showShowcasesSection = computed(() => {
|
||||
return Boolean(props.snapshot.showcases.length > 0 || props.editable)
|
||||
return !isSectionHidden('showcases') && Boolean(props.snapshot.showcases.length > 0 || props.editable)
|
||||
})
|
||||
|
||||
const showExamplesSection = computed(() => {
|
||||
return !isSectionHidden('examples') && props.snapshot.examples.length > 0
|
||||
})
|
||||
|
||||
const showVariablesSection = computed(() => {
|
||||
return !isSectionHidden('variables') && props.snapshot.variables.length > 0
|
||||
})
|
||||
|
||||
const parameterEntries = (asset: GardenSnapshotPreviewAsset): Array<[string, string]> => {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
:snapshot="snapshot"
|
||||
editable
|
||||
:busy="isSaving"
|
||||
:hidden-sections="gardenSnapshotHiddenSections"
|
||||
:source-only="gardenSnapshotSourceOnly"
|
||||
@upload-cover="handleGardenCoverUpload"
|
||||
@append-showcase-images="handleGardenShowcaseUpload"
|
||||
/>
|
||||
@@ -30,6 +32,8 @@ import GardenSnapshotPreview from './GardenSnapshotPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
favorite: FavoritePrompt
|
||||
gardenSnapshotHiddenSections?: Array<'basicInfo' | 'metaInfo' | 'cover' | 'showcases' | 'examples' | 'variables'>
|
||||
gardenSnapshotSourceOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -243,6 +243,7 @@ import {
|
||||
normalizeWorkspacePath,
|
||||
parseWorkspaceRoutePath,
|
||||
} from '../../router/workspaceRoutes';
|
||||
import { createExternalDataLoadingGate } from '../../utils/external-data-loading'
|
||||
import { registerOptionalIntegrations } from '../../integrations/registerOptionalIntegrations';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
@@ -614,7 +615,8 @@ const hasRestoredInitialState = ref(false);
|
||||
|
||||
// ✅ 外部数据加载中标志(防止模式切换的自动 restore 覆盖外部数据)
|
||||
// 适用场景:历史记录恢复、收藏加载、模板导入等任何外部数据加载导致模式切换的情况
|
||||
const isLoadingExternalData = ref(false);
|
||||
const externalDataLoadingGate = createExternalDataLoadingGate();
|
||||
const isLoadingExternalData = externalDataLoadingGate.isLoading;
|
||||
|
||||
// 5. 控制主UI渲染的标志
|
||||
// 🔧 必须等待路由初始化完成,避免短暂显示根路径的空白页
|
||||
@@ -1585,8 +1587,11 @@ const navigateToSubModeKeyCompat = (
|
||||
toKey: string,
|
||||
opts?: { replace?: boolean },
|
||||
) => {
|
||||
if (!WORKSPACE_SUB_MODE_KEYS.includes(toKey as SubModeKey)) return Promise.resolve();
|
||||
return navigateToSubModeKey(toKey as SubModeKey, opts).then(() => undefined);
|
||||
if (!WORKSPACE_SUB_MODE_KEYS.includes(toKey as SubModeKey)) {
|
||||
console.warn(`[PromptOptimizerApp] Invalid workspace sub mode key: ${toKey}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return navigateToSubModeKey(toKey as SubModeKey, opts).then(() => true);
|
||||
};
|
||||
|
||||
const optimizerPrompt = computed<string>({
|
||||
@@ -1623,6 +1628,13 @@ const {
|
||||
optimizerPrompt,
|
||||
t,
|
||||
isLoadingExternalData,
|
||||
proMultiMessageSession,
|
||||
proVariableSession,
|
||||
imageText2ImageSession,
|
||||
imageImage2ImageSession,
|
||||
imageMultiImageSession,
|
||||
getFavoriteImageStorageService:
|
||||
() => services.value?.favoriteImageStorageService || services.value?.imageStorageService || null,
|
||||
});
|
||||
|
||||
const resolveFavoritesReturnPath = () =>
|
||||
@@ -1653,8 +1665,11 @@ const returnToWorkspace = () => {
|
||||
void routerInstance.push(resolveFavoritesReturnPath());
|
||||
};
|
||||
|
||||
const handleUseFavoriteFromPage = async (favorite: FavoritePrompt): Promise<boolean> => {
|
||||
const used = await handleUseFavorite(favorite);
|
||||
const handleUseFavoriteFromPage = async (
|
||||
favorite: FavoritePrompt,
|
||||
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
|
||||
): Promise<boolean> => {
|
||||
const used = await handleUseFavorite(favorite, options);
|
||||
|
||||
// handleUseFavorite awaits target workspace navigation. This fallback only covers
|
||||
// legacy/non-navigating favorite payloads that still leave the page route active.
|
||||
|
||||
@@ -127,9 +127,12 @@ const handleReturnToWorkspace = () => {
|
||||
void routerInstance.push(DEFAULT_WORKSPACE_PATH)
|
||||
}
|
||||
|
||||
const handleUseFavorite = async (favorite: FavoritePrompt): Promise<boolean> => {
|
||||
const handleUseFavorite = async (
|
||||
favorite: FavoritePrompt,
|
||||
options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number },
|
||||
): Promise<boolean> => {
|
||||
if (actions) {
|
||||
return await actions.useFavorite(favorite)
|
||||
return await actions.useFavorite(favorite, options)
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { InjectionKey } from 'vue'
|
||||
import type { FavoritePrompt } from '@prompt-optimizer/core'
|
||||
|
||||
export interface FavoritesPageActions {
|
||||
useFavorite: (favorite: FavoritePrompt) => boolean | Promise<boolean>
|
||||
useFavorite: (favorite: FavoritePrompt, options?: { applyExample?: boolean; exampleId?: string; exampleIndex?: number }) => Promise<boolean>
|
||||
returnToWorkspace: () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
|
||||
import { ref, nextTick, type Ref } from 'vue'
|
||||
import { useToast } from '../ui/useToast'
|
||||
import type { BasicSubMode, ProSubMode, ContextMode, OptimizationMode } from '@prompt-optimizer/core'
|
||||
import type { BasicSubMode, ProSubMode, ContextMode, IImageStorageService, OptimizationMode } from '@prompt-optimizer/core'
|
||||
import { isValidVariableName } from '../../types/variable'
|
||||
import {
|
||||
parseFavoriteReproducibility,
|
||||
type FavoriteReproducibilityExample,
|
||||
} from '../../utils/favorite-reproducibility'
|
||||
import {
|
||||
normalizeImageSourceToPayload,
|
||||
resolveAssetIdToDataUrl,
|
||||
type ImagePayload,
|
||||
} from '../../utils/image-asset-storage'
|
||||
|
||||
/**
|
||||
* 保存收藏的数据结构
|
||||
@@ -40,12 +50,33 @@ export interface FavoriteItem {
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface UseFavoriteOptions {
|
||||
applyExample?: boolean
|
||||
exampleId?: string
|
||||
exampleIndex?: number
|
||||
}
|
||||
|
||||
type TemporaryVariablesSessionApi = {
|
||||
getTemporaryVariable: (name: string) => string | undefined
|
||||
setTemporaryVariable: (name: string, value: string) => void
|
||||
clearTemporaryVariables: () => void
|
||||
updatePrompt?: (prompt: string) => void
|
||||
}
|
||||
|
||||
type Image2ImageExampleSessionApi = TemporaryVariablesSessionApi & {
|
||||
updateInputImage: (b64: string | null, mimeType?: string) => void
|
||||
}
|
||||
|
||||
type MultiImageExampleSessionApi = TemporaryVariablesSessionApi & {
|
||||
replaceInputImages: (images: ImagePayload[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* useAppFavorite 的配置选项
|
||||
*/
|
||||
export interface AppFavoriteOptions {
|
||||
/** 🔧 Step D: 路由导航函数(替代 setFunctionMode/set*SubMode) */
|
||||
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => void | Promise<void>
|
||||
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise<boolean | void>
|
||||
/** 处理上下文模式变更 */
|
||||
handleContextModeChange: (mode: ContextMode) => Promise<void>
|
||||
/** 优化器提示词(用于设置收藏内容) */
|
||||
@@ -54,6 +85,13 @@ export interface AppFavoriteOptions {
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
/** 外部数据加载中标志(防止模式切换的自动 restore 覆盖外部数据) */
|
||||
isLoadingExternalData: Ref<boolean>
|
||||
/** 高级/图像模式临时变量会话,用于应用收藏示例参数 */
|
||||
proMultiMessageSession?: TemporaryVariablesSessionApi
|
||||
proVariableSession?: TemporaryVariablesSessionApi
|
||||
imageText2ImageSession?: TemporaryVariablesSessionApi
|
||||
imageImage2ImageSession?: Image2ImageExampleSessionApi
|
||||
imageMultiImageSession?: MultiImageExampleSessionApi
|
||||
getFavoriteImageStorageService?: () => IImageStorageService | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +111,45 @@ export interface AppFavoriteReturn {
|
||||
/** 处理收藏优化提示词 */
|
||||
handleFavoriteOptimizePrompt: () => void
|
||||
/** 处理使用收藏 */
|
||||
handleUseFavorite: (favorite: FavoriteItem) => Promise<boolean>
|
||||
handleUseFavorite: (favorite: FavoriteItem, options?: UseFavoriteOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
const getFavoriteTargetKey = (favorite: FavoriteItem): string | null => {
|
||||
const favFunctionMode = favorite.functionMode
|
||||
if (favFunctionMode === 'image') {
|
||||
return `image-${favorite.imageSubMode || 'text2image'}`
|
||||
}
|
||||
|
||||
if (favFunctionMode === 'basic' || favFunctionMode === 'context' || favFunctionMode === 'pro') {
|
||||
const targetFunctionMode = (favFunctionMode === 'context' || favFunctionMode === 'pro') ? 'pro' : 'basic'
|
||||
if (targetFunctionMode === 'pro') {
|
||||
const mode = favorite.optimizationMode ?? 'user'
|
||||
return mode === 'system' ? 'pro-multi' : 'pro-variable'
|
||||
}
|
||||
|
||||
return `basic-${favorite.optimizationMode ?? 'system'}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const pickFavoriteExample = (
|
||||
examples: FavoriteReproducibilityExample[],
|
||||
options: UseFavoriteOptions,
|
||||
): FavoriteReproducibilityExample | null => {
|
||||
if (!options.applyExample || examples.length === 0) return null
|
||||
|
||||
const id = String(options.exampleId || '').trim()
|
||||
if (id) {
|
||||
const found = examples.find((example) => (example.id || '').trim() === id)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
if (typeof options.exampleIndex === 'number' && Number.isInteger(options.exampleIndex)) {
|
||||
return examples[options.exampleIndex] || null
|
||||
}
|
||||
|
||||
return examples[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +162,12 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
optimizerPrompt,
|
||||
t,
|
||||
isLoadingExternalData,
|
||||
proMultiMessageSession,
|
||||
proVariableSession,
|
||||
imageText2ImageSession,
|
||||
imageImage2ImageSession,
|
||||
imageMultiImageSession,
|
||||
getFavoriteImageStorageService,
|
||||
} = options
|
||||
|
||||
const toast = useToast()
|
||||
@@ -132,7 +214,102 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
/**
|
||||
* 处理使用收藏 - 智能模式切换(内部实现)
|
||||
*/
|
||||
const handleUseFavoriteImpl = async (favorite: FavoriteItem): Promise<boolean> => {
|
||||
const getTemporaryVariablesSession = (targetKey: string | null): TemporaryVariablesSessionApi | null => {
|
||||
switch (targetKey) {
|
||||
case 'pro-multi':
|
||||
return proMultiMessageSession || null
|
||||
case 'pro-variable':
|
||||
return proVariableSession || null
|
||||
case 'image-text2image':
|
||||
return imageText2ImageSession || null
|
||||
case 'image-image2image':
|
||||
return imageImage2ImageSession || null
|
||||
case 'image-multiimage':
|
||||
return imageMultiImageSession || null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const resolveExampleInputImages = async (example: FavoriteReproducibilityExample): Promise<ImagePayload[]> => {
|
||||
const storageService = getFavoriteImageStorageService?.() || null
|
||||
const sources = [...example.inputImages]
|
||||
|
||||
if (storageService) {
|
||||
for (const assetId of example.inputImageAssetIds) {
|
||||
try {
|
||||
const dataUrl = await resolveAssetIdToDataUrl(assetId, storageService)
|
||||
if (dataUrl) sources.push(dataUrl)
|
||||
} catch (error) {
|
||||
console.warn('[App] Failed to resolve favorite example input image:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images: ImagePayload[] = []
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const payload = await normalizeImageSourceToPayload(source)
|
||||
if (payload) images.push(payload)
|
||||
} catch (error) {
|
||||
console.warn('[App] Failed to load favorite example input image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
const applyFavoriteExample = async (
|
||||
favorite: FavoriteItem,
|
||||
targetKey: string | null,
|
||||
useOptions: UseFavoriteOptions,
|
||||
) => {
|
||||
if (!useOptions.applyExample) return
|
||||
|
||||
const reproducibility = parseFavoriteReproducibility(favorite)
|
||||
const example = pickFavoriteExample(reproducibility.examples, useOptions)
|
||||
if (!example) return
|
||||
|
||||
const session = getTemporaryVariablesSession(targetKey)
|
||||
if (session) {
|
||||
const variableEntries = reproducibility.variables
|
||||
.map((variable) => ({
|
||||
name: variable.name.trim(),
|
||||
value: variable.defaultValue !== undefined ? String(variable.defaultValue) : '',
|
||||
}))
|
||||
.filter((variable) => isValidVariableName(variable.name))
|
||||
|
||||
const variableNames = new Set(variableEntries.map((variable) => variable.name))
|
||||
session.clearTemporaryVariables()
|
||||
|
||||
for (const { name, value } of variableEntries) {
|
||||
session.setTemporaryVariable(name, value)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(example.parameters)) {
|
||||
const name = key.trim()
|
||||
if (!isValidVariableName(name)) continue
|
||||
if (variableNames.size > 0 && !variableNames.has(name)) continue
|
||||
session.setTemporaryVariable(name, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
if (targetKey === 'image-image2image' && imageImage2ImageSession) {
|
||||
const [firstImage] = await resolveExampleInputImages(example)
|
||||
if (firstImage) {
|
||||
imageImage2ImageSession.updateInputImage(firstImage.b64, firstImage.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (targetKey === 'image-multiimage' && imageMultiImageSession) {
|
||||
const images = await resolveExampleInputImages(example)
|
||||
if (images.length > 0) {
|
||||
imageMultiImageSession.replaceInputImages(images)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUseFavoriteImpl = async (favorite: FavoriteItem, useOptions: UseFavoriteOptions = {}): Promise<boolean> => {
|
||||
const {
|
||||
functionMode: favFunctionMode,
|
||||
optimizationMode: favOptimizationMode,
|
||||
@@ -147,7 +324,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
const targetSubMode = favImageSubMode || 'text2image'
|
||||
const targetKey = `image-${targetSubMode}`
|
||||
|
||||
await navigateToSubModeKey(targetKey)
|
||||
const didNavigate = await navigateToSubModeKey(targetKey)
|
||||
if (didNavigate === false) return false
|
||||
toast.info(t('toast.info.switchedToImageMode'))
|
||||
|
||||
await nextTick()
|
||||
@@ -165,6 +343,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
)
|
||||
}
|
||||
|
||||
await applyFavoriteExample(favorite, targetKey, useOptions)
|
||||
|
||||
toast.success(t('toast.success.imageFavoriteLoaded'))
|
||||
} else if (favFunctionMode === 'basic' || favFunctionMode === 'context' || favFunctionMode === 'pro') {
|
||||
// 基础模式或上下文模式
|
||||
@@ -186,7 +366,8 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
|
||||
// 3. 一次性导航到目标路由
|
||||
const targetKey = `${targetFunctionMode}-${targetSubMode}`
|
||||
await navigateToSubModeKey(targetKey)
|
||||
const didNavigate = await navigateToSubModeKey(targetKey)
|
||||
if (didNavigate === false) return false
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -214,9 +395,15 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
|
||||
// 5. 将收藏的提示词内容设置到输入框
|
||||
optimizerPrompt.value = favorite.content
|
||||
if (targetKey === 'pro-variable') {
|
||||
proVariableSession?.updatePrompt?.(favorite.content)
|
||||
}
|
||||
await applyFavoriteExample(favorite, targetKey, useOptions)
|
||||
} else {
|
||||
// 其他情况:直接设置内容,不切换模式
|
||||
optimizerPrompt.value = favorite.content
|
||||
// 未知模式无法可靠定位目标 session,仅保留正文应用行为。
|
||||
await applyFavoriteExample(favorite, null, useOptions)
|
||||
}
|
||||
|
||||
// 关闭收藏管理对话框
|
||||
@@ -231,12 +418,12 @@ export function useAppFavorite(options: AppFavoriteOptions): AppFavoriteReturn {
|
||||
/**
|
||||
* 收藏加载的错误处理包装器
|
||||
*/
|
||||
const handleUseFavorite = async (favorite: FavoriteItem): Promise<boolean> => {
|
||||
const handleUseFavorite = async (favorite: FavoriteItem, useOptions: UseFavoriteOptions = {}): Promise<boolean> => {
|
||||
try {
|
||||
// 🔧 设置外部数据加载标志,防止模式切换的自动 restore 覆盖外部数据
|
||||
isLoadingExternalData.value = true
|
||||
|
||||
return await handleUseFavoriteImpl(favorite)
|
||||
return await handleUseFavoriteImpl(favorite, useOptions)
|
||||
} catch (error) {
|
||||
// 捕获收藏加载过程中的所有错误
|
||||
console.error('[App] Failed to load favorite:', error)
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface AppHistoryRestoreOptions {
|
||||
/** 服务实例 */
|
||||
services: Ref<{ historyManager: IHistoryManager } | null>
|
||||
/** 🔧 Step D: 路由导航函数(替代 setFunctionMode/set*SubMode) */
|
||||
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => void | Promise<void>
|
||||
navigateToSubModeKey: (toKey: string, opts?: { replace?: boolean }) => boolean | void | Promise<boolean | void>
|
||||
/** 处理上下文模式变更 */
|
||||
handleContextModeChange: (mode: ContextMode) => Promise<void>
|
||||
/** 处理历史记录选择 */
|
||||
@@ -133,7 +133,10 @@ export function useAppHistoryRestore(options: AppHistoryRestoreOptions): AppHist
|
||||
: 'text2image' // 默认为文生图模式
|
||||
|
||||
// 🔧 Step D: 使用 navigateToSubModeKey 替代 setImageSubMode
|
||||
await navigateToSubModeKey(`image-${imageMode}`)
|
||||
const didNavigate = await navigateToSubModeKey(`image-${imageMode}`)
|
||||
if (didNavigate === false) {
|
||||
throw new Error(`Invalid image workspace target: image-${imageMode}`)
|
||||
}
|
||||
toast.info(t('toast.info.switchedToImageMode'))
|
||||
|
||||
// 🆕 图像模式专用数据回填逻辑
|
||||
@@ -189,7 +192,10 @@ export function useAppHistoryRestore(options: AppHistoryRestoreOptions): AppHist
|
||||
targetFunctionMode === 'pro'
|
||||
? `pro-${targetMode === 'system' ? 'multi' : 'variable'}`
|
||||
: `basic-${targetMode}`
|
||||
await navigateToSubModeKey(targetKey)
|
||||
const didNavigate = await navigateToSubModeKey(targetKey)
|
||||
if (didNavigate === false) {
|
||||
throw new Error(`Invalid workspace target: ${targetKey}`)
|
||||
}
|
||||
|
||||
// 等待路由切换完成
|
||||
await nextTick()
|
||||
|
||||
@@ -26,9 +26,6 @@ type ToastOptions = number | MessageOptions
|
||||
|
||||
export function useToast() {
|
||||
const getMessageApi = (): MessageApi | null => {
|
||||
if (!globalMessageApi) {
|
||||
console.warn('[useToast] NMessageProvider context not available yet.')
|
||||
}
|
||||
return globalMessageApi
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,8 @@ const messages = {
|
||||
"exampleLabel": "Example #{index}",
|
||||
"parameters": "Parameters",
|
||||
"images": "Images",
|
||||
"inputImages": "Input images"
|
||||
"inputImages": "Input images",
|
||||
"applyExample": "Use this example"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -313,10 +314,20 @@ const messages = {
|
||||
"required": "Required",
|
||||
"remove": "Remove",
|
||||
"exampleIdPlaceholder": "Example ID (optional)",
|
||||
"exampleLabel": "Example #{index}",
|
||||
"exampleTextPlaceholder": "Example name or note (optional)",
|
||||
"exampleDescriptionPlaceholder": "Example description (optional)",
|
||||
"exampleParametersPlaceholder": "Parameters, one key=value per line",
|
||||
"exampleInputImagesPlaceholder": "Input image URLs, one per line",
|
||||
"exampleParametersLabel": "Example variable values",
|
||||
"parameterNamePlaceholder": "Variable name",
|
||||
"parameterValuePlaceholder": "Variable value",
|
||||
"addParameter": "Add value",
|
||||
"exampleImages": "Example/output images",
|
||||
"exampleInputImages": "Input images",
|
||||
"exampleImagesPlaceholder": "Paste an image link",
|
||||
"exampleInputImagesPlaceholder": "Paste an image link",
|
||||
"addImageUrl": "Add link",
|
||||
"addExampleImages": "Upload example images",
|
||||
"addExampleInputImages": "Upload input images",
|
||||
"variableType": {
|
||||
"string": "Text",
|
||||
"number": "Number",
|
||||
|
||||
@@ -152,7 +152,8 @@ const messages = {
|
||||
"exampleLabel": "示例 #{index}",
|
||||
"parameters": "参数",
|
||||
"images": "图片",
|
||||
"inputImages": "输入图"
|
||||
"inputImages": "输入图",
|
||||
"applyExample": "应用此示例"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -313,10 +314,20 @@ const messages = {
|
||||
"required": "必填",
|
||||
"remove": "移除",
|
||||
"exampleIdPlaceholder": "示例 ID(可选)",
|
||||
"exampleLabel": "示例 #{index}",
|
||||
"exampleTextPlaceholder": "示例名称或说明(可选)",
|
||||
"exampleDescriptionPlaceholder": "示例描述(可选)",
|
||||
"exampleParametersPlaceholder": "参数,每行 key=value",
|
||||
"exampleInputImagesPlaceholder": "输入图片 URL,每行一个",
|
||||
"exampleParametersLabel": "示例变量取值",
|
||||
"parameterNamePlaceholder": "变量名",
|
||||
"parameterValuePlaceholder": "变量取值",
|
||||
"addParameter": "添加取值",
|
||||
"exampleImages": "示例/输出图片",
|
||||
"exampleInputImages": "输入图片",
|
||||
"exampleImagesPlaceholder": "粘贴图片链接",
|
||||
"exampleInputImagesPlaceholder": "粘贴图片链接",
|
||||
"addImageUrl": "添加链接",
|
||||
"addExampleImages": "上传示例图片",
|
||||
"addExampleInputImages": "上传输入图片",
|
||||
"variableType": {
|
||||
"string": "文本",
|
||||
"number": "数字",
|
||||
|
||||
@@ -152,7 +152,8 @@ const messages = {
|
||||
"exampleLabel": "範例 #{index}",
|
||||
"parameters": "參數",
|
||||
"images": "圖片",
|
||||
"inputImages": "輸入圖"
|
||||
"inputImages": "輸入圖",
|
||||
"applyExample": "套用此範例"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -313,10 +314,20 @@ const messages = {
|
||||
"required": "必填",
|
||||
"remove": "移除",
|
||||
"exampleIdPlaceholder": "範例 ID(可選)",
|
||||
"exampleLabel": "範例 #{index}",
|
||||
"exampleTextPlaceholder": "範例名稱或說明(可選)",
|
||||
"exampleDescriptionPlaceholder": "範例描述(可選)",
|
||||
"exampleParametersPlaceholder": "參數,每行 key=value",
|
||||
"exampleInputImagesPlaceholder": "輸入圖片 URL,每行一個",
|
||||
"exampleParametersLabel": "範例變數取值",
|
||||
"parameterNamePlaceholder": "變數名",
|
||||
"parameterValuePlaceholder": "變數取值",
|
||||
"addParameter": "新增取值",
|
||||
"exampleImages": "範例/輸出圖片",
|
||||
"exampleInputImages": "輸入圖片",
|
||||
"exampleImagesPlaceholder": "貼上圖片連結",
|
||||
"exampleInputImagesPlaceholder": "貼上圖片連結",
|
||||
"addImageUrl": "新增連結",
|
||||
"addExampleImages": "上傳範例圖片",
|
||||
"addExampleInputImages": "上傳輸入圖片",
|
||||
"variableType": {
|
||||
"string": "文字",
|
||||
"number": "數字",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineComponent, h, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGlobalSettings, type GlobalSettingsApi } from '../stores/settings/useGlobalSettings'
|
||||
import { DEFAULT_WORKSPACE_PATH } from './workspaceRoutes'
|
||||
|
||||
export const getInitialRouteFromGlobalSettings = (globalSettings: GlobalSettingsApi) => {
|
||||
const { functionMode, basicSubMode, proSubMode, imageSubMode } = globalSettings.state
|
||||
@@ -13,7 +14,7 @@ export const getInitialRouteFromGlobalSettings = (globalSettings: GlobalSettings
|
||||
case 'image':
|
||||
return `/image/${imageSubMode}`
|
||||
default:
|
||||
return '/basic/system'
|
||||
return DEFAULT_WORKSPACE_PATH
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,23 +26,23 @@ export const parseSubModeKey = (path: string): SubModeKey | null => {
|
||||
export const beforeRouteSwitch: NavigationGuard = (to) => {
|
||||
// ✅ 兼容旧 pro 路由(/pro/system|/pro/user -> /pro/multi|/pro/variable)
|
||||
if (to.path === '/pro/system') {
|
||||
return '/pro/multi'
|
||||
return { path: '/pro/multi', query: to.query, hash: to.hash }
|
||||
}
|
||||
if (to.path === '/pro/user') {
|
||||
return '/pro/variable'
|
||||
return { path: '/pro/variable', query: to.query, hash: to.hash }
|
||||
}
|
||||
|
||||
const subModeKey = parseSubModeKey(to.path)
|
||||
|
||||
if (subModeKey === null && to.path !== '/') {
|
||||
const match = to.path.match(/^\/(basic|pro|image)/)
|
||||
const match = to.path.match(/^\/(basic|pro|image)(\/|$)/)
|
||||
if (match) {
|
||||
const mode = match[1] as WorkspaceMode
|
||||
|
||||
const defaultSubMode = getDefaultSubModeForWorkspaceMode(mode)
|
||||
|
||||
console.warn(`[Router] Invalid subMode: ${to.path}. Redirecting to /${mode}/${defaultSubMode}`)
|
||||
return `/${mode}/${defaultSubMode}`
|
||||
return { path: `/${mode}/${defaultSubMode}`, query: to.query, hash: to.hash }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
packages/ui/src/utils/external-data-loading.ts
Normal file
22
packages/ui/src/utils/external-data-loading.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const createExternalDataLoadingGate = () => {
|
||||
const depth = ref(0)
|
||||
|
||||
const isLoading = computed<boolean>({
|
||||
get: () => depth.value > 0,
|
||||
set: (value) => {
|
||||
if (value) {
|
||||
depth.value += 1
|
||||
return
|
||||
}
|
||||
|
||||
depth.value = Math.max(0, depth.value - 1)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
depth,
|
||||
}
|
||||
}
|
||||
@@ -46,19 +46,8 @@ const collectFavoriteAssetIds = (
|
||||
const gardenSnapshot = isRecord(favorite.metadata.gardenSnapshot)
|
||||
? favorite.metadata.gardenSnapshot
|
||||
: null
|
||||
if (!gardenSnapshot || !isRecord(gardenSnapshot.assets)) {
|
||||
return assetIds
|
||||
}
|
||||
|
||||
const cover = isRecord(gardenSnapshot.assets.cover) ? gardenSnapshot.assets.cover : null
|
||||
if (cover) {
|
||||
const coverAssetId = typeof cover.assetId === 'string' ? cover.assetId.trim() : ''
|
||||
if (coverAssetId) {
|
||||
assetIds.add(coverAssetId)
|
||||
}
|
||||
}
|
||||
|
||||
const collectFromSnapshotItems = (items: unknown) => {
|
||||
const collectFromExampleItems = (items: unknown) => {
|
||||
if (!Array.isArray(items)) return
|
||||
|
||||
items.forEach((item) => {
|
||||
@@ -69,8 +58,27 @@ const collectFavoriteAssetIds = (
|
||||
})
|
||||
}
|
||||
|
||||
collectFromSnapshotItems(gardenSnapshot.assets.showcases)
|
||||
collectFromSnapshotItems(gardenSnapshot.assets.examples)
|
||||
if (gardenSnapshot && isRecord(gardenSnapshot.assets)) {
|
||||
const cover = isRecord(gardenSnapshot.assets.cover) ? gardenSnapshot.assets.cover : null
|
||||
if (cover) {
|
||||
const coverAssetId = typeof cover.assetId === 'string' ? cover.assetId.trim() : ''
|
||||
if (coverAssetId) {
|
||||
assetIds.add(coverAssetId)
|
||||
}
|
||||
}
|
||||
|
||||
collectFromExampleItems(gardenSnapshot.assets.showcases)
|
||||
collectFromExampleItems(gardenSnapshot.assets.examples)
|
||||
}
|
||||
|
||||
const reproducibility = isRecord(favorite.metadata.reproducibility)
|
||||
? favorite.metadata.reproducibility
|
||||
: null
|
||||
if (reproducibility) {
|
||||
collectFromExampleItems(reproducibility.examples)
|
||||
}
|
||||
|
||||
collectFromExampleItems(favorite.metadata.examples)
|
||||
|
||||
return assetIds
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { FavoriteCategory, FavoritePrompt } from '@prompt-optimizer/core'
|
||||
|
||||
import FavoriteCard from '../../../src/components/FavoriteCard.vue'
|
||||
|
||||
const parseFavoriteMediaMetadataMock = vi.fn()
|
||||
const resolveAssetIdToDataUrlMock = vi.fn()
|
||||
|
||||
vi.mock('../../../src/utils/favorite-media', () => ({
|
||||
parseFavoriteMediaMetadata: (...args: unknown[]) => parseFavoriteMediaMetadataMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/utils/image-asset-storage', () => ({
|
||||
resolveAssetIdToDataUrl: (...args: unknown[]) => resolveAssetIdToDataUrlMock(...args),
|
||||
}))
|
||||
|
||||
const naiveStubs = {
|
||||
NCard: {
|
||||
name: 'NCard',
|
||||
template: `
|
||||
<article class="n-card" @click="$emit('click')">
|
||||
<header class="n-card-header"><slot name="header" /></header>
|
||||
<div class="n-card-cover"><slot name="cover" /></div>
|
||||
<section class="n-card-content"><slot /></section>
|
||||
<footer class="n-card-footer"><slot name="footer" /></footer>
|
||||
</article>
|
||||
`,
|
||||
emits: ['click'],
|
||||
},
|
||||
NSpace: {
|
||||
name: 'NSpace',
|
||||
template: '<div class="n-space"><slot /></div>',
|
||||
props: ['vertical', 'size', 'align', 'justify', 'wrap', 'class'],
|
||||
},
|
||||
NTag: {
|
||||
name: 'NTag',
|
||||
template: '<span class="n-tag"><slot /></span>',
|
||||
props: ['type', 'size', 'bordered', 'color'],
|
||||
},
|
||||
NText: {
|
||||
name: 'NText',
|
||||
template: '<span class="n-text"><slot /></span>',
|
||||
props: ['depth'],
|
||||
},
|
||||
NIcon: {
|
||||
name: 'NIcon',
|
||||
template: '<i class="n-icon"><slot /></i>',
|
||||
},
|
||||
NButton: {
|
||||
name: 'NButton',
|
||||
template: '<button class="n-button" :data-testid="$attrs[\'data-testid\']" @click="$emit(\'click\', $event)"><slot name="icon" /><slot /></button>',
|
||||
emits: ['click'],
|
||||
},
|
||||
NEllipsis: {
|
||||
name: 'NEllipsis',
|
||||
template: '<span class="n-ellipsis"><slot /></span>',
|
||||
props: ['lineClamp', 'tooltip', 'class'],
|
||||
},
|
||||
NThing: {
|
||||
name: 'NThing',
|
||||
template: '<div class="n-thing"><div><slot name="header" /></div><div><slot /></div><div><slot name="footer" /></div></div>',
|
||||
},
|
||||
NDropdown: {
|
||||
name: 'NDropdown',
|
||||
template: `
|
||||
<div class="n-dropdown">
|
||||
<slot />
|
||||
<button
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.key"
|
||||
class="n-dropdown-option"
|
||||
:data-key="option.key"
|
||||
@click="$emit('select', option.key)"
|
||||
>
|
||||
{{ option.key }}
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
props: ['options'],
|
||||
emits: ['select'],
|
||||
computed: {
|
||||
normalizedOptions() {
|
||||
return (this.options || []).filter((option: any) => !option.type)
|
||||
},
|
||||
},
|
||||
},
|
||||
AppPreviewImage: {
|
||||
name: 'AppPreviewImage',
|
||||
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
|
||||
props: ['src', 'alt', 'objectFit', 'previewDisabled', 'class'],
|
||||
},
|
||||
}
|
||||
|
||||
const createFavorite = (overrides: Partial<FavoritePrompt> = {}): FavoritePrompt => ({
|
||||
id: 'favorite-1',
|
||||
title: 'Prompt title',
|
||||
content: 'A concise favorite content block for testing.',
|
||||
description: 'A reusable favorite description.',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
tags: ['tag-a', 'tag-b', 'tag-c'],
|
||||
category: 'category-1',
|
||||
useCount: 7,
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const category: FavoriteCategory = {
|
||||
id: 'category-1',
|
||||
name: 'Visual',
|
||||
color: '#3366ff',
|
||||
createdAt: Date.now(),
|
||||
sortOrder: 1,
|
||||
}
|
||||
|
||||
const mountComponent = (favorite: FavoritePrompt) =>
|
||||
mount(FavoriteCard, {
|
||||
props: {
|
||||
favorite,
|
||||
category,
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
describe('FavoriteCard', () => {
|
||||
beforeEach(() => {
|
||||
parseFavoriteMediaMetadataMock.mockReset()
|
||||
resolveAssetIdToDataUrlMock.mockReset()
|
||||
parseFavoriteMediaMetadataMock.mockReturnValue(null)
|
||||
resolveAssetIdToDataUrlMock.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('renders a unified placeholder cover for text-only favorites', async () => {
|
||||
const wrapper = mountComponent(createFavorite({ functionMode: 'basic', optimizationMode: 'system' }))
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="favorite-card-cover"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="favorite-card-cover-placeholder"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.app-preview-image').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Use now')
|
||||
expect(wrapper.text()).toContain('Copy content')
|
||||
expect(wrapper.text()).toContain('System')
|
||||
})
|
||||
|
||||
it('renders cover media and emits select plus action events', async () => {
|
||||
parseFavoriteMediaMetadataMock.mockReturnValue({
|
||||
coverAssetId: 'asset-cover-1',
|
||||
coverUrl: 'https://example.com/fallback.png',
|
||||
})
|
||||
resolveAssetIdToDataUrlMock.mockResolvedValue('data:image/png;base64,cover')
|
||||
|
||||
const wrapper = mountComponent(createFavorite())
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.app-preview-image').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-testid="favorite-card-cover-placeholder"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.find('.n-card').trigger('click')
|
||||
await wrapper.find('[data-testid="favorite-card-use-button"]').trigger('click')
|
||||
await wrapper.find('[data-testid="favorite-card-copy-button"]').trigger('click')
|
||||
const vm = wrapper.vm as unknown as { handleMenuSelect: (key: string) => void }
|
||||
vm.handleMenuSelect('edit')
|
||||
vm.handleMenuSelect('delete')
|
||||
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
expect(wrapper.emitted('use')).toHaveLength(1)
|
||||
expect(wrapper.emitted('copy')).toHaveLength(1)
|
||||
expect(wrapper.emitted('edit')).toHaveLength(1)
|
||||
expect(wrapper.emitted('delete')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -349,4 +349,39 @@ describe('FavoriteDetailPanel', () => {
|
||||
expect(wrapper.text()).toContain('example-1')
|
||||
expect(wrapper.text()).toContain('style=ink')
|
||||
})
|
||||
|
||||
it('resolves and displays example asset images in the detail panel', async () => {
|
||||
resolveAssetIdToDataUrlMock.mockImplementation(async (assetId: string) => {
|
||||
if (assetId === 'asset-output') return 'data:image/png;base64,output-preview'
|
||||
if (assetId === 'asset-input') return 'data:image/png;base64,input-preview'
|
||||
return null
|
||||
})
|
||||
|
||||
const wrapper = mountComponent({
|
||||
...favorite,
|
||||
functionMode: 'basic',
|
||||
optimizationMode: 'system',
|
||||
imageSubMode: undefined,
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
id: 'example-assets',
|
||||
parameters: {},
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.app-preview-image').map((image) => image.attributes('src'))).toEqual([
|
||||
'data:image/png;base64,output-preview',
|
||||
'data:image/png;base64,input-preview',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
473
packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts
Normal file
473
packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import type { FavoritePrompt } from '@prompt-optimizer/core'
|
||||
|
||||
const resolveAssetIdToDataUrlMock = vi.fn()
|
||||
const persistImageSourceAsAssetIdMock = vi.fn()
|
||||
|
||||
vi.mock('../../../src/utils/image-asset-storage', () => ({
|
||||
resolveAssetIdToDataUrl: (...args: unknown[]) => resolveAssetIdToDataUrlMock(...args),
|
||||
persistImageSourceAsAssetId: (...args: unknown[]) => persistImageSourceAsAssetIdMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/ui/useTagSuggestions', () => ({
|
||||
useTagSuggestions: () => ({
|
||||
filterTags: () => [],
|
||||
loadTags: vi.fn(async () => {}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/composables/ui/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
import FavoriteEditorForm from '../../../src/components/FavoriteEditorForm.vue'
|
||||
|
||||
const naiveStubs = {
|
||||
NAutoComplete: {
|
||||
name: 'NAutoComplete',
|
||||
template: '<input class="n-auto-complete" :value="value" />',
|
||||
props: ['value', 'options', 'placeholder', 'getShow', 'clearable'],
|
||||
},
|
||||
NButton: {
|
||||
name: 'NButton',
|
||||
template: '<button class="n-button" :disabled="disabled" @click="$emit(\'click\', $event)"><slot /></button>',
|
||||
props: ['disabled', 'loading', 'type', 'secondary', 'size', 'quaternary'],
|
||||
emits: ['click'],
|
||||
},
|
||||
NCard: {
|
||||
name: 'NCard',
|
||||
template: '<section class="n-card"><header>{{ title }}</header><slot /><footer><slot name="footer" /></footer></section>',
|
||||
props: ['title', 'size', 'segmented', 'class'],
|
||||
},
|
||||
NForm: {
|
||||
name: 'NForm',
|
||||
template: '<form class="n-form"><slot /></form>',
|
||||
props: ['labelPlacement'],
|
||||
},
|
||||
NFormItem: {
|
||||
name: 'NFormItem',
|
||||
template: '<label class="n-form-item">{{ label }}<slot /></label>',
|
||||
props: ['label', 'required'],
|
||||
},
|
||||
NGrid: {
|
||||
name: 'NGrid',
|
||||
template: '<div class="n-grid"><slot /></div>',
|
||||
props: ['cols', 'xGap'],
|
||||
},
|
||||
NGridItem: {
|
||||
name: 'NGridItem',
|
||||
template: '<div class="n-grid-item"><slot /></div>',
|
||||
},
|
||||
NInput: {
|
||||
name: 'NInput',
|
||||
template: '<textarea v-if="type === \'textarea\'" class="n-input" :value="value" /><input v-else class="n-input" :value="value" />',
|
||||
props: ['value', 'type', 'placeholder', 'autosize', 'maxlength', 'showCount'],
|
||||
},
|
||||
NScrollbar: {
|
||||
name: 'NScrollbar',
|
||||
template: '<div class="n-scrollbar"><slot /></div>',
|
||||
props: ['class'],
|
||||
},
|
||||
NSelect: {
|
||||
name: 'NSelect',
|
||||
template: '<select class="n-select" :value="value"></select>',
|
||||
props: ['value', 'options', 'placeholder', 'disabled'],
|
||||
},
|
||||
NSpace: {
|
||||
name: 'NSpace',
|
||||
template: '<div class="n-space"><slot /></div>',
|
||||
props: ['vertical', 'size', 'justify', 'align', 'wrap', 'class'],
|
||||
},
|
||||
NTag: {
|
||||
name: 'NTag',
|
||||
template: '<span class="n-tag"><slot /></span>',
|
||||
props: ['type', 'closable', 'size', 'bordered'],
|
||||
},
|
||||
NText: {
|
||||
name: 'NText',
|
||||
template: '<span class="n-text"><slot /></span>',
|
||||
props: ['depth'],
|
||||
},
|
||||
NUpload: {
|
||||
name: 'NUpload',
|
||||
template: '<div class="n-upload" @click="onBeforeUpload && onBeforeUpload({ file: { file: testBlob } })"><slot /></div>',
|
||||
props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
|
||||
emits: ['before-upload'],
|
||||
data: () => ({
|
||||
testBlob: new Blob(['upload'], { type: 'image/png' }),
|
||||
}),
|
||||
},
|
||||
Upload: {
|
||||
name: 'Upload',
|
||||
template: '<div class="n-upload" @click="onBeforeUpload && onBeforeUpload({ file: { file: testBlob } })"><slot /></div>',
|
||||
props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'disabled', 'onBeforeUpload'],
|
||||
emits: ['before-upload'],
|
||||
data: () => ({
|
||||
testBlob: new Blob(['upload'], { type: 'image/png' }),
|
||||
}),
|
||||
},
|
||||
CategoryTreeSelect: {
|
||||
name: 'CategoryTreeSelect',
|
||||
template: '<div class="category-tree-select"></div>',
|
||||
props: ['modelValue', 'placeholder', 'showAllOption'],
|
||||
},
|
||||
FavoriteReproducibilityEditor: {
|
||||
name: 'FavoriteReproducibilityEditor',
|
||||
template: '<div class="favorite-reproducibility-editor"></div>',
|
||||
props: ['variables', 'examples', 'examplePreviews'],
|
||||
},
|
||||
AppPreviewImage: {
|
||||
name: 'AppPreviewImage',
|
||||
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
|
||||
props: ['src', 'alt', 'objectFit', 'class'],
|
||||
},
|
||||
AppPreviewImageGroup: {
|
||||
name: 'AppPreviewImageGroup',
|
||||
template: '<div class="app-preview-image-group"><slot /></div>',
|
||||
},
|
||||
}
|
||||
|
||||
const createFavorite = (id: string, coverAssetId: string): FavoritePrompt => ({
|
||||
id,
|
||||
title: `Favorite ${id}`,
|
||||
content: `Content ${id}`,
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
tags: [],
|
||||
category: '',
|
||||
useCount: 0,
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
metadata: {
|
||||
media: {
|
||||
coverAssetId,
|
||||
assetIds: [],
|
||||
urls: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const createFavoriteWithoutMedia = (id: string): FavoritePrompt => ({
|
||||
id,
|
||||
title: `Favorite ${id}`,
|
||||
content: `Content ${id}`,
|
||||
description: '',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
tags: [],
|
||||
category: '',
|
||||
useCount: 0,
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('FavoriteEditorForm', () => {
|
||||
beforeEach(() => {
|
||||
resolveAssetIdToDataUrlMock.mockReset()
|
||||
persistImageSourceAsAssetIdMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('ignores stale media hydration after switching edited favorites', async () => {
|
||||
const firstCover = createDeferred<string | null>()
|
||||
const secondCover = createDeferred<string | null>()
|
||||
const firstFavorite = createFavorite('first', 'asset-first')
|
||||
const secondFavorite = createFavorite('second', 'asset-second')
|
||||
const updateFavorite = vi.fn(async () => {})
|
||||
|
||||
resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
|
||||
if (assetId === 'asset-first') return firstCover.promise
|
||||
if (assetId === 'asset-second') return secondCover.promise
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) =>
|
||||
source.includes('second') ? 'persisted-second' : 'persisted-first',
|
||||
)
|
||||
|
||||
const wrapper = mount(FavoriteEditorForm, {
|
||||
props: {
|
||||
mode: 'edit',
|
||||
favorite: firstFavorite,
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
favoriteManager: {
|
||||
getAllTags: vi.fn(async () => []),
|
||||
addTag: vi.fn(async () => {}),
|
||||
updateFavorite,
|
||||
},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ favorite: secondFavorite })
|
||||
await flushPromises()
|
||||
|
||||
secondCover.resolve('data:image/png;base64,second')
|
||||
await flushPromises()
|
||||
firstCover.resolve('data:image/png;base64,first')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').at(-1)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(updateFavorite).toHaveBeenCalledWith('second', expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
media: expect.objectContaining({
|
||||
coverAssetId: 'persisted-second',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect(updateFavorite).not.toHaveBeenCalledWith('second', expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
media: expect.objectContaining({
|
||||
coverAssetId: 'persisted-first',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('ignores stale image uploads after switching edited favorites', async () => {
|
||||
const readers: Array<{
|
||||
result: string
|
||||
onload: null | (() => void)
|
||||
onerror: null | (() => void)
|
||||
readAsDataURL: ReturnType<typeof vi.fn>
|
||||
}> = []
|
||||
class TestFileReader {
|
||||
result = ''
|
||||
onload: null | (() => void) = null
|
||||
onerror: null | (() => void) = null
|
||||
readAsDataURL = vi.fn(() => {
|
||||
readers.push(this)
|
||||
})
|
||||
}
|
||||
vi.stubGlobal('FileReader', TestFileReader)
|
||||
|
||||
const secondCover = createDeferred<string | null>()
|
||||
const firstFavorite = createFavoriteWithoutMedia('first')
|
||||
const secondFavorite = createFavorite('second', 'asset-second')
|
||||
const updateFavorite = vi.fn(async () => {})
|
||||
|
||||
resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
|
||||
if (assetId === 'asset-second') return secondCover.promise
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) =>
|
||||
source.includes('stale-upload') ? 'persisted-stale-upload' : 'persisted-second',
|
||||
)
|
||||
|
||||
const wrapper = mount(FavoriteEditorForm, {
|
||||
props: {
|
||||
mode: 'edit',
|
||||
favorite: firstFavorite,
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
favoriteManager: {
|
||||
getAllTags: vi.fn(async () => []),
|
||||
addTag: vi.fn(async () => {}),
|
||||
updateFavorite,
|
||||
},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.n-upload').trigger('click')
|
||||
await flushPromises()
|
||||
expect(readers).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ favorite: secondFavorite })
|
||||
await flushPromises()
|
||||
|
||||
secondCover.resolve('data:image/png;base64,second')
|
||||
await flushPromises()
|
||||
|
||||
readers[0].result = 'data:image/png;base64,stale-upload'
|
||||
readers[0].onload?.()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('button').at(-1)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(persistImageSourceAsAssetIdMock).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
source: 'data:image/png;base64,stale-upload',
|
||||
}))
|
||||
expect(updateFavorite).toHaveBeenCalledWith('second', expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
media: expect.objectContaining({
|
||||
coverAssetId: 'persisted-second',
|
||||
assetIds: ['persisted-second'],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('persists reproducibility example images as favorite asset ids on save', async () => {
|
||||
const favorite: FavoritePrompt = {
|
||||
...createFavoriteWithoutMedia('manual-examples'),
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
id: 'case-1',
|
||||
parameters: { style: 'ink' },
|
||||
images: ['data:image/png;base64,output-image'],
|
||||
imageAssetIds: ['existing-output-asset'],
|
||||
inputImages: ['data:image/png;base64,input-image'],
|
||||
inputImageAssetIds: ['existing-input-asset'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
const updateFavorite = vi.fn(async () => {})
|
||||
|
||||
persistImageSourceAsAssetIdMock.mockImplementation(({ source }: { source: string }) => {
|
||||
if (source.includes('output-image')) return 'persisted-output-asset'
|
||||
if (source.includes('input-image')) return 'persisted-input-asset'
|
||||
return null
|
||||
})
|
||||
|
||||
const wrapper = mount(FavoriteEditorForm, {
|
||||
props: {
|
||||
mode: 'edit',
|
||||
favorite,
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
favoriteManager: {
|
||||
getAllTags: vi.fn(async () => []),
|
||||
addTag: vi.fn(async () => {}),
|
||||
updateFavorite,
|
||||
},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').at(-1)?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const savedMetadata = updateFavorite.mock.calls[0]?.[1]?.metadata as Record<string, any>
|
||||
const savedExample = savedMetadata.reproducibility.examples[0]
|
||||
|
||||
expect(savedExample.imageAssetIds).toEqual(['existing-output-asset', 'persisted-output-asset'])
|
||||
expect(savedExample.inputImageAssetIds).toEqual(['existing-input-asset', 'persisted-input-asset'])
|
||||
expect(JSON.stringify(savedExample)).not.toContain('data:image/png;base64')
|
||||
})
|
||||
|
||||
it('hydrates reproducibility example asset previews without writing them into editable url fields', async () => {
|
||||
const favorite: FavoritePrompt = {
|
||||
...createFavoriteWithoutMedia('preview-examples'),
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
id: 'case-preview',
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resolveAssetIdToDataUrlMock.mockImplementation((assetId: string) => {
|
||||
if (assetId === 'asset-output') return Promise.resolve('data:image/png;base64,output-preview')
|
||||
if (assetId === 'asset-input') return Promise.resolve('data:image/png;base64,input-preview')
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const wrapper = mount(FavoriteEditorForm, {
|
||||
props: {
|
||||
mode: 'edit',
|
||||
favorite,
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
provide: {
|
||||
services: ref({
|
||||
favoriteImageStorageService: {},
|
||||
imageStorageService: {},
|
||||
favoriteManager: {
|
||||
getAllTags: vi.fn(async () => []),
|
||||
addTag: vi.fn(async () => {}),
|
||||
updateFavorite: vi.fn(async () => {}),
|
||||
},
|
||||
} as any),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'FavoriteReproducibilityEditor' })
|
||||
expect(editor.props('examples')).toEqual([
|
||||
expect.objectContaining({
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
}),
|
||||
])
|
||||
expect(editor.props('examplePreviews')).toEqual([
|
||||
{
|
||||
images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
|
||||
inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import FavoriteReproducibilityDisplay from '../../../src/components/FavoriteReproducibilityDisplay.vue'
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) =>
|
||||
values?.index ? `${key} ${values.index}` : key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const naiveStubs = {
|
||||
NCard: {
|
||||
name: 'NCard',
|
||||
template: '<section class="n-card"><slot /></section>',
|
||||
props: ['size', 'embedded'],
|
||||
},
|
||||
NDescriptions: {
|
||||
name: 'NDescriptions',
|
||||
template: '<dl class="n-descriptions"><slot /></dl>',
|
||||
props: ['column', 'size', 'bordered', 'labelPlacement'],
|
||||
},
|
||||
NDescriptionsItem: {
|
||||
name: 'NDescriptionsItem',
|
||||
template: '<div class="n-descriptions-item"><dt>{{ label }}</dt><dd><slot /></dd></div>',
|
||||
props: ['label'],
|
||||
},
|
||||
NEmpty: {
|
||||
name: 'NEmpty',
|
||||
template: '<div class="n-empty">{{ description }}</div>',
|
||||
props: ['description', 'size'],
|
||||
},
|
||||
NSpace: {
|
||||
name: 'NSpace',
|
||||
template: '<div class="n-space"><slot /></div>',
|
||||
props: ['vertical', 'size', 'align', 'wrap'],
|
||||
},
|
||||
NTable: {
|
||||
name: 'NTable',
|
||||
template: '<table class="n-table"><slot /></table>',
|
||||
props: ['size', 'striped', 'singleLine'],
|
||||
},
|
||||
NTag: {
|
||||
name: 'NTag',
|
||||
template: '<span class="n-tag"><slot /></span>',
|
||||
props: ['size', 'type', 'bordered'],
|
||||
},
|
||||
NText: {
|
||||
name: 'NText',
|
||||
template: '<span class="n-text"><slot /></span>',
|
||||
props: ['depth', 'strong'],
|
||||
},
|
||||
AppPreviewImage: {
|
||||
name: 'AppPreviewImage',
|
||||
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
|
||||
props: ['src', 'alt', 'objectFit', 'class'],
|
||||
},
|
||||
AppPreviewImageGroup: {
|
||||
name: 'AppPreviewImageGroup',
|
||||
template: '<div class="app-preview-image-group"><slot /></div>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('FavoriteReproducibilityDisplay', () => {
|
||||
it('renders example images and input images from resolved asset previews', () => {
|
||||
const wrapper = mount(FavoriteReproducibilityDisplay, {
|
||||
props: {
|
||||
reproducibility: {
|
||||
source: 'reproducibility',
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
id: 'case-1',
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
},
|
||||
],
|
||||
variableCount: 0,
|
||||
exampleCount: 1,
|
||||
hasInputImages: true,
|
||||
hasData: true,
|
||||
},
|
||||
examplePreviews: [
|
||||
{
|
||||
images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
|
||||
inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.app-preview-image').map((image) => image.attributes('src'))).toEqual([
|
||||
'data:image/png;base64,output-preview',
|
||||
'data:image/png;base64,input-preview',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
import FavoriteReproducibilityEditor from '../../../src/components/FavoriteReproducibilityEditor.vue'
|
||||
@@ -70,8 +70,43 @@ const naiveStubs = {
|
||||
template: '<span class="n-text"><slot /></span>',
|
||||
props: ['depth', 'strong'],
|
||||
},
|
||||
NUpload: {
|
||||
name: 'NUpload',
|
||||
template: '<div class="n-upload" @click="handleClick"><slot /></div>',
|
||||
props: ['accept', 'multiple', 'defaultUpload', 'showFileList', 'onBeforeUpload'],
|
||||
emits: ['before-upload'],
|
||||
data: () => ({
|
||||
testBlob: new Blob(['upload'], { type: 'image/png' }),
|
||||
}),
|
||||
methods: {
|
||||
handleClick() {
|
||||
const payload = { file: { file: this.testBlob } }
|
||||
if (typeof this.onBeforeUpload === 'function') {
|
||||
this.onBeforeUpload(payload)
|
||||
}
|
||||
this.$emit('before-upload', payload)
|
||||
},
|
||||
},
|
||||
},
|
||||
AppPreviewImage: {
|
||||
name: 'AppPreviewImage',
|
||||
template: '<img class="app-preview-image" :src="src" :alt="alt" />',
|
||||
props: ['src', 'alt', 'objectFit', 'class'],
|
||||
},
|
||||
AppPreviewImageGroup: {
|
||||
name: 'AppPreviewImageGroup',
|
||||
template: '<div class="app-preview-image-group"><slot /></div>',
|
||||
},
|
||||
}
|
||||
|
||||
const findField = (wrapper: any, testId: string) =>
|
||||
wrapper.find(
|
||||
`[data-testid="${testId}"] textarea, ` +
|
||||
`[data-testid="${testId}"] input, ` +
|
||||
`textarea[data-testid="${testId}"], ` +
|
||||
`input[data-testid="${testId}"]`,
|
||||
)
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(FavoriteReproducibilityEditor, {
|
||||
props: {
|
||||
@@ -84,6 +119,10 @@ const mountComponent = () =>
|
||||
})
|
||||
|
||||
describe('FavoriteReproducibilityEditor', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('lets users add and edit variable configuration', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
@@ -127,9 +166,15 @@ describe('FavoriteReproducibilityEditor', () => {
|
||||
])
|
||||
|
||||
await wrapper.setProps({ examples: nextExamples })
|
||||
await wrapper
|
||||
.find('[data-testid="favorite-repro-example-parameters"] textarea, [data-testid="favorite-repro-example-parameters"] input')
|
||||
.setValue('style=ink\nsize=large')
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('style')
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('ink')
|
||||
await wrapper.find('[data-testid="favorite-repro-example-add-parameter"]').trigger('click')
|
||||
|
||||
const examplesWithStyle = wrapper.emitted('update:examples')?.at(-1)?.[0]
|
||||
await wrapper.setProps({ examples: examplesWithStyle })
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('size')
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('large')
|
||||
await wrapper.find('[data-testid="favorite-repro-example-add-parameter"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
|
||||
{
|
||||
@@ -144,4 +189,143 @@ describe('FavoriteReproducibilityEditor', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('lets users edit example output and input image urls', async () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
await wrapper.findAll('.n-button')[1].trigger('click')
|
||||
const nextExamples = wrapper.emitted('update:examples')?.[0]?.[0]
|
||||
await wrapper.setProps({ examples: nextExamples })
|
||||
|
||||
await findField(wrapper, 'favorite-repro-example-images').setValue('https://example.com/output.png')
|
||||
await wrapper.find('[data-testid="favorite-repro-example-add-image-url"]').trigger('click')
|
||||
const examplesWithOutputImages = wrapper.emitted('update:examples')?.at(-1)?.[0]
|
||||
await wrapper.setProps({ examples: examplesWithOutputImages })
|
||||
|
||||
await findField(wrapper, 'favorite-repro-example-input-images').setValue('https://example.com/input.png')
|
||||
await wrapper.find('[data-testid="favorite-repro-example-add-input-image-url"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
|
||||
{
|
||||
parameters: {},
|
||||
images: ['https://example.com/output.png'],
|
||||
imageAssetIds: [],
|
||||
inputImages: ['https://example.com/input.png'],
|
||||
inputImageAssetIds: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not carry unsaved example drafts onto the next example after removal', async () => {
|
||||
const wrapper = mount(FavoriteReproducibilityEditor, {
|
||||
props: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
id: 'ex-1',
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: [],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: [],
|
||||
},
|
||||
{
|
||||
id: 'ex-2',
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: [],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
},
|
||||
})
|
||||
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-key').setValue('stale')
|
||||
await findField(wrapper, 'favorite-repro-example-parameter-new-value').setValue('draft')
|
||||
await findField(wrapper, 'favorite-repro-example-images').setValue('https://example.com/stale.png')
|
||||
await wrapper.findAll('[data-testid="favorite-repro-remove-example"]')[0].trigger('click')
|
||||
|
||||
const remainingExamples = wrapper.emitted('update:examples')?.at(-1)?.[0]
|
||||
await wrapper.setProps({ examples: remainingExamples })
|
||||
|
||||
expect((findField(wrapper, 'favorite-repro-example-parameter-key').element as HTMLInputElement).value).toBe('')
|
||||
expect((findField(wrapper, 'favorite-repro-example-parameter-new-value').element as HTMLInputElement).value).toBe('')
|
||||
expect((findField(wrapper, 'favorite-repro-example-images').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('shows persisted example asset previews separately from editable urls', () => {
|
||||
const wrapper = mount(FavoriteReproducibilityEditor, {
|
||||
props: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
},
|
||||
],
|
||||
examplePreviews: [
|
||||
{
|
||||
images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
|
||||
inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
},
|
||||
})
|
||||
|
||||
const previewImages = wrapper.findAll('.app-preview-image')
|
||||
expect(previewImages.map((image) => image.attributes('src'))).toEqual([
|
||||
'data:image/png;base64,output-preview',
|
||||
'data:image/png;base64,input-preview',
|
||||
])
|
||||
expect((findField(wrapper, 'favorite-repro-example-images').element as HTMLInputElement).value).toBe('')
|
||||
expect((findField(wrapper, 'favorite-repro-example-input-images').element as HTMLInputElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('removes persisted example asset previews from the matching asset field', async () => {
|
||||
const wrapper = mount(FavoriteReproducibilityEditor, {
|
||||
props: {
|
||||
variables: [],
|
||||
examples: [
|
||||
{
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: ['asset-input'],
|
||||
},
|
||||
],
|
||||
examplePreviews: [
|
||||
{
|
||||
images: [{ assetId: 'asset-output', source: 'data:image/png;base64,output-preview' }],
|
||||
inputImages: [{ assetId: 'asset-input', source: 'data:image/png;base64,input-preview' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
global: {
|
||||
stubs: naiveStubs,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('[data-testid="favorite-repro-example-remove-input-image"]').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:examples')?.at(-1)?.[0]).toEqual([
|
||||
{
|
||||
parameters: {},
|
||||
images: [],
|
||||
imageAssetIds: ['asset-output'],
|
||||
inputImages: [],
|
||||
inputImageAssetIds: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,6 +112,39 @@ describe('GardenSnapshotPreview', () => {
|
||||
expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('can hide sections already promoted by the favorite detail panel', () => {
|
||||
const wrapper = mount(GardenSnapshotPreview, {
|
||||
props: {
|
||||
snapshot: createSnapshot(),
|
||||
hiddenSections: ['metaInfo', 'cover', 'showcases', 'examples', 'variables'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('IMP-100')
|
||||
expect(wrapper.find('[data-testid="favorite-garden-meta"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-cover"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-showcases"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-examples"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders source-only mode without the nested snapshot header or collapse sections', () => {
|
||||
const wrapper = mount(GardenSnapshotPreview, {
|
||||
props: {
|
||||
snapshot: createSnapshot(),
|
||||
sourceOnly: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.find('[data-testid="favorite-garden-basic-info"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('IMP-100')
|
||||
expect(wrapper.text()).toContain('https://garden.example.com')
|
||||
expect(wrapper.text()).not.toContain('favorites.manager.preview.garden.snapshotTitle')
|
||||
expect(wrapper.find('.n-collapse').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-examples"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="favorite-garden-variables"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('supports image zoom and local upload actions in editable mode', async () => {
|
||||
class MockFileReader {
|
||||
public result: string | null = null
|
||||
|
||||
@@ -82,6 +82,143 @@ describe('useAppFavorite', () => {
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('applies selected example parameters to pro-variable temporary variables', async () => {
|
||||
const success = vi.fn(() => createReactive())
|
||||
setGlobalMessageApi({
|
||||
success,
|
||||
error: vi.fn(() => createReactive()),
|
||||
warning: vi.fn(() => createReactive()),
|
||||
info: vi.fn(() => createReactive()),
|
||||
})
|
||||
|
||||
const optimizerPrompt = ref('')
|
||||
const temporaryVariables: Record<string, string> = { topic: 'old' }
|
||||
const proVariableSession = {
|
||||
getTemporaryVariable: vi.fn((name: string) => temporaryVariables[name]),
|
||||
setTemporaryVariable: vi.fn((name: string, value: string) => {
|
||||
temporaryVariables[name] = value
|
||||
}),
|
||||
clearTemporaryVariables: vi.fn(() => {
|
||||
for (const key of Object.keys(temporaryVariables)) delete temporaryVariables[key]
|
||||
}),
|
||||
}
|
||||
|
||||
const { handleUseFavorite } = useAppFavorite({
|
||||
navigateToSubModeKey: vi.fn(async () => {}),
|
||||
handleContextModeChange: vi.fn(async () => {}),
|
||||
optimizerPrompt,
|
||||
t: (key: string) => key,
|
||||
isLoadingExternalData: ref(false),
|
||||
proVariableSession,
|
||||
})
|
||||
|
||||
const used = await handleUseFavorite({
|
||||
content: 'Write about {{topic}}',
|
||||
functionMode: 'context',
|
||||
optimizationMode: 'user',
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [{ name: 'topic', defaultValue: 'default' }],
|
||||
examples: [
|
||||
{ id: 'a', parameters: { topic: 'alpha' } },
|
||||
{ id: 'b', parameters: { topic: 'beta' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}, { applyExample: true, exampleId: 'b' })
|
||||
|
||||
expect(used).toBe(true)
|
||||
expect(optimizerPrompt.value).toBe('Write about {{topic}}')
|
||||
expect(proVariableSession.clearTemporaryVariables).toHaveBeenCalled()
|
||||
expect(temporaryVariables.topic).toBe('beta')
|
||||
expect(success).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies image example input images without changing template content', async () => {
|
||||
const success = vi.fn(() => createReactive())
|
||||
setGlobalMessageApi({
|
||||
success,
|
||||
error: vi.fn(() => createReactive()),
|
||||
warning: vi.fn(() => createReactive()),
|
||||
info: vi.fn(() => createReactive()),
|
||||
})
|
||||
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const replaceInputImages = vi.fn()
|
||||
const imageMultiImageSession = {
|
||||
getTemporaryVariable: vi.fn(() => undefined),
|
||||
setTemporaryVariable: vi.fn(),
|
||||
clearTemporaryVariables: vi.fn(),
|
||||
replaceInputImages,
|
||||
}
|
||||
|
||||
const { handleUseFavorite } = useAppFavorite({
|
||||
navigateToSubModeKey: vi.fn(async () => {}),
|
||||
handleContextModeChange: vi.fn(async () => {}),
|
||||
optimizerPrompt: ref('unchanged'),
|
||||
t: (key: string) => key,
|
||||
isLoadingExternalData: ref(false),
|
||||
imageMultiImageSession,
|
||||
})
|
||||
|
||||
const used = await handleUseFavorite({
|
||||
content: 'Image prompt {{scene}}',
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'multiimage',
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [{ name: 'scene' }],
|
||||
examples: [{ id: 'img', parameters: { scene: 'city' }, inputImages: ['data:image/png;base64,AAECAw=='] }],
|
||||
},
|
||||
},
|
||||
}, { applyExample: true, exampleId: 'img' })
|
||||
|
||||
expect(used).toBe(true)
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'image-workspace-restore-favorite',
|
||||
detail: expect.objectContaining({ content: 'Image prompt {{scene}}' }),
|
||||
}))
|
||||
expect(imageMultiImageSession.setTemporaryVariable).toHaveBeenCalledWith('scene', 'city')
|
||||
expect(replaceInputImages).toHaveBeenCalledWith([{ b64: 'AAECAw==', mimeType: 'image/png' }])
|
||||
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('stops loading favorite data when workspace navigation rejects the target key', async () => {
|
||||
const success = vi.fn(() => createReactive())
|
||||
setGlobalMessageApi({
|
||||
success,
|
||||
error: vi.fn(() => createReactive()),
|
||||
warning: vi.fn(() => createReactive()),
|
||||
info: vi.fn(() => createReactive()),
|
||||
})
|
||||
|
||||
const optimizerPrompt = ref('unchanged')
|
||||
const dispatchSpy = vi.spyOn(window, 'dispatchEvent')
|
||||
const { handleUseFavorite } = useAppFavorite({
|
||||
navigateToSubModeKey: vi.fn(async () => false),
|
||||
handleContextModeChange: vi.fn(async () => {}),
|
||||
optimizerPrompt,
|
||||
t: (key: string) => key,
|
||||
isLoadingExternalData: ref(false),
|
||||
})
|
||||
|
||||
const used = await handleUseFavorite({
|
||||
content: 'image favorite prompt',
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'text2image',
|
||||
})
|
||||
|
||||
expect(used).toBe(false)
|
||||
expect(optimizerPrompt.value).toBe('unchanged')
|
||||
expect(dispatchSpy).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'image-workspace-restore-favorite',
|
||||
}))
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
|
||||
dispatchSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('logs favorite restore failures with an English runtime message', async () => {
|
||||
const error = vi.fn(() => createReactive())
|
||||
setGlobalMessageApi({
|
||||
|
||||
@@ -8,7 +8,6 @@ const readSource = (relativePath: string) =>
|
||||
describe('app preview image adoption guards', () => {
|
||||
it('replaces direct preview image usage with AppPreviewImage in key UI surfaces', () => {
|
||||
const files = [
|
||||
'src/components/FavoriteCard.vue',
|
||||
'src/components/FavoriteMediaPreviewPanel.vue',
|
||||
'src/components/GardenSnapshotPreview.vue',
|
||||
'src/components/ImageModelEditModal.vue',
|
||||
|
||||
@@ -3,9 +3,15 @@ import type { RouteLocationNormalized } from 'vue-router'
|
||||
import { beforeRouteSwitch, parseSubModeKey } from '../../../src/router/guards'
|
||||
import { normalizeWorkspacePath, parseWorkspaceRoutePath } from '../../../src/router/workspaceRoutes'
|
||||
|
||||
function createRoute(path: string): RouteLocationNormalized {
|
||||
function createRoute(
|
||||
path: string,
|
||||
overrides: Partial<RouteLocationNormalized> = {},
|
||||
): RouteLocationNormalized {
|
||||
return {
|
||||
path,
|
||||
query: {},
|
||||
hash: '',
|
||||
...overrides,
|
||||
} as RouteLocationNormalized
|
||||
}
|
||||
|
||||
@@ -42,15 +48,23 @@ describe('router guards', () => {
|
||||
|
||||
describe('beforeRouteSwitch', () => {
|
||||
it('redirects legacy pro system route to multi mode', () => {
|
||||
const result = beforeRouteSwitch(createRoute('/pro/system'), createRoute('/'), undefined as never)
|
||||
const result = beforeRouteSwitch(
|
||||
createRoute('/pro/system', { query: { from: '/favorites' }, hash: '#section' }),
|
||||
createRoute('/'),
|
||||
undefined as never,
|
||||
)
|
||||
|
||||
expect(result).toBe('/pro/multi')
|
||||
expect(result).toEqual({
|
||||
path: '/pro/multi',
|
||||
query: { from: '/favorites' },
|
||||
hash: '#section',
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects legacy pro user route to variable mode', () => {
|
||||
const result = beforeRouteSwitch(createRoute('/pro/user'), createRoute('/'), undefined as never)
|
||||
|
||||
expect(result).toBe('/pro/variable')
|
||||
expect(result).toEqual({ path: '/pro/variable', query: {}, hash: '' })
|
||||
})
|
||||
|
||||
it('redirects invalid image sub mode to the default image route', () => {
|
||||
@@ -58,10 +72,16 @@ describe('router guards', () => {
|
||||
|
||||
const result = beforeRouteSwitch(createRoute('/image/unknown'), createRoute('/'), undefined as never)
|
||||
|
||||
expect(result).toBe('/image/text2image')
|
||||
expect(result).toEqual({ path: '/image/text2image', query: {}, hash: '' })
|
||||
expect(warnSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not redirect non-workspace routes that only share a prefix', () => {
|
||||
expect(beforeRouteSwitch(createRoute('/profile'), createRoute('/'), undefined as never)).toBe(true)
|
||||
expect(beforeRouteSwitch(createRoute('/project'), createRoute('/'), undefined as never)).toBe(true)
|
||||
expect(beforeRouteSwitch(createRoute('/process'), createRoute('/'), undefined as never)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows valid routes to continue', () => {
|
||||
const result = beforeRouteSwitch(createRoute('/basic/user'), createRoute('/'), undefined as never)
|
||||
|
||||
|
||||
34
packages/ui/tests/unit/utils/external-data-loading.spec.ts
Normal file
34
packages/ui/tests/unit/utils/external-data-loading.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createExternalDataLoadingGate } from '../../../src/utils/external-data-loading'
|
||||
|
||||
describe('createExternalDataLoadingGate', () => {
|
||||
it('keeps loading true until every concurrent owner leaves', () => {
|
||||
const gate = createExternalDataLoadingGate()
|
||||
|
||||
gate.isLoading.value = true
|
||||
gate.isLoading.value = true
|
||||
|
||||
expect(gate.isLoading.value).toBe(true)
|
||||
expect(gate.depth.value).toBe(2)
|
||||
|
||||
gate.isLoading.value = false
|
||||
|
||||
expect(gate.isLoading.value).toBe(true)
|
||||
expect(gate.depth.value).toBe(1)
|
||||
|
||||
gate.isLoading.value = false
|
||||
|
||||
expect(gate.isLoading.value).toBe(false)
|
||||
expect(gate.depth.value).toBe(0)
|
||||
})
|
||||
|
||||
it('does not underflow when a caller leaves too many times', () => {
|
||||
const gate = createExternalDataLoadingGate()
|
||||
|
||||
gate.isLoading.value = false
|
||||
|
||||
expect(gate.isLoading.value).toBe(false)
|
||||
expect(gate.depth.value).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,20 @@ describe('favoriteAssetMaintenance', () => {
|
||||
examples: [{ inputImageAssetIds: ['input-1'] }],
|
||||
},
|
||||
},
|
||||
reproducibility: {
|
||||
examples: [
|
||||
{
|
||||
imageAssetIds: ['manual-output-1'],
|
||||
inputImageAssetIds: ['manual-input-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
examples: [
|
||||
{
|
||||
imageAssetIds: ['legacy-output-1'],
|
||||
inputImageAssetIds: ['legacy-input-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
@@ -33,6 +47,10 @@ describe('favoriteAssetMaintenance', () => {
|
||||
{ id: 'asset-1' },
|
||||
{ id: 'gallery-1' },
|
||||
{ id: 'input-1' },
|
||||
{ id: 'manual-output-1' },
|
||||
{ id: 'manual-input-1' },
|
||||
{ id: 'legacy-output-1' },
|
||||
{ id: 'legacy-input-1' },
|
||||
{ id: 'orphan-1' },
|
||||
]),
|
||||
deleteImages: vi.fn(async (_ids: string[]) => {}),
|
||||
|
||||
@@ -1,4 +1,188 @@
|
||||
import path from 'node:path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { waitForAppReady } from './helpers/common';
|
||||
|
||||
const imageFixturePath = (fileName: string) =>
|
||||
path.resolve(process.cwd(), 'tests/e2e/fixtures/images', fileName);
|
||||
const inlineExampleImage =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l9v7rwAAAABJRU5ErkJggg==';
|
||||
|
||||
async function fillFieldByTestId(page: Page, testId: string, value: string): Promise<void> {
|
||||
const field = page
|
||||
.locator(
|
||||
`[data-testid="${testId}"] input, ` +
|
||||
`[data-testid="${testId}"] textarea, ` +
|
||||
`input[data-testid="${testId}"], ` +
|
||||
`textarea[data-testid="${testId}"]`
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(field).toBeVisible({ timeout: 5000 });
|
||||
await field.fill(value);
|
||||
}
|
||||
|
||||
async function openFavoritesPage(page: Page): Promise<void> {
|
||||
await page.goto('/#/favorites', { waitUntil: 'domcontentloaded' });
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByTestId('favorites-manager-add')).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
async function uploadFavoriteImage(page: Page, fileName: string): Promise<void> {
|
||||
const uploadInput = page
|
||||
.locator(
|
||||
'[data-testid="favorite-editor-image-upload-empty"] input[type="file"], ' +
|
||||
'[data-testid="favorite-editor-image-upload"] input[type="file"]'
|
||||
)
|
||||
.first();
|
||||
|
||||
await uploadInput.setInputFiles(imageFixturePath(fileName));
|
||||
await expect(page.getByTestId('favorite-editor-remove-image')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
async function uploadExampleImage(
|
||||
page: Page,
|
||||
testId: 'favorite-repro-example-image-upload' | 'favorite-repro-example-input-image-upload',
|
||||
removeTestId: 'favorite-repro-example-remove-image' | 'favorite-repro-example-remove-input-image',
|
||||
fileName: string
|
||||
): Promise<void> {
|
||||
const uploadInput = page
|
||||
.locator(`[data-testid="${testId}"] input[type="file"]`)
|
||||
.first();
|
||||
const previousCount = await page.getByTestId(removeTestId).count();
|
||||
|
||||
await uploadInput.setInputFiles(imageFixturePath(fileName));
|
||||
await expect.poll(async () => page.getByTestId(removeTestId).count(), { timeout: 10000 }).toBeGreaterThan(previousCount);
|
||||
}
|
||||
|
||||
async function addExampleImageUrl(
|
||||
page: Page,
|
||||
fieldTestId: 'favorite-repro-example-images' | 'favorite-repro-example-input-images',
|
||||
buttonTestId: 'favorite-repro-example-add-image-url' | 'favorite-repro-example-add-input-image-url',
|
||||
removeTestId: 'favorite-repro-example-remove-image' | 'favorite-repro-example-remove-input-image',
|
||||
value: string
|
||||
): Promise<void> {
|
||||
const previousCount = await page.getByTestId(removeTestId).count();
|
||||
await fillFieldByTestId(page, fieldTestId, value);
|
||||
await page.getByTestId(buttonTestId).click();
|
||||
await expect.poll(async () => page.getByTestId(removeTestId).count(), { timeout: 10000 }).toBeGreaterThan(previousCount);
|
||||
}
|
||||
|
||||
async function addExampleParameter(page: Page, key: string, value: string): Promise<void> {
|
||||
await fillFieldByTestId(page, 'favorite-repro-example-parameter-key', key);
|
||||
await fillFieldByTestId(page, 'favorite-repro-example-parameter-new-value', value);
|
||||
await page.getByTestId('favorite-repro-example-add-parameter').click();
|
||||
}
|
||||
|
||||
async function closeFavoritesDrawerIfOpen(page: Page): Promise<void> {
|
||||
const closeButton = page.locator('.n-drawer .n-base-close:visible').last();
|
||||
if (await closeButton.count()) {
|
||||
await closeButton.dispatchEvent('click');
|
||||
await expect(page.locator('.n-drawer-mask')).toBeHidden({ timeout: 10000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function createFavoriteWithReproData(
|
||||
page: Page,
|
||||
data: {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
image: string;
|
||||
variableName: string;
|
||||
variableDefault: string;
|
||||
variableDescription: string;
|
||||
exampleId: string;
|
||||
exampleText: string;
|
||||
exampleDescription: string;
|
||||
exampleParameterKey: string;
|
||||
exampleParameterValue: string;
|
||||
exampleImages: string;
|
||||
exampleInputImages: string;
|
||||
exampleImageUpload: string;
|
||||
exampleInputImageUpload: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
await closeFavoritesDrawerIfOpen(page);
|
||||
await page.getByTestId('favorites-manager-add').click();
|
||||
await expect(page.getByTestId('favorite-editor-title')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await fillFieldByTestId(page, 'favorite-editor-title', data.title);
|
||||
await fillFieldByTestId(page, 'favorite-editor-description', data.description);
|
||||
await fillFieldByTestId(page, 'favorite-editor-content', data.content);
|
||||
await uploadFavoriteImage(page, data.image);
|
||||
|
||||
await page.getByTestId('favorite-repro-add-variable-empty').click();
|
||||
await fillFieldByTestId(page, 'favorite-repro-variable-name', data.variableName);
|
||||
await fillFieldByTestId(page, 'favorite-repro-variable-default', data.variableDefault);
|
||||
await fillFieldByTestId(page, 'favorite-repro-variable-description', data.variableDescription);
|
||||
|
||||
await page.getByTestId('favorite-repro-add-example').click();
|
||||
await fillFieldByTestId(page, 'favorite-repro-example-id', data.exampleId);
|
||||
await fillFieldByTestId(page, 'favorite-repro-example-text', data.exampleText);
|
||||
await fillFieldByTestId(page, 'favorite-repro-example-description', data.exampleDescription);
|
||||
await addExampleParameter(page, data.exampleParameterKey, data.exampleParameterValue);
|
||||
await addExampleImageUrl(page, 'favorite-repro-example-images', 'favorite-repro-example-add-image-url', 'favorite-repro-example-remove-image', data.exampleImages);
|
||||
await addExampleImageUrl(page, 'favorite-repro-example-input-images', 'favorite-repro-example-add-input-image-url', 'favorite-repro-example-remove-input-image', data.exampleInputImages);
|
||||
await uploadExampleImage(page, 'favorite-repro-example-image-upload', 'favorite-repro-example-remove-image', data.exampleImageUpload);
|
||||
await uploadExampleImage(page, 'favorite-repro-example-input-image-upload', 'favorite-repro-example-remove-input-image', data.exampleInputImageUpload);
|
||||
|
||||
await page.getByTestId('favorite-editor-save').click();
|
||||
const detailPanel = page.getByTestId('favorite-detail-panel');
|
||||
await expect(detailPanel).toBeVisible({ timeout: 15000 });
|
||||
await expect(detailPanel).toContainText(data.title);
|
||||
await expect(detailPanel).toContainText(data.variableName);
|
||||
await expect(detailPanel).toContainText(data.exampleId);
|
||||
}
|
||||
|
||||
async function selectFavoriteByTitle(page: Page, title: string): Promise<void> {
|
||||
await closeFavoritesDrawerIfOpen(page);
|
||||
await page.locator('.favorites-manager-search input').fill(title);
|
||||
const listItem = page.getByTestId('favorite-workspace-list-item').filter({ hasText: title }).first();
|
||||
await expect(listItem).toBeVisible({ timeout: 10000 });
|
||||
await listItem.click();
|
||||
await expect(page.getByTestId('favorite-detail-panel')).toContainText(title, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function seedFavorites(page: Page, favorites: unknown[]): Promise<void> {
|
||||
await page.evaluate(async (items) => {
|
||||
const dbName = (window as unknown as { __TEST_DB_NAME__?: string }).__TEST_DB_NAME__ || 'PromptOptimizerDB';
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('storage')) {
|
||||
db.createObjectStore('storage', { keyPath: 'key' });
|
||||
}
|
||||
};
|
||||
request.onerror = () => reject(request.error || new Error('Failed to open test database'));
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction('storage', 'readwrite');
|
||||
const store = transaction.objectStore('storage');
|
||||
store.put({ key: 'favorites', value: JSON.stringify(items), timestamp: Date.now() });
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => {
|
||||
db.close();
|
||||
reject(transaction.error || new Error('Failed to seed favorites'));
|
||||
};
|
||||
};
|
||||
});
|
||||
}, favorites);
|
||||
}
|
||||
|
||||
async function expectAnyTextareaValue(page: Page, value: string): Promise<void> {
|
||||
await expect.poll(async () => {
|
||||
const values = await page.locator('textarea').evaluateAll((nodes) =>
|
||||
nodes.map((node) => (node as HTMLTextAreaElement).value)
|
||||
);
|
||||
return values.includes(value);
|
||||
}, { timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收藏管理基础 E2E 测试
|
||||
@@ -419,3 +603,165 @@ test.describe('收藏数据持久化', () => {
|
||||
expect(localStorageAvailable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('收藏夹图片、变量和示例流程', () => {
|
||||
test('能够创建、编辑切换并使用带图片/变量/示例的收藏', async ({ page }) => {
|
||||
test.setTimeout(90000);
|
||||
|
||||
const firstFavorite = {
|
||||
title: 'E2E 图片收藏 A',
|
||||
description: '红色图片收藏,包含变量和示例',
|
||||
content: '使用 {{topic}} 生成一段正式中文系统提示词。',
|
||||
image: 'favorite-red.svg',
|
||||
variableName: 'topic',
|
||||
variableDefault: '产品发布',
|
||||
variableDescription: '需要生成提示词的主题',
|
||||
exampleId: 'case-red',
|
||||
exampleText: '围绕新品发布生成语气稳重的系统提示词',
|
||||
exampleDescription: '红色图片收藏的示例说明',
|
||||
exampleParameterKey: 'topic',
|
||||
exampleParameterValue: '新品发布',
|
||||
exampleImages: inlineExampleImage,
|
||||
exampleInputImages: inlineExampleImage,
|
||||
exampleImageUpload: 'favorite-red.svg',
|
||||
exampleInputImageUpload: 'favorite-red.svg',
|
||||
};
|
||||
const secondFavorite = {
|
||||
title: 'E2E 图片收藏 B',
|
||||
description: '蓝色图片收藏,验证编辑态不会串数据',
|
||||
content: '请根据 {{audience}} 输出精简版图像提示词。',
|
||||
image: 'favorite-blue.svg',
|
||||
variableName: 'audience',
|
||||
variableDefault: '开发者',
|
||||
variableDescription: '目标读者或使用者',
|
||||
exampleId: 'case-blue',
|
||||
exampleText: '为开发者输出一段精简提示词',
|
||||
exampleDescription: '蓝色图片收藏的示例说明',
|
||||
exampleParameterKey: 'audience',
|
||||
exampleParameterValue: '开发者',
|
||||
exampleImages: inlineExampleImage,
|
||||
exampleInputImages: inlineExampleImage,
|
||||
exampleImageUpload: 'favorite-blue.svg',
|
||||
exampleInputImageUpload: 'favorite-blue.svg',
|
||||
};
|
||||
|
||||
await openFavoritesPage(page);
|
||||
|
||||
await createFavoriteWithReproData(page, firstFavorite);
|
||||
await createFavoriteWithReproData(page, secondFavorite);
|
||||
|
||||
await selectFavoriteByTitle(page, firstFavorite.title);
|
||||
await expect(page.getByTestId('favorite-detail-panel')).toContainText(firstFavorite.variableName);
|
||||
await expect(page.getByTestId('favorite-detail-panel')).toContainText(firstFavorite.exampleId);
|
||||
|
||||
await page.getByTestId('favorite-detail-edit').click();
|
||||
await expect(page.getByTestId('favorite-editor-title')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('[data-testid="favorite-editor-title"] input')).toHaveValue(firstFavorite.title);
|
||||
|
||||
await page.getByTestId('favorite-editor-cancel').click();
|
||||
await closeFavoritesDrawerIfOpen(page);
|
||||
await selectFavoriteByTitle(page, secondFavorite.title);
|
||||
await page.getByTestId('favorite-detail-edit').click();
|
||||
await expect(page.locator('[data-testid="favorite-editor-title"] input')).toHaveValue(secondFavorite.title);
|
||||
await expect(page.locator('[data-testid="favorite-editor-content"] textarea')).toHaveValue(secondFavorite.content);
|
||||
await expect(page.locator('[data-testid="favorite-repro-variable-name"] input')).toHaveValue(secondFavorite.variableName);
|
||||
await expect(page.locator('[data-testid="favorite-repro-example-id"] input')).toHaveValue(secondFavorite.exampleId);
|
||||
|
||||
await fillFieldByTestId(page, 'favorite-editor-description', '蓝色图片收藏已通过 E2E 编辑验证');
|
||||
await page.getByTestId('favorite-editor-save').click();
|
||||
const detailPanel = page.getByTestId('favorite-detail-panel');
|
||||
await expect(detailPanel).toBeVisible({ timeout: 15000 });
|
||||
await expect(detailPanel).toContainText('蓝色图片收藏已通过 E2E 编辑验证');
|
||||
await expect(detailPanel).toContainText(secondFavorite.variableName);
|
||||
await expect(detailPanel).toContainText(secondFavorite.exampleId);
|
||||
|
||||
await page.getByTestId('favorite-detail-use').click();
|
||||
await expect(page).toHaveURL(/\/#\/basic\/system$/, { timeout: 20000 });
|
||||
await expect(page.locator('[data-testid="basic-system-input"] textarea')).toHaveValue(secondFavorite.content, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await openFavoritesPage(page);
|
||||
await selectFavoriteByTitle(page, secondFavorite.title);
|
||||
page.once('dialog', async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByTestId('favorite-detail-delete').click();
|
||||
await expect(page.getByTestId('favorite-workspace-list-item').filter({ hasText: secondFavorite.title })).toHaveCount(0, {
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('收藏示例应用流程', () => {
|
||||
test('能够从收藏示例应用非图像变量提示词和图像提示词,不调用 LLM', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const now = Date.now();
|
||||
const nonImageFavorite = {
|
||||
id: 'e2e-fav-pro-variable-example',
|
||||
title: 'E2E 非图像示例收藏',
|
||||
description: '验证收藏示例参数进入上下文变量模式',
|
||||
content: '请围绕 {{topic}} 写一个结构化提示词。',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tags: [],
|
||||
useCount: 0,
|
||||
functionMode: 'context',
|
||||
optimizationMode: 'user',
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [{ name: 'topic', defaultValue: '默认主题', required: true }],
|
||||
examples: [{ id: 'case-topic', parameters: { topic: '收藏示例主题' } }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const imageFavorite = {
|
||||
id: 'e2e-fav-image-example',
|
||||
title: 'E2E 图像示例收藏',
|
||||
description: '验证收藏示例可应用图像模式输入图和变量',
|
||||
content: '生成一张 {{scene}} 的参考图。',
|
||||
createdAt: now + 1,
|
||||
updatedAt: now + 1,
|
||||
tags: [],
|
||||
useCount: 0,
|
||||
functionMode: 'image',
|
||||
imageSubMode: 'multiimage',
|
||||
metadata: {
|
||||
reproducibility: {
|
||||
variables: [{ name: 'scene', defaultValue: '默认场景' }],
|
||||
examples: [{ id: 'case-image', parameters: { scene: '夜晚花园' }, inputImages: ['https://favorite-example.local/input.png'] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await openFavoritesPage(page);
|
||||
await seedFavorites(page, [nonImageFavorite, imageFavorite]);
|
||||
await page.route('https://favorite-example.local/input.png', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
body: Buffer.from(inlineExampleImage.split(',')[1] || '', 'base64'),
|
||||
});
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByTestId('favorites-manager-add')).toBeVisible({ timeout: 20000 });
|
||||
await selectFavoriteByTitle(page, nonImageFavorite.title);
|
||||
await page.getByTestId('favorite-repro-example-apply-0').click();
|
||||
await expect(page).toHaveURL(/\/#\/pro\/variable$/, { timeout: 20000 });
|
||||
await expect(page.getByTestId('workspace')).toHaveAttribute('data-mode', 'pro-variable');
|
||||
await expect(page.getByTestId('pro-variable-input')).toContainText(nonImageFavorite.content, { timeout: 10000 });
|
||||
await expect(page.locator('[data-testid="workspace"][data-mode="pro-variable"]')).toContainText('topic', { timeout: 10000 });
|
||||
await expectAnyTextareaValue(page, '收藏示例主题');
|
||||
|
||||
await openFavoritesPage(page);
|
||||
await selectFavoriteByTitle(page, imageFavorite.title);
|
||||
await page.getByTestId('favorite-repro-example-apply-0').click();
|
||||
await expect(page).toHaveURL(/\/#\/image\/multiimage$/, { timeout: 20000 });
|
||||
await expect(page.getByTestId('workspace')).toHaveAttribute('data-mode', 'image-multiimage');
|
||||
await expect(page.getByTestId('workspace')).toContainText(imageFavorite.content, { timeout: 10000 });
|
||||
await expectAnyTextareaValue(page, '夜晚花园');
|
||||
await expect(page.locator('[data-testid="workspace"][data-mode="image-multiimage"] img[src^="data:image/"]')).toHaveCount(1, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
4
tests/e2e/fixtures/images/favorite-blue.svg
Normal file
4
tests/e2e/fixtures/images/favorite-blue.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#1a73e8"/>
|
||||
<path d="M18 40 L32 16 L46 40 Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
4
tests/e2e/fixtures/images/favorite-red.svg
Normal file
4
tests/e2e/fixtures/images/favorite-red.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" fill="#d93025"/>
|
||||
<circle cx="32" cy="32" r="18" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 189 B |
Reference in New Issue
Block a user