From f034bde2d4438301f227c35b0cbf5a287c77f3f9 Mon Sep 17 00:00:00 2001 From: linshen <32978552+linshenkx@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:28:00 +0800 Subject: [PATCH] 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 --- packages/ui/src/components/FavoriteCard.vue | 488 ------------- .../ui/src/components/FavoriteDetailPanel.vue | 76 +- .../ui/src/components/FavoriteEditorForm.vue | 196 ++++- .../components/FavoriteLibraryWorkspace.vue | 13 +- .../ui/src/components/FavoriteManager.vue | 13 +- .../FavoritePreviewExtensionHost.vue | 4 + .../FavoriteReproducibilityDisplay.vue | 113 ++- .../FavoriteReproducibilityEditor.vue | 688 ++++++++++++++++-- .../components/FavoriteWorkspaceListItem.vue | 3 +- .../src/components/GardenSnapshotPreview.vue | 63 +- .../PromptGardenFavoritePreviewPanel.vue | 4 + .../app-layout/PromptOptimizerApp.vue | 25 +- .../components/favorites/FavoritesPage.vue | 7 +- .../favorites/favorites-page-context.ts | 2 +- .../ui/src/composables/app/useAppFavorite.ts | 203 +++++- .../composables/app/useAppHistoryRestore.ts | 12 +- packages/ui/src/composables/ui/useToast.ts | 3 - .../ui/src/i18n/locales/en-US/favorites.ts | 17 +- .../ui/src/i18n/locales/zh-CN/favorites.ts | 17 +- .../ui/src/i18n/locales/zh-TW/favorites.ts | 17 +- packages/ui/src/router/RootBootstrapRoute.ts | 3 +- packages/ui/src/router/guards.ts | 8 +- .../ui/src/utils/external-data-loading.ts | 22 + .../src/utils/favorite-asset-maintenance.ts | 36 +- .../unit/components/FavoriteCard.spec.ts | 185 ----- .../components/FavoriteDetailPanel.spec.ts | 35 + .../components/FavoriteEditorForm.spec.ts | 473 ++++++++++++ .../FavoriteReproducibilityDisplay.spec.ts | 108 +++ .../FavoriteReproducibilityEditor.spec.ts | 192 ++++- .../components/GardenSnapshotPreview.spec.ts | 33 + .../unit/composables/useAppFavorite.spec.ts | 137 ++++ .../image/app-preview-image-adoption.spec.ts | 1 - packages/ui/tests/unit/router/guards.spec.ts | 30 +- .../unit/utils/external-data-loading.spec.ts | 34 + .../utils/favorite-asset-maintenance.spec.ts | 18 + tests/e2e/favorite-management.spec.ts | 346 +++++++++ tests/e2e/fixtures/images/favorite-blue.svg | 4 + tests/e2e/fixtures/images/favorite-red.svg | 4 + 38 files changed, 2770 insertions(+), 863 deletions(-) delete mode 100644 packages/ui/src/components/FavoriteCard.vue create mode 100644 packages/ui/src/utils/external-data-loading.ts delete mode 100644 packages/ui/tests/unit/components/FavoriteCard.spec.ts create mode 100644 packages/ui/tests/unit/components/FavoriteEditorForm.spec.ts create mode 100644 packages/ui/tests/unit/components/FavoriteReproducibilityDisplay.spec.ts create mode 100644 packages/ui/tests/unit/utils/external-data-loading.spec.ts create mode 100644 tests/e2e/fixtures/images/favorite-blue.svg create mode 100644 tests/e2e/fixtures/images/favorite-red.svg diff --git a/packages/ui/src/components/FavoriteCard.vue b/packages/ui/src/components/FavoriteCard.vue deleted file mode 100644 index 6cda897c..00000000 --- a/packages/ui/src/components/FavoriteCard.vue +++ /dev/null @@ -1,488 +0,0 @@ - - - - - diff --git a/packages/ui/src/components/FavoriteDetailPanel.vue b/packages/ui/src/components/FavoriteDetailPanel.vue index 155d57c8..0d9b5dd6 100644 --- a/packages/ui/src/components/FavoriteDetailPanel.vue +++ b/packages/ui/src/components/FavoriteDetailPanel.vue @@ -20,6 +20,7 @@ @@ -29,6 +30,7 @@ {{ t('favorites.manager.card.useNow') }} @@ -38,6 +40,7 @@ {{ t('favorites.manager.card.copyContent') }} @@ -47,6 +50,7 @@ {{ t('common.fullscreen') }} @@ -56,6 +60,7 @@ {{ t('favorites.manager.card.edit') }} - + @@ -298,11 +309,17 @@ name="reproducibility" :title="t('favorites.manager.preview.reproducibility.title')" > - + @@ -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 | null>('services', null) const assetDataUrlCache = new Map() const displayImages = ref([]) +const promotedGardenSnapshotSections = ['metaInfo', 'cover', 'showcases', 'examples', 'variables'] +const reproducibilityExamplePreviews = ref + 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 }) +} diff --git a/packages/ui/src/components/FavoriteReproducibilityEditor.vue b/packages/ui/src/components/FavoriteReproducibilityEditor.vue index af23f598..6dc14111 100644 --- a/packages/ui/src/components/FavoriteReproducibilityEditor.vue +++ b/packages/ui/src/components/FavoriteReproducibilityEditor.vue @@ -14,10 +14,10 @@ {{ t('favorites.dialog.reproducibility.empty') }} - + {{ t('favorites.dialog.reproducibility.addVariable') }} - + {{ t('favorites.dialog.reproducibility.addExample') }} @@ -27,7 +27,7 @@
{{ t('favorites.dialog.reproducibility.variables') }} - + {{ t('favorites.dialog.reproducibility.addVariable') }}
@@ -55,6 +55,7 @@ {{ t('favorites.dialog.reproducibility.required') }}
{{ t('favorites.dialog.reproducibility.examples') }} - + {{ t('favorites.dialog.reproducibility.addExample') }}
@@ -123,53 +129,14 @@
- - - - - - - - - - - - - - - - - - -
+
+ + {{ example.id || t('favorites.dialog.reproducibility.exampleLabel', { index: index + 1 }) }} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ {{ t('favorites.dialog.reproducibility.exampleParametersLabel') }} + +
+ + {{ parameterKey }} + + + + {{ t('favorites.dialog.reproducibility.remove') }} + +
+
+
+ + + + {{ t('favorites.dialog.reproducibility.addParameter') }} + +
+
+
+ +
+
+ {{ t('favorites.dialog.reproducibility.exampleImages') }} + + + + {{ t('favorites.dialog.reproducibility.addImageUrl') }} + + + + + {{ t('favorites.dialog.reproducibility.addExampleImages') }} + + + +
+
+ + + × + +
+
+
+
+ +
+ {{ t('favorites.dialog.reproducibility.exampleInputImages') }} + + + + {{ t('favorites.dialog.reproducibility.addImageUrl') }} + + + + + {{ t('favorites.dialog.reproducibility.addExampleInputImages') }} + + + +
+
+ + + × + +
+
+
+
+
@@ -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([]) +const imageUrlDrafts = reactive>({}) +const parameterDrafts = reactive>({}) + const formatOptions = (items: string[] | undefined) => (items || []).join(', ') +const dedupeStrings = (items: string[]) => Array.from(new Set(items.filter(Boolean))) +const assetFieldByImageField: Record = { + 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 | undefined) => { - return Object.entries(parameters || {}) - .map(([key, value]) => `${key}=${value}`) - .join('\n') +const getParameterEntries = (parameters: Record | undefined) => + Object.entries(parameters || {}) + +const getParameterDraft = (index: number) => { + parameterDrafts[index] ||= { key: '', value: '' } + return parameterDrafts[index] } -const parseParametersText = (value: string): Record => { - const parameters: Record = {} - 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 = {} + 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 = {} + 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, ) => { - 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((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 } @@ -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; + } +} diff --git a/packages/ui/src/components/FavoriteWorkspaceListItem.vue b/packages/ui/src/components/FavoriteWorkspaceListItem.vue index d35db31c..4d290fd0 100644 --- a/packages/ui/src/components/FavoriteWorkspaceListItem.vue +++ b/packages/ui/src/components/FavoriteWorkspaceListItem.vue @@ -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 @@
- + diff --git a/packages/ui/src/components/GardenSnapshotPreview.vue b/packages/ui/src/components/GardenSnapshotPreview.vue index b7ffb84f..37141e7a 100644 --- a/packages/ui/src/components/GardenSnapshotPreview.vue +++ b/packages/ui/src/components/GardenSnapshotPreview.vue @@ -1,14 +1,46 @@