@dickpy/dsh-imagegen 1.5.9 → 1.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ /** reactbits.dev "GooeyNav" port (controlled, dependency-free): the active
2
+ * pill merges with a radial burst of gooey particles through a
3
+ * blur+contrast filter. The original renders white ink on dark surfaces,
4
+ * so the palette flips per detected surface to stay readable in the host
5
+ * theme. */
6
+
7
+ import { useEffect, useRef, type ReactNode } from 'react'
8
+ import { detectLightSurface } from './surface-theme.ts'
9
+ import css from './GooeyNav.module.css'
10
+
11
+ export interface GooeyNavItem {
12
+ key: string
13
+ label: ReactNode
14
+ }
15
+
16
+ const ANIMATION_TIME = 600
17
+ const PARTICLE_COUNT = 15
18
+ const PARTICLE_DISTANCES = [90, 10]
19
+ const PARTICLE_R = 100
20
+ const TIME_VARIANCE = 300
21
+ const COLORS = [1, 2, 3, 1, 2, 3, 1, 4]
22
+
23
+ export function GooeyNav(props: {
24
+ items: GooeyNavItem[]
25
+ activeIndex: number
26
+ onSelect: (index: number) => void
27
+ ariaLabel?: string
28
+ }): React.JSX.Element {
29
+ const { items, activeIndex, onSelect, ariaLabel } = props
30
+ const containerRef = useRef<HTMLDivElement>(null)
31
+ const navRef = useRef<HTMLUListElement>(null)
32
+ const filterRef = useRef<HTMLSpanElement>(null)
33
+ const textRef = useRef<HTMLSpanElement>(null)
34
+ const burstRef = useRef<(li: HTMLElement) => void>(() => {})
35
+ const timersRef = useRef<number[]>([])
36
+ const activeIndexRef = useRef(activeIndex)
37
+ activeIndexRef.current = activeIndex
38
+
39
+ const updateEffectPosition = (element: HTMLElement): void => {
40
+ const container = containerRef.current
41
+ const filter = filterRef.current
42
+ const text = textRef.current
43
+ if (container === null || filter === null || text === null) return
44
+ const containerRect = container.getBoundingClientRect()
45
+ const pos = element.getBoundingClientRect()
46
+ const styles = {
47
+ left: `${pos.x - containerRect.x}px`,
48
+ top: `${pos.y - containerRect.y}px`,
49
+ width: `${pos.width}px`,
50
+ height: `${pos.height}px`,
51
+ }
52
+ Object.assign(filter.style, styles)
53
+ Object.assign(text.style, styles)
54
+ text.textContent = element.textContent
55
+ }
56
+
57
+ // One-time setup: the particle burst timers must survive selection changes,
58
+ // so they live outside the activeIndex-driven effect below.
59
+ useEffect(() => {
60
+ const container = containerRef.current
61
+ const filter = filterRef.current
62
+ const text = textRef.current
63
+ if (container === null || filter === null || text === null) return
64
+ container.dataset.theme = detectLightSurface(container) ? 'light' : 'dark'
65
+ let disposed = false
66
+ const noise = (amount: number): number => amount / 2 - Math.random() * amount
67
+ const getXY = (distance: number, pointIndex: number, totalPoints: number): [number, number] => {
68
+ const angle = ((360 + noise(8)) / totalPoints) * pointIndex * (Math.PI / 180)
69
+ return [distance * Math.cos(angle), distance * Math.sin(angle)]
70
+ }
71
+ const makeParticles = (element: HTMLElement): void => {
72
+ const bubbleTime = ANIMATION_TIME * 2 + TIME_VARIANCE
73
+ element.style.setProperty('--time', `${bubbleTime}ms`)
74
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
75
+ const time = ANIMATION_TIME * 2 + noise(TIME_VARIANCE * 2)
76
+ const rotateSeed = noise(PARTICLE_R / 10)
77
+ const start = getXY(PARTICLE_DISTANCES[0] as number, PARTICLE_COUNT - i, PARTICLE_COUNT)
78
+ const end = getXY((PARTICLE_DISTANCES[1] as number) + noise(7), PARTICLE_COUNT - i, PARTICLE_COUNT)
79
+ const scale = 1 + noise(0.2)
80
+ const color = COLORS[Math.floor(Math.random() * COLORS.length)] ?? 1
81
+ const rotate = rotateSeed > 0 ? (rotateSeed + PARTICLE_R / 20) * 10 : (rotateSeed - PARTICLE_R / 20) * 10
82
+ const timer = window.setTimeout(() => {
83
+ if (disposed) return
84
+ const particle = document.createElement('span')
85
+ const point = document.createElement('span')
86
+ particle.className = css.particle
87
+ point.className = css.point
88
+ particle.style.setProperty('--start-x', `${start[0] as number}px`)
89
+ particle.style.setProperty('--start-y', `${start[1] as number}px`)
90
+ particle.style.setProperty('--end-x', `${end[0] as number}px`)
91
+ particle.style.setProperty('--end-y', `${end[1] as number}px`)
92
+ particle.style.setProperty('--time', `${time}ms`)
93
+ particle.style.setProperty('--scale', `${scale}`)
94
+ particle.style.setProperty('--color', `var(--gooey-color-${color}, currentColor)`)
95
+ particle.style.setProperty('--rotate', `${rotate}deg`)
96
+ particle.appendChild(point)
97
+ element.appendChild(particle)
98
+ if (typeof window.requestAnimationFrame === 'function') {
99
+ window.requestAnimationFrame(() => { element.classList.add(css.active) })
100
+ } else {
101
+ element.classList.add(css.active)
102
+ }
103
+ const removeTimer = window.setTimeout(() => { particle.remove() }, time)
104
+ timersRef.current.push(removeTimer)
105
+ }, 30)
106
+ timersRef.current.push(timer)
107
+ }
108
+ }
109
+ burstRef.current = (li: HTMLElement): void => {
110
+ updateEffectPosition(li)
111
+ for (const particle of [...filter.querySelectorAll(`.${css.particle}`)]) particle.remove()
112
+ text.classList.remove(css.active)
113
+ void text.offsetWidth
114
+ text.classList.add(css.active)
115
+ makeParticles(filter)
116
+ }
117
+ const positionActive = (): void => {
118
+ const nav = navRef.current
119
+ const activeLi = nav?.querySelectorAll('li')[activeIndexRef.current]
120
+ if (activeLi) {
121
+ updateEffectPosition(activeLi)
122
+ text.classList.add(css.active)
123
+ }
124
+ }
125
+ positionActive()
126
+ const observer = new ResizeObserver(positionActive)
127
+ observer.observe(container)
128
+ return () => {
129
+ disposed = true
130
+ for (const timer of timersRef.current) window.clearTimeout(timer)
131
+ timersRef.current = []
132
+ observer.disconnect()
133
+ }
134
+ }, [])
135
+
136
+ // External selection changes only reposition the pill (no burst); clicks
137
+ // animate through burstRef above.
138
+ useEffect(() => {
139
+ const nav = navRef.current
140
+ const text = textRef.current
141
+ const activeLi = nav?.querySelectorAll('li')[activeIndex]
142
+ if (activeLi) {
143
+ updateEffectPosition(activeLi)
144
+ text?.classList.add(css.active)
145
+ }
146
+ }, [activeIndex])
147
+
148
+ return (
149
+ <div className={css.container} ref={containerRef}>
150
+ <svg className={css.gooSvg} aria-hidden="true" focusable="false">
151
+ <defs>
152
+ {/* Alpha-contrast goo: merges the pill and particles without
153
+ crushing their colors the way CSS contrast() would. */}
154
+ <filter id="dsh-gooey-nav-goo" x="-150%" y="-150%" width="400%" height="400%">
155
+ <feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" />
156
+ <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9" />
157
+ </filter>
158
+ </defs>
159
+ </svg>
160
+ <nav aria-label={ariaLabel} role="tablist">
161
+ <ul ref={navRef}>
162
+ {items.map((item, index) => (
163
+ <li key={item.key} className={activeIndex === index ? css.active : undefined}>
164
+ <button
165
+ type="button"
166
+ role="tab"
167
+ aria-selected={activeIndex === index}
168
+ onClick={event => {
169
+ if (index === activeIndex) return
170
+ const li = event.currentTarget.closest('li')
171
+ if (li !== null) burstRef.current(li)
172
+ onSelect(index)
173
+ }}
174
+ >
175
+ {item.label}
176
+ </button>
177
+ </li>
178
+ ))}
179
+ </ul>
180
+ </nav>
181
+ <span className={`${css.effect} ${css.effectFilter}`} ref={filterRef} />
182
+ <span className={`${css.effect} ${css.effectText}`} ref={textRef} />
183
+ </div>
184
+ )
185
+ }
@@ -17,6 +17,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
17
17
  import type { ImageGenApi } from './api.ts'
18
18
  import { errorMessage, tt } from './helpers.ts'
19
19
  import { TemplateLibrary } from './TemplateLibrary.tsx'
20
+ import { GooeyNav } from './GooeyNav.tsx'
20
21
  import { InspirationGallery } from './InspirationGallery.tsx'
21
22
  import { CanvasWorkspace } from './CanvasWorkspace.tsx'
22
23
  import { useImageGenLanguageTick } from './use-language.ts'
@@ -353,7 +354,7 @@ function formatTime(timestamp: number): string {
353
354
  function defaultEcommerceDraft(): ProductSetDraft {
354
355
  return {
355
356
  projectId: '', projectName: '', category: '通用商品', platform: '通用', language: '中文', customLanguage: '', size: '1:1',
356
- productName: '', sellingPoints: '', protectedFeatures: '', styleHint: '',
357
+ productName: '', promptInfo: '',
357
358
  slots: PRODUCT_SET_SLOTS.map(slot => ({ ...slot })),
358
359
  }
359
360
  }
@@ -377,13 +378,12 @@ function effectiveEcommerceLanguage(draft: ProductSetDraft): string {
377
378
  }
378
379
 
379
380
  function ecommercePrompt(draft: ProductSetDraft, slot: ProductSetSlot): string {
380
- const points = draft.sellingPoints.trim() || '突出商品真实材质、结构和核心价值'
381
- const protectedFeatures = draft.protectedFeatures.trim() || '保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
381
+ const info = draft.promptInfo.trim() || '突出商品真实材质、结构和核心价值;保持商品颜色、形状、Logo、包装文字和结构真实,不添加不存在的配件'
382
382
  const language = effectiveEcommerceLanguage(draft) || '中文'
383
383
  const refClause = slot.refRole !== undefined && slot.refRole !== 'none'
384
384
  ? `本图以上传的${ECOMMERCE_ROLE_PROMPT_LABELS[slot.refRole]}图片为参考,商品与风格必须与参考图保持一致;`
385
385
  : ''
386
- return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${language}。商品卖点:${points}。必须遵守:${protectedFeatures}。${refClause}整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布;${draft.styleHint.trim()}`
386
+ return `电商${slot.label}:为${draft.productName.trim() || '该商品'}制作${slot.description}。商品品类:${draft.category};平台:${draft.platform};语言:${language}。${refClause}商品信息与要求:${info}。整体要求:商品主体清晰、比例真实、光线自然、画面干净、适合电商发布。`
387
387
  }
388
388
 
389
389
  /** Consistency prefix for slots generated after the main image exists. */
@@ -561,6 +561,14 @@ export function ImageGenPanel(props: {
561
561
  if (Array.isArray(merged.slots)) {
562
562
  merged.slots = merged.slots.map(slot => ({ ...slot, refRole: slot.refRole ?? 'product' }))
563
563
  }
564
+ // Drafts from the three-field era (卖点/保护要素/风格) fold into 参数信息.
565
+ merged.promptInfo = typeof merged.promptInfo === 'string' ? merged.promptInfo : ''
566
+ if (merged.promptInfo.trim() === '') {
567
+ const legacy = [merged.sellingPoints ?? '', merged.protectedFeatures ?? '', merged.styleHint ?? '']
568
+ .map(part => part.trim())
569
+ .filter(part => part !== '')
570
+ if (legacy.length > 0) merged.promptInfo = legacy.join('\n')
571
+ }
564
572
  return merged
565
573
  }
566
574
  } catch { /* ignore malformed or unavailable storage */ }
@@ -568,6 +576,7 @@ export function ImageGenPanel(props: {
568
576
  })
569
577
  const [ecommercePreview, setEcommercePreview] = useState(false)
570
578
  const [ecommerceGenerating, setEcommerceGenerating] = useState(false)
579
+ const [ecommerceEnhancing, setEcommerceEnhancing] = useState(false)
571
580
  const [ecommerceProjectId, setEcommerceProjectId] = useState<string | null>(null)
572
581
  const [ecommerceAssets, setEcommerceAssets] = useState<ProductAsset[]>([])
573
582
  /** History-restored product set currently shown in the results canvas. */
@@ -827,6 +836,34 @@ export function ImageGenPanel(props: {
827
836
  }
828
837
  }
829
838
 
839
+ /** AI 帮写:整理/补全电商参数信息(提示词),走提示词增强通道。 */
840
+ const ecommerceEnhanceInfo = async (): Promise<void> => {
841
+ if (ecommerceEnhancing) return
842
+ const promptEndpointConfigured = (config?.promptApiUrl ?? '').trim() !== '' || configured
843
+ if ((config?.promptModel ?? '').trim() === '' || !promptEndpointConfigured || (!promptKeySet && !apiKeySet)) {
844
+ openSettingsGuide('enhancement')
845
+ return
846
+ }
847
+ setEcommerceEnhancing(true)
848
+ setError(null)
849
+ try {
850
+ const instruction = [
851
+ '你是电商图文策划。请把下面的商品信息整理成一段可直接用于 AI 生图提示词的中文参数描述(120 字以内),',
852
+ '涵盖商品主体、规格材质、核心卖点与必须保留的特征;信息缺失处按商品类目合理补全,不要解释,只输出整理结果。',
853
+ `商品名称:${ecommerce.productName.trim() || '未提供'}`,
854
+ `商品类目:${ecommerce.category}`,
855
+ `商品平台:${ecommerce.platform}`,
856
+ `已有信息:${ecommerce.promptInfo.trim() !== '' ? ecommerce.promptInfo : '无,请根据商品名称与类目合理补全'}`,
857
+ ].join('\n')
858
+ const result = await api.enhancePrompt(instruction)
859
+ setEcommerce(previous => ({ ...previous, promptInfo: result }))
860
+ } catch (caught) {
861
+ setError(errorMessage(caught))
862
+ } finally {
863
+ setEcommerceEnhancing(false)
864
+ }
865
+ }
866
+
830
867
  /** Read an uploaded reference image into a data URL. */
831
868
  const acceptFile = (file: File | undefined): void => {
832
869
  if (file === undefined) return
@@ -846,9 +883,10 @@ export function ImageGenPanel(props: {
846
883
  reader.readAsDataURL(file)
847
884
  }
848
885
 
849
- /** Read uploaded product assets into session-only data-URL chips, capped at
850
- * MAX_ECOMMERCE_ASSETS. Each starts as the product-role reference. */
851
- const acceptEcommerceFiles = (files: FileList | undefined): void => {
886
+ /** Read uploaded product assets into session-only data-URL chips. Product
887
+ * refs cap at MAX_ECOMMERCE_ASSETS; the style group holds a single image
888
+ * that gets replaced on re-upload. */
889
+ const acceptEcommerceFiles = (files: FileList | undefined, role: 'product' | 'style' = 'product'): void => {
852
890
  if (files === undefined) return
853
891
  const incoming = Array.from(files).filter(file => file.type.startsWith('image/') && file.size <= REF_IMAGE_MAX_BYTES)
854
892
  if (incoming.length === 0) {
@@ -861,7 +899,11 @@ export function ImageGenPanel(props: {
861
899
  if (typeof reader.result !== 'string') return
862
900
  const dataUrl = reader.result
863
901
  setEcommerceAssets(previous => {
864
- if (previous.length >= MAX_ECOMMERCE_ASSETS) {
902
+ if (role === 'style') {
903
+ const styleAsset = { id: newComparisonId(), dataUrl, name: file.name, role: 'style' as const }
904
+ return [...previous.filter(item => item.role !== 'style'), styleAsset]
905
+ }
906
+ if (previous.filter(item => item.role !== 'style').length >= MAX_ECOMMERCE_ASSETS) {
865
907
  setError(tt('ecommerce.assetsFull'))
866
908
  return previous
867
909
  }
@@ -1059,9 +1101,7 @@ export function ImageGenPanel(props: {
1059
1101
  platform: ecommerce.platform,
1060
1102
  language: ecommerce.language,
1061
1103
  size: ecommerce.size,
1062
- sellingPoints: ecommerce.sellingPoints,
1063
- protectedFeatures: ecommerce.protectedFeatures,
1064
- styleHint: ecommerce.styleHint,
1104
+ promptInfo: ecommerce.promptInfo,
1065
1105
  },
1066
1106
  generatedAt: new Date().toISOString(),
1067
1107
  images: ecommerceMergedItems.map(item => ({
@@ -1494,6 +1534,8 @@ export function ImageGenPanel(props: {
1494
1534
  const ecommerceTotal = ecommerceSlots.reduce((total, slot) => total + slot.count, 0)
1495
1535
  const ecommerceGenerateDisabled = submitting || ecommerceGenerating || ecommerceSlots.length === 0 || ecommerce.productName.trim() === '' || (ecommerce.language === 'custom' && effectiveEcommerceLanguage(ecommerce) === '')
1496
1536
  const ecommerceFileInput = useRef<HTMLInputElement>(null)
1537
+ /** Which group (主图/风格) the shared file input uploads into. */
1538
+ const ecommerceUploadRoleRef = useRef<'product' | 'style'>('product')
1497
1539
  // The results canvas merges live tasks of the active project with restored
1498
1540
  // history entries of the same project; restored slots that were regenerated
1499
1541
  // this session are covered by their live counterparts (same slotKey).
@@ -1522,6 +1564,49 @@ export function ImageGenPanel(props: {
1522
1564
  const ecommerceResultGroups = [...new Set(ecommerceMergedItems.map(item => item.label))]
1523
1565
  .filter(label => label !== '')
1524
1566
  .map(label => ({ label, items: ecommerceMergedItems.filter(item => item.label === label) }))
1567
+ // 套图结果左右布局:主图组独占左侧,其余分组在右侧纵排。live 任务的
1568
+ // slotKey 带序号后缀(main-1),这里按 main 前缀识别主图组。
1569
+ const ecommerceMainGroup = ecommerceResultGroups.find(group => group.items.some(item => item.slotKey === 'main' || item.slotKey.startsWith('main-'))) ?? null
1570
+ const ecommerceSideGroups = ecommerceResultGroups.filter(group => group !== ecommerceMainGroup)
1571
+ const renderEcommerceGroup = (group: { label: string, items: EcommerceResultItem[] }, main = false): React.JSX.Element => (
1572
+ <section key={group.label} className={css.ecommerceGroup} data-ecommerce-group={group.label} data-main={main ? '' : undefined}>
1573
+ <header>
1574
+ <strong>{group.label}</strong>
1575
+ <span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
1576
+ <button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
1577
+ </header>
1578
+ <div className={css.ecommerceGroupGrid}>
1579
+ {group.items.map(item => (
1580
+ <div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
1581
+ {item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
1582
+ <figure
1583
+ key={imageIndex}
1584
+ className={css.imageCard}
1585
+ role="button"
1586
+ tabIndex={0}
1587
+ title={tt('preview.open')}
1588
+ onClick={() => { openPreview(item.images, imageIndex) }}
1589
+ >
1590
+ <img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
1591
+ <span className={css.ecommerceResultBadge}>{group.label}</span>
1592
+ <span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
1593
+ <a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
1594
+ <button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
1595
+ <button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
1596
+ </span>
1597
+ </figure>
1598
+ )) : (
1599
+ <span className={css.ecommerceTaskState}>
1600
+ <b>{group.label}</b>
1601
+ {tt(`tasks.${item.status}` as never)}
1602
+ {item.error !== undefined ? ` · ${item.error}` : ''}
1603
+ </span>
1604
+ )}
1605
+ </div>
1606
+ ))}
1607
+ </div>
1608
+ </section>
1609
+ )
1525
1610
  const conversationBusy = addingToConversation !== null || galleryConversationAddingId !== null || historyConversationAddingId !== null
1526
1611
  const viewingEntry = viewingHistoryId === null ? null : history.find(entry => entry.id === viewingHistoryId) ?? null
1527
1612
  const viewingGalleryEntry = galleryViewingId === null ? null : gallery.find(entry => entry.id === galleryViewingId) ?? null
@@ -1752,13 +1837,22 @@ export function ImageGenPanel(props: {
1752
1837
  <svg viewBox="0 0 16 16" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1753
1838
  </a>
1754
1839
  </span>
1755
- <nav className={css.topNav} role="tablist" aria-label={tt('workspace.label')}>
1756
- <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab !== 'gallery' ? '' : undefined} onClick={() => { if (workspace !== 'normal' || tab === 'gallery') openTab('text') }}>{tt('workspace.normal')}</button>
1757
- <button type="button" className={css.topNavItem} data-active={workspace === 'normal' && tab === 'gallery' ? '' : undefined} onClick={() => { openTab('gallery') }}>{tt('gallery.title')}</button>
1758
- <span className={css.topNavDivider} aria-hidden="true" />
1759
- <button type="button" className={css.topNavItem} data-active={workspace === 'canvas' ? '' : undefined} onClick={() => { setWorkspace('canvas') }}>{tt('workspace.canvas')}</button>
1760
- <button type="button" className={css.topNavItem} data-active={workspace === 'ecommerce' ? '' : undefined} onClick={() => { setWorkspace('ecommerce') }}>{tt('workspace.ecommerce')}<span className={css.previewBadge}>{tt('ecommerce.badge')}</span></button>
1761
- </nav>
1840
+ <GooeyNav
1841
+ ariaLabel={tt('workspace.label')}
1842
+ activeIndex={workspace === 'normal' ? (tab === 'gallery' ? 1 : 0) : workspace === 'canvas' ? 2 : 3}
1843
+ onSelect={index => {
1844
+ if (index === 0) openTab('text')
1845
+ else if (index === 1) openTab('gallery')
1846
+ else if (index === 2) setWorkspace('canvas')
1847
+ else setWorkspace('ecommerce')
1848
+ }}
1849
+ items={[
1850
+ { key: 'normal', label: tt('workspace.normal') },
1851
+ { key: 'gallery', label: tt('gallery.title') },
1852
+ { key: 'canvas', label: tt('workspace.canvas') },
1853
+ { key: 'ecommerce', label: <>{tt('workspace.ecommerce')}<span className={css.previewBadge}>{tt('ecommerce.badge')}</span></> },
1854
+ ]}
1855
+ />
1762
1856
  <span className={css.panelHeaderActions}>
1763
1857
  <button
1764
1858
  type="button"
@@ -1883,30 +1977,13 @@ export function ImageGenPanel(props: {
1883
1977
  {workspace === 'ecommerce' ? (
1884
1978
  <section className={css.ecommerceWorkspace} data-ecommerce-workspace="">
1885
1979
  <div className={css.ecommerceSection}>
1886
- <h3>{tt('ecommerce.product')}</h3>
1887
- <label className={css.ecommerceField}>
1888
- <span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
1889
- <input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
1890
- </label>
1891
- {ecommerceAssets.length === 0 ? (
1892
- <button
1893
- type="button"
1894
- className={css.ecommerceUploadHero}
1895
- data-ecommerce-upload=""
1896
- onClick={() => { ecommerceFileInput.current?.click() }}
1897
- onDragOver={(event) => { event.preventDefault() }}
1898
- onDrop={(event) => {
1899
- event.preventDefault()
1900
- acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
1901
- }}
1902
- >
1903
- <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
1904
- <span>{tt('ecommerce.uploadRef')}</span>
1905
- <small>{tt('edit.uploadHint')}</small>
1906
- </button>
1907
- ) : (
1980
+ <header className={css.ecommerceCardHead}>
1981
+ <h3>{tt('ecommerce.productRefTitle')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.productRefHint')}</small></h3>
1982
+ <span className={css.ecommerceCardCount}>{ecommerceAssets.filter(asset => asset.role !== 'style').length}/{MAX_ECOMMERCE_ASSETS}</span>
1983
+ </header>
1984
+ {ecommerceAssets.some(asset => asset.role !== 'style') ? (
1908
1985
  <div className={css.ecommerceAssets}>
1909
- {ecommerceAssets.map(asset => (
1986
+ {ecommerceAssets.filter(asset => asset.role !== 'style').map(asset => (
1910
1987
  <div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
1911
1988
  <img src={asset.dataUrl} alt={asset.name} />
1912
1989
  <select
@@ -1915,24 +1992,24 @@ export function ImageGenPanel(props: {
1915
1992
  aria-label={tt('ecommerce.refSelect')}
1916
1993
  onChange={event => setEcommerceAssets(previous => previous.map(item => item.id === asset.id ? { ...item, role: event.target.value as EcommerceAssetRole } : item))}
1917
1994
  >
1918
- {ECOMMERCE_ASSET_ROLES.map(role => (
1995
+ {ECOMMERCE_ASSET_ROLES.filter(role => role !== 'style').map(role => (
1919
1996
  <option key={role} value={role}>{tt(`ecommerce.role.${role}` as never)}</option>
1920
1997
  ))}
1921
1998
  </select>
1922
1999
  <button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
1923
2000
  </div>
1924
2001
  ))}
1925
- {ecommerceAssets.length < MAX_ECOMMERCE_ASSETS ? (
2002
+ {ecommerceAssets.filter(asset => asset.role !== 'style').length < MAX_ECOMMERCE_ASSETS ? (
1926
2003
  <button
1927
2004
  type="button"
1928
2005
  className={css.ecommerceAssetAdd}
1929
2006
  data-ecommerce-upload=""
1930
2007
  title={tt('ecommerce.uploadRef')}
1931
- onClick={() => { ecommerceFileInput.current?.click() }}
2008
+ onClick={() => { ecommerceUploadRoleRef.current = 'product'; ecommerceFileInput.current?.click() }}
1932
2009
  onDragOver={(event) => { event.preventDefault() }}
1933
2010
  onDrop={(event) => {
1934
2011
  event.preventDefault()
1935
- acceptEcommerceFiles(event.dataTransfer.files ?? undefined)
2012
+ acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'product')
1936
2013
  }}
1937
2014
  >
1938
2015
  <span aria-hidden="true">+</span>
@@ -1940,8 +2017,79 @@ export function ImageGenPanel(props: {
1940
2017
  </button>
1941
2018
  ) : null}
1942
2019
  </div>
2020
+ ) : (
2021
+ <button
2022
+ type="button"
2023
+ className={css.ecommerceUploadHero}
2024
+ data-ecommerce-upload=""
2025
+ onClick={() => { ecommerceUploadRoleRef.current = 'product'; ecommerceFileInput.current?.click() }}
2026
+ onDragOver={(event) => { event.preventDefault() }}
2027
+ onDrop={(event) => {
2028
+ event.preventDefault()
2029
+ acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'product')
2030
+ }}
2031
+ >
2032
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 10V3.5"/><path d="M5.5 5.5L8 3l2.5 2.5"/><path d="M3 9.5V12a1.5 1.5 0 001.5 1.5h7A1.5 1.5 0 0013 12V9.5"/></svg>
2033
+ <span>{tt('ecommerce.uploadRef')}</span>
2034
+ <small>{tt('edit.uploadHint')}</small>
2035
+ </button>
2036
+ )}
2037
+ </div>
2038
+ <div className={css.ecommerceSection}>
2039
+ <header className={css.ecommerceCardHead}>
2040
+ <h3>{tt('ecommerce.styleRefTitle')}<small className={css.ecommerceSectionHint}>({tt('ecommerce.styleRefBadge')})</small></h3>
2041
+ <span className={css.ecommerceCardCount}>{ecommerceAssets.some(asset => asset.role === 'style') ? 1 : 0}/1</span>
2042
+ </header>
2043
+ <p className={css.ecommerceCardHint}>{tt('ecommerce.styleRefHint')}</p>
2044
+ {ecommerceAssets.some(asset => asset.role === 'style') ? (
2045
+ <div className={css.ecommerceAssets}>
2046
+ {ecommerceAssets.filter(asset => asset.role === 'style').map(asset => (
2047
+ <div key={asset.id} className={css.ecommerceAsset} data-ecommerce-asset="">
2048
+ <img src={asset.dataUrl} alt={asset.name} />
2049
+ <button type="button" aria-label={tt('edit.remove')} onClick={() => { setEcommerceAssets(previous => previous.filter(item => item.id !== asset.id)) }}>×</button>
2050
+ </div>
2051
+ ))}
2052
+ </div>
2053
+ ) : (
2054
+ <button
2055
+ type="button"
2056
+ className={css.ecommerceAssetAdd}
2057
+ data-ecommerce-upload=""
2058
+ title={tt('ecommerce.styleRefUpload')}
2059
+ onClick={() => { ecommerceUploadRoleRef.current = 'style'; ecommerceFileInput.current?.click() }}
2060
+ onDragOver={(event) => { event.preventDefault() }}
2061
+ onDrop={(event) => {
2062
+ event.preventDefault()
2063
+ acceptEcommerceFiles(event.dataTransfer.files ?? undefined, 'style')
2064
+ }}
2065
+ >
2066
+ <span aria-hidden="true">+</span>
2067
+ <small>{tt('ecommerce.styleRefUpload')}</small>
2068
+ </button>
1943
2069
  )}
1944
2070
  </div>
2071
+ <div className={css.ecommerceSection}>
2072
+ <label className={css.ecommerceField}>
2073
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.productName')}</span>
2074
+ <input value={ecommerce.productName} placeholder={tt('ecommerce.productName')} onChange={event => setEcommerce(previous => ({ ...previous, productName: event.target.value }))} />
2075
+ </label>
2076
+ </div>
2077
+ <div className={css.ecommerceSection}>
2078
+ <header className={css.ecommerceCardHead}>
2079
+ <h3>{tt('ecommerce.refInfoTitle')}<small className={css.ecommerceSectionHint}>({tt('ecommerce.optional')})</small></h3>
2080
+ <button
2081
+ type="button"
2082
+ className={css.ecommerceAiButton}
2083
+ disabled={ecommerceEnhancing}
2084
+ title={tt('ecommerce.aiWriteHint')}
2085
+ onClick={() => { void ecommerceEnhanceInfo() }}
2086
+ >
2087
+ <svg viewBox="0 0 16 16" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M8 1.5l1.4 3.6L13 6.5l-3.6 1.4L8 11.5 6.6 7.9 3 6.5l3.6-1.4z"/></svg>
2088
+ {ecommerceEnhancing ? tt('ecommerce.aiWriting') : tt('ecommerce.aiWrite')}
2089
+ </button>
2090
+ </header>
2091
+ <textarea value={ecommerce.promptInfo} placeholder={tt('ecommerce.promptInfoPlaceholder')} onChange={event => setEcommerce(previous => ({ ...previous, promptInfo: event.target.value }))} />
2092
+ </div>
1945
2093
  <div className={css.ecommerceSection}>
1946
2094
  <h3>{tt('ecommerce.params')}</h3>
1947
2095
  <div className={css.ecommerceParamGrid}>
@@ -1964,20 +2112,16 @@ export function ImageGenPanel(props: {
1964
2112
  />
1965
2113
  ) : null}
1966
2114
  </label>
1967
- <label className={css.ecommerceField}>
1968
- <span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
1969
- <select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
1970
- </label>
1971
2115
  <label className={css.ecommerceField}>
1972
2116
  <span className={css.ecommerceFieldLabel}>{tt('ecommerce.categoryLabel')}</span>
1973
2117
  <select value={ecommerce.category} onChange={event => setEcommerce(previous => ({ ...previous, category: event.target.value }))}><option>通用商品</option><option>食品饮料</option><option>美妆个护</option><option>服装配饰</option><option>家居用品</option><option>3C 数码</option></select>
1974
2118
  </label>
2119
+ <label className={css.ecommerceField}>
2120
+ <span className={css.ecommerceFieldLabel}>{tt('ecommerce.ratioLabel')}</span>
2121
+ <select value={ecommerce.size} onChange={event => setEcommerce(previous => ({ ...previous, size: event.target.value }))}>{SIZES.filter(size => size !== 'auto').map(size => <option key={size}>{size}</option>)}</select>
2122
+ </label>
1975
2123
  </div>
1976
2124
  </div>
1977
- <div className={css.ecommerceSection}>
1978
- <h3>{tt('ecommerce.sellingTitle')}</h3>
1979
- <textarea value={ecommerce.sellingPoints} placeholder={tt('ecommerce.sellingPoints')} onChange={event => setEcommerce(previous => ({ ...previous, sellingPoints: event.target.value }))} />
1980
- </div>
1981
2125
  <div className={css.ecommerceSection}>
1982
2126
  <h3>{tt('ecommerce.setStructure')}<small className={css.ecommerceSectionHint}>{tt('ecommerce.multiSelect')}</small></h3>
1983
2127
  <div className={css.ecommerceStructureGrid}>
@@ -2033,12 +2177,6 @@ export function ImageGenPanel(props: {
2033
2177
  <select value={modeModels.includes(model) ? model : modeModels[0] ?? ''} aria-label={tt('model.label')} onChange={event => setModel(event.target.value)}>{modeModels.map(option => <option key={option} value={option}>{option}</option>)}</select>
2034
2178
  <div className={css.optionRow}>{QUALITIES.map(option => <Pill key={option} active={quality === option} onClick={() => { setQuality(option) }} className={css.optionPill}>{tt(`quality.${option}` as const)}</Pill>)}</div>
2035
2179
  </div>
2036
- <div className={css.ecommerceSection}>
2037
- <h3>{tt('ecommerce.styleTitle')}</h3>
2038
- <textarea value={ecommerce.styleHint} placeholder={tt('ecommerce.styleHint')} onChange={event => setEcommerce(previous => ({ ...previous, styleHint: event.target.value }))} />
2039
- <span className={css.ecommerceFieldLabel}>{tt('ecommerce.protectedLabel')}</span>
2040
- <textarea value={ecommerce.protectedFeatures} placeholder={tt('ecommerce.protectedFeatures')} onChange={event => setEcommerce(previous => ({ ...previous, protectedFeatures: event.target.value }))} />
2041
- </div>
2042
2180
  <input
2043
2181
  ref={ecommerceFileInput}
2044
2182
  type="file"
@@ -2046,7 +2184,7 @@ export function ImageGenPanel(props: {
2046
2184
  accept="image/png,image/jpeg,image/webp,image/gif"
2047
2185
  className={css.hiddenFile}
2048
2186
  onChange={(event) => {
2049
- acceptEcommerceFiles(event.target.files ?? undefined)
2187
+ acceptEcommerceFiles(event.target.files ?? undefined, ecommerceUploadRoleRef.current)
2050
2188
  event.target.value = ''
2051
2189
  }}
2052
2190
  />
@@ -2456,46 +2594,11 @@ export function ImageGenPanel(props: {
2456
2594
  {ecommerceMergedItems.length === 0 ? (
2457
2595
  <div className={css.ecommerceResultsEmpty}>{tt('ecommerce.results.empty')}</div>
2458
2596
  ) : (
2459
- <div className={css.ecommerceGroups}>
2460
- {ecommerceResultGroups.map(group => (
2461
- <section key={group.label} className={css.ecommerceGroup} data-ecommerce-group={group.label}>
2462
- <header>
2463
- <strong>{group.label}</strong>
2464
- <span>{group.items.filter(item => item.status === 'completed').length}/{group.items.length}</span>
2465
- <button type="button" className={css.galleryBulkButton} disabled={ecommerceGenerating} onClick={() => { void regenerateEcommerceSlot(group.label) }}>{tt('ecommerce.results.regenerate')}</button>
2466
- </header>
2467
- <div className={css.ecommerceGroupGrid}>
2468
- {group.items.map(item => (
2469
- <div key={item.id} className={css.ecommerceTaskCard} data-status={item.status}>
2470
- {item.status === 'completed' && item.images.length > 0 ? item.images.map((image, imageIndex) => (
2471
- <figure
2472
- key={imageIndex}
2473
- className={css.imageCard}
2474
- role="button"
2475
- tabIndex={0}
2476
- title={tt('preview.open')}
2477
- onClick={() => { openPreview(item.images, imageIndex) }}
2478
- >
2479
- <img className={css.image} src={srcOf(image)} alt={`${group.label} ${imageIndex + 1}`} />
2480
- <span className={css.ecommerceResultBadge}>{group.label}</span>
2481
- <span className={css.ecommerceTaskActions} onClick={event => event.stopPropagation()}>
2482
- <a className={css.ecommerceActionChip} href={srcOf(image)} download={`product-${item.slotKey || item.id}-${imageIndex + 1}.${extensionOf(image.mime)}`}>{tt('download')}</a>
2483
- <button type="button" className={css.ecommerceActionChip} disabled={galleryAdding} onClick={() => { void addToGallery(image) }}>{tt('gallery.add')}</button>
2484
- <button type="button" className={css.ecommerceActionChip} disabled={conversationBusy} onClick={() => { void addImageToConversation(image, imageIndex, `${item.id}:${imageIndex}`) }}>{addingToConversation === `${item.id}:${imageIndex}` ? tt('conversation.adding') : tt('conversation.add')}</button>
2485
- </span>
2486
- </figure>
2487
- )) : (
2488
- <span className={css.ecommerceTaskState}>
2489
- <b>{group.label}</b>
2490
- {tt(`tasks.${item.status}` as never)}
2491
- {item.error !== undefined ? ` · ${item.error}` : ''}
2492
- </span>
2493
- )}
2494
- </div>
2495
- ))}
2496
- </div>
2497
- </section>
2498
- ))}
2597
+ <div className={css.ecommerceGroups} data-split={ecommerceMainGroup !== null ? 'true' : undefined}>
2598
+ {ecommerceMainGroup !== null ? renderEcommerceGroup(ecommerceMainGroup, true) : null}
2599
+ <div className={css.ecommerceGroupsSide}>
2600
+ {ecommerceSideGroups.map(group => renderEcommerceGroup(group))}
2601
+ </div>
2499
2602
  </div>
2500
2603
  )}
2501
2604
  </div>