@dsh-plus/image-studio 0.1.0 → 0.1.2

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,219 @@
1
+ /**
2
+ * 图像工作室面板根组件:侧栏 footer 入口打开的 overlay 工作台。
3
+ * 三个标签(文生图/图生图/画廊)+ 底部任务条;两个生图表单常驻挂载
4
+ * (hidden 切换,保留各自表单态)。数据归口:providers 一次性加载,
5
+ * presets/gallery/tasks 面板打开时加载,任务 2s 轮询并侦测成功转移
6
+ * 自动刷画廊。
7
+ * 响应式:桌面居中模态(≤1100px),≤767px 全屏抽屉(见 styles.ts)。
8
+ * @module image-studio/client/panel/panel
9
+ */
10
+ import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import type { NamespaceSettingsApi, Scope } from '@dsh-plus/shared/client'
12
+ import { IconBrush, IconCloseOutline16, IconImage, IconSparkles } from '@dsh-plus/shared/client'
13
+ import {
14
+ type ReactElement,
15
+ useCallback,
16
+ useEffect,
17
+ useRef,
18
+ useState,
19
+ useSyncExternalStore,
20
+ } from 'react'
21
+ import type { PresetsWire, ProvidersWire, TaskWire } from '../../dto.ts'
22
+ import type { GalleryItem } from '../../gallery/store.ts'
23
+ import type { ParamEntry } from '../../params/catalog.ts'
24
+ import { cancelTask, fetchGallery, fetchPresets, fetchProviders, fetchTasks } from '../api.ts'
25
+ import type { Translate } from '../i18n.ts'
26
+ import type { PanelController, StudioTab } from './controller.ts'
27
+ import { GalleryView } from './gallery.tsx'
28
+ import { GeneratorForm } from './generator.tsx'
29
+ import { TasksBar } from './tasks.tsx'
30
+
31
+ interface StudioPanelProps {
32
+ studio: PanelController
33
+ t: Translate
34
+ scope: Scope
35
+ api: NamespaceSettingsApi
36
+ }
37
+
38
+ const EMPTY_PRESETS: PresetsWire = { promptPresets: [], paramPresets: [], providerPresets: [] }
39
+
40
+ export function StudioPanel(props: StudioPanelProps): ReactElement | null {
41
+ const { studio, t, scope, api } = props
42
+ const { open, tab, editSeed } = useSyncExternalStore(studio.subscribe, studio.getSnapshot)
43
+ const [providers, setProviders] = useState<ProvidersWire | null>(null)
44
+ const [presets, setPresets] = useState<PresetsWire>(EMPTY_PRESETS)
45
+ const [gallery, setGallery] = useState<GalleryItem[]>([])
46
+ const [galleryLoading, setGalleryLoading] = useState(false)
47
+ const [galleryFailed, setGalleryFailed] = useState(false)
48
+ const [tasks, setTasks] = useState<TaskWire[]>([])
49
+ const [detailId, setDetailId] = useState<string | null>(null)
50
+ /** 上一轮任务快照(成功转移侦测)。 */
51
+ const prevTasksRef = useRef<Map<string, TaskWire['state']>>(new Map())
52
+
53
+ const reloadGallery = useCallback((): void => {
54
+ setGalleryLoading(true)
55
+ fetchGallery()
56
+ .then((res) => {
57
+ setGallery(res.items)
58
+ setGalleryFailed(false)
59
+ })
60
+ .catch(() => setGalleryFailed(true))
61
+ .finally(() => setGalleryLoading(false))
62
+ }, [])
63
+
64
+ const reloadPresets = useCallback((): void => {
65
+ fetchPresets()
66
+ .then(setPresets)
67
+ .catch(() => {})
68
+ }, [])
69
+
70
+ const reloadTasks = useCallback((): void => {
71
+ fetchTasks()
72
+ .then((res) => setTasks(res.tasks))
73
+ .catch(() => {})
74
+ }, [])
75
+
76
+ // 打开时加载静态目录 + 三类数据;关闭时停止轮询(effect 清理)。
77
+ useEffect(() => {
78
+ if (!open) return
79
+ if (providers === null) {
80
+ fetchProviders()
81
+ .then(setProviders)
82
+ .catch(() => {})
83
+ }
84
+ reloadPresets()
85
+ reloadGallery()
86
+ reloadTasks()
87
+ const timer = setInterval(() => {
88
+ fetchTasks()
89
+ .then((res) => {
90
+ setTasks(res.tasks)
91
+ // 侦测新成功任务 → 刷画廊(含其它客户端/来源产生的成功)。
92
+ const prev = prevTasksRef.current
93
+ const next = new Map<string, TaskWire['state']>()
94
+ let succeeded = false
95
+ for (const task of res.tasks) {
96
+ next.set(task.id, task.state)
97
+ if (task.state === 'succeeded' && prev.get(task.id) !== 'succeeded') succeeded = true
98
+ }
99
+ prevTasksRef.current = next
100
+ if (succeeded) reloadGallery()
101
+ })
102
+ .catch(() => {})
103
+ }, 2000)
104
+ return () => clearInterval(timer)
105
+ }, [open, providers, reloadPresets, reloadGallery, reloadTasks])
106
+
107
+ // wire 的 params 与 ParamEntry 结构同源(kind 宽化为 string),边界处一次性窄化。
108
+ const catalog = (providers?.params ?? []) as unknown as readonly ParamEntry[]
109
+ const protocols = providers?.protocols ?? []
110
+
111
+ const tabs: Array<{ key: StudioTab; label: string; icon: ReactElement }> = [
112
+ { key: 'generation', label: t('tab.generation'), icon: <IconSparkles size={15} /> },
113
+ { key: 'edit', label: t('tab.edit'), icon: <IconBrush size={15} /> },
114
+ { key: 'gallery', label: t('tab.gallery'), icon: <IconImage size={15} /> },
115
+ ]
116
+
117
+ // 官方 Modal 承载:portal 到 body(z-index 1000,移动端层叠/开关与外壳一致),
118
+ // 遮罩点击与 Escape 关闭由 Modal 处理;headless 模式自绘头部标签条。
119
+ return (
120
+ <Modal
121
+ open={open}
122
+ onClose={() => studio.setOpen(false)}
123
+ title={t('panel.title')}
124
+ headless
125
+ className="ims-modal"
126
+ >
127
+ <div className="ims-panel">
128
+ <header className="ims-head">
129
+ <div className="ims-tabs" role="tablist" aria-label={t('panel.title')}>
130
+ {tabs.map((item) => (
131
+ <button
132
+ key={item.key}
133
+ type="button"
134
+ role="tab"
135
+ aria-selected={tab === item.key}
136
+ className={`ims-tab${tab === item.key ? ' ims-tabActive' : ''}`}
137
+ onClick={() => studio.setTab(item.key)}
138
+ >
139
+ {item.icon}
140
+ <span>{item.label}</span>
141
+ </button>
142
+ ))}
143
+ </div>
144
+ <button
145
+ type="button"
146
+ className="ims-iconBtn"
147
+ aria-label={t('common.close')}
148
+ onClick={() => studio.setOpen(false)}
149
+ >
150
+ <IconCloseOutline16 size={16} />
151
+ </button>
152
+ </header>
153
+
154
+ <div className="ims-body">
155
+ <GeneratorForm
156
+ t={t}
157
+ endpoint="generation"
158
+ catalog={catalog}
159
+ protocols={protocols}
160
+ presets={presets}
161
+ gallery={gallery}
162
+ scope={scope}
163
+ api={api}
164
+ editSeed={null}
165
+ onPresetSaved={reloadPresets}
166
+ onSubmitted={reloadTasks}
167
+ hidden={tab !== 'generation'}
168
+ />
169
+ <GeneratorForm
170
+ t={t}
171
+ endpoint="edit"
172
+ catalog={catalog}
173
+ protocols={protocols}
174
+ presets={presets}
175
+ gallery={gallery}
176
+ scope={scope}
177
+ api={api}
178
+ editSeed={editSeed}
179
+ onPresetSaved={reloadPresets}
180
+ onSubmitted={reloadTasks}
181
+ hidden={tab !== 'edit'}
182
+ />
183
+ <GalleryView
184
+ t={t}
185
+ items={gallery}
186
+ loading={galleryLoading}
187
+ failed={galleryFailed}
188
+ onRefresh={reloadGallery}
189
+ onEdit={(item) =>
190
+ studio.seedEdit({
191
+ sourceIds: [...item.imageIds],
192
+ prompt: item.prompt,
193
+ params: item.params,
194
+ providerPresetId: item.providerPresetId,
195
+ })
196
+ }
197
+ detailId={detailId}
198
+ onDetailChange={setDetailId}
199
+ hidden={tab !== 'gallery'}
200
+ />
201
+ </div>
202
+
203
+ <TasksBar
204
+ t={t}
205
+ tasks={tasks}
206
+ onCancel={(taskId) => {
207
+ cancelTask(taskId)
208
+ .then(reloadTasks)
209
+ .catch(() => {})
210
+ }}
211
+ onView={(galleryItemId) => {
212
+ studio.setTab('gallery')
213
+ setDetailId(galleryItemId)
214
+ }}
215
+ />
216
+ </div>
217
+ </Modal>
218
+ )
219
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 参数表单状态纯函数:目录(ParamEntry)驱动的表单状态 ⇄ ParamSpecMap。
3
+ * - 表单值统一字符串/布尔(输入控件友好),提交时按 kind 解析回数值;
4
+ * - 画廊条目 params 回放、参数预设应用、预设另存均经此模块换算;
5
+ * - 纯函数零 DOM 依赖,可 node 单测。
6
+ * @module image-studio/client/panel/param-state
7
+ */
8
+ import { isHostParam, type ParamEntry } from '../../params/catalog.ts'
9
+ import type { ParamSpecMap } from '../../params/spec.ts'
10
+ import type { ImageEndpoint } from '../../provider/types.ts'
11
+
12
+ /** 表单字段值(integer/enum/string 用字符串承载,boolean 用布尔)。 */
13
+ export type ParamFieldValue = string | boolean
14
+
15
+ /** 单参数表单态。 */
16
+ export interface ParamFieldState {
17
+ enabled: boolean
18
+ value: ParamFieldValue
19
+ }
20
+
21
+ /** 参数表单态(key → 字段态)。 */
22
+ export type ParamFormState = Record<string, ParamFieldState>
23
+
24
+ /** 各参数的顺手默认值(启用控件时的预填,非官方默认)。 */
25
+ const DEFAULT_VALUES: Record<string, ParamFieldValue> = {
26
+ n: '1',
27
+ size: '1024x1024',
28
+ quality: 'auto',
29
+ background: 'auto',
30
+ output_format: 'png',
31
+ output_compression: '100',
32
+ moderation: 'auto',
33
+ response_format: 'url',
34
+ stream: false,
35
+ partial_images: '0',
36
+ user: '',
37
+ input_fidelity: 'high',
38
+ style: 'vivid',
39
+ }
40
+
41
+ /** 目录条目 → 表单默认值(enum 取首值兜底)。 */
42
+ export function defaultValueFor(entry: ParamEntry): ParamFieldValue {
43
+ const known = DEFAULT_VALUES[entry.key]
44
+ if (known !== undefined) return known
45
+ if (entry.kind === 'enum') return entry.values?.[0] ?? ''
46
+ if (entry.kind === 'boolean') return false
47
+ return ''
48
+ }
49
+
50
+ /** 端点可用的目录条目(剔除 host 承载参数 model)。 */
51
+ export function paramEntriesFor(
52
+ catalog: readonly ParamEntry[],
53
+ endpoint: ImageEndpoint,
54
+ ): ParamEntry[] {
55
+ return catalog.filter((entry) => entry.applies.includes(endpoint) && !isHostParam(entry.key))
56
+ }
57
+
58
+ /** 空表单(全部关闭 + 默认值)。 */
59
+ export function emptyParamForm(
60
+ catalog: readonly ParamEntry[],
61
+ endpoint: ImageEndpoint,
62
+ ): ParamFormState {
63
+ const form: ParamFormState = {}
64
+ for (const entry of paramEntriesFor(catalog, endpoint)) {
65
+ form[entry.key] = { enabled: false, value: defaultValueFor(entry) }
66
+ }
67
+ return form
68
+ }
69
+
70
+ /** 表单值 ← 服务端参数值(按 kind 字符串化;未知键忽略)。 */
71
+ function fieldValueOf(entry: ParamEntry, value: unknown): ParamFieldValue {
72
+ if (entry.kind === 'boolean') return value === true
73
+ if (typeof value === 'number') return String(value)
74
+ if (typeof value === 'string') return value
75
+ return defaultValueFor(entry)
76
+ }
77
+
78
+ /** 套用参数表(预设应用/画廊回放共用):仅目录内且端点适用的键生效并启用。 */
79
+ export function applyParams(
80
+ catalog: readonly ParamEntry[],
81
+ endpoint: ImageEndpoint,
82
+ params: Record<string, unknown>,
83
+ ): ParamFormState {
84
+ const form = emptyParamForm(catalog, endpoint)
85
+ for (const entry of paramEntriesFor(catalog, endpoint)) {
86
+ const value = params[entry.key]
87
+ if (value === undefined) continue
88
+ form[entry.key] = { enabled: true, value: fieldValueOf(entry, value) }
89
+ }
90
+ return form
91
+ }
92
+
93
+ /** 参数预设(ParamSpecMap 形态)套用到表单:enabled=false 的键显式关闭。 */
94
+ export function applySpecs(
95
+ catalog: readonly ParamEntry[],
96
+ endpoint: ImageEndpoint,
97
+ specs: ParamSpecMap,
98
+ ): ParamFormState {
99
+ const form = emptyParamForm(catalog, endpoint)
100
+ for (const entry of paramEntriesFor(catalog, endpoint)) {
101
+ const spec = specs[entry.key]
102
+ if (spec === undefined) continue
103
+ form[entry.key] = {
104
+ enabled: spec.enabled,
105
+ value:
106
+ spec.enabled && spec.value !== undefined
107
+ ? fieldValueOf(entry, spec.value)
108
+ : defaultValueFor(entry),
109
+ }
110
+ }
111
+ return form
112
+ }
113
+
114
+ /**
115
+ * 表单 → ParamSpecMap(提交/另存预设共用):仅导出启用项,integer 按数值解析;
116
+ * 空字符串 integer 视为未填(导出 {enabled:true} 无 value,交由边界校验报错)。
117
+ */
118
+ export function formToSpecs(
119
+ catalog: readonly ParamEntry[],
120
+ endpoint: ImageEndpoint,
121
+ form: ParamFormState,
122
+ ): ParamSpecMap {
123
+ const specs: ParamSpecMap = {}
124
+ for (const entry of paramEntriesFor(catalog, endpoint)) {
125
+ const field = form[entry.key]
126
+ if (field === undefined || !field.enabled) continue
127
+ if (entry.kind === 'boolean') {
128
+ specs[entry.key] = { enabled: true, value: field.value === true }
129
+ continue
130
+ }
131
+ const text = String(field.value).trim()
132
+ if (entry.kind === 'integer') {
133
+ if (text === '') {
134
+ specs[entry.key] = { enabled: true }
135
+ continue
136
+ }
137
+ const parsed = Number(text)
138
+ specs[entry.key] = { enabled: true, value: Number.isNaN(parsed) ? text : parsed }
139
+ continue
140
+ }
141
+ specs[entry.key] = { enabled: true, value: text }
142
+ }
143
+ return specs
144
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * 参数表单:按参数目录动态渲染(kind → 控件形态,advanced 折叠)。
3
+ * - boolean 参数的开关即取值(启用 = true);
4
+ * - 其余参数:开关 + 值控件(enum 下拉 / integer 数字框 / string 文本框);
5
+ * - 每项附中文说明与代际标记(GPT Image / dall-e-3 / dall-e-2)。
6
+ * @module image-studio/client/panel/params-form
7
+ */
8
+ import type { ReactElement } from 'react'
9
+ import type { ParamEntry } from '../../params/catalog.ts'
10
+ import type { ImageEndpoint } from '../../provider/types.ts'
11
+ import type { Translate } from '../i18n.ts'
12
+ import { type ParamFormState, paramEntriesFor } from './param-state.ts'
13
+
14
+ interface ParamsFormProps {
15
+ t: Translate
16
+ catalog: readonly ParamEntry[]
17
+ endpoint: ImageEndpoint
18
+ form: ParamFormState
19
+ onChange(next: ParamFormState): void
20
+ }
21
+
22
+ /** 单参数行:开关 + 名称/说明 + 值控件。 */
23
+ function ParamRow(props: {
24
+ t: Translate
25
+ entry: ParamEntry
26
+ form: ParamFormState
27
+ onChange(next: ParamFormState): void
28
+ }): ReactElement {
29
+ const { t, entry, form, onChange } = props
30
+ const field = form[entry.key] ?? { enabled: false, value: '' }
31
+ const patch = (next: Partial<typeof field>): void =>
32
+ onChange({ ...form, [entry.key]: { ...field, ...next } })
33
+
34
+ const control = (() => {
35
+ if (entry.kind === 'boolean') return null
36
+ if (entry.kind === 'enum') {
37
+ return (
38
+ <select
39
+ className="ims-input ims-paramControl"
40
+ value={String(field.value)}
41
+ disabled={!field.enabled}
42
+ onChange={(event) => patch({ value: event.target.value })}
43
+ >
44
+ {(entry.values ?? []).map((value) => (
45
+ <option key={value} value={value}>
46
+ {value}
47
+ </option>
48
+ ))}
49
+ </select>
50
+ )
51
+ }
52
+ if (entry.kind === 'integer') {
53
+ return (
54
+ <input
55
+ className="ims-input ims-paramControl"
56
+ type="number"
57
+ inputMode="numeric"
58
+ min={entry.min}
59
+ max={entry.max}
60
+ value={String(field.value)}
61
+ disabled={!field.enabled}
62
+ onChange={(event) => patch({ value: event.target.value })}
63
+ />
64
+ )
65
+ }
66
+ return (
67
+ <input
68
+ className="ims-input ims-paramControl"
69
+ type="text"
70
+ value={String(field.value)}
71
+ disabled={!field.enabled}
72
+ onChange={(event) => patch({ value: event.target.value })}
73
+ />
74
+ )
75
+ })()
76
+
77
+ return (
78
+ <div className={`ims-paramRow${field.enabled ? ' ims-paramRowOn' : ''}`}>
79
+ <label className="ims-switch" title={t('params.enabled')}>
80
+ <input
81
+ type="checkbox"
82
+ role="switch"
83
+ aria-label={`${entry.key} ${t('params.enabled')}`}
84
+ aria-checked={field.enabled}
85
+ checked={field.enabled}
86
+ onChange={(event) =>
87
+ entry.kind === 'boolean'
88
+ ? patch({ enabled: event.target.checked, value: event.target.checked })
89
+ : patch({ enabled: event.target.checked })
90
+ }
91
+ />
92
+ <span className="ims-switchTrack" aria-hidden="true" />
93
+ </label>
94
+ <div className="ims-paramText">
95
+ <span className="ims-paramKey">
96
+ {entry.key}
97
+ {entry.generation !== 'current' ? (
98
+ <span className="ims-paramGen">{entry.generation}</span>
99
+ ) : null}
100
+ </span>
101
+ <span className="ims-paramDesc">{entry.description}</span>
102
+ </div>
103
+ {control}
104
+ </div>
105
+ )
106
+ }
107
+
108
+ export function ParamsForm(props: ParamsFormProps): ReactElement {
109
+ const { t, catalog, endpoint, form, onChange } = props
110
+ const entries = paramEntriesFor(catalog, endpoint)
111
+ const basics = entries.filter((entry) => !entry.advanced)
112
+ const advanced = entries.filter((entry) => entry.advanced)
113
+ const advancedOn = advanced.filter((entry) => form[entry.key]?.enabled === true).length
114
+ return (
115
+ <div className="ims-params">
116
+ <div className="ims-paramGroup">
117
+ {basics.map((entry) => (
118
+ <ParamRow key={entry.key} t={t} entry={entry} form={form} onChange={onChange} />
119
+ ))}
120
+ </div>
121
+ {advanced.length > 0 ? (
122
+ <details className="ims-adv">
123
+ <summary className="ims-advSummary">
124
+ {t('params.advanced')}
125
+ {advancedOn > 0 ? <span className="ims-advCount">{advancedOn}</span> : null}
126
+ </summary>
127
+ <div className="ims-paramGroup">
128
+ {advanced.map((entry) => (
129
+ <ParamRow key={entry.key} t={t} entry={entry} form={form} onChange={onChange} />
130
+ ))}
131
+ </div>
132
+ </details>
133
+ ) : null}
134
+ </div>
135
+ )
136
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 画廊图片选择器(模态):源图多选(≤16)与遮罩单选共用。
3
+ * 扁平化全部画廊条目的图片,倒序排列;底部确认条显示已选数量。
4
+ * @module image-studio/client/panel/picker
5
+ */
6
+ import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
7
+ import { type ReactElement, useState } from 'react'
8
+ import type { GalleryItem } from '../../gallery/store.ts'
9
+ import { imageUrl } from '../api.ts'
10
+ import type { Translate } from '../i18n.ts'
11
+
12
+ /** 扁平化的可选图片(imageId + 归属条目提示词)。 */
13
+ interface PickableImage {
14
+ imageId: string
15
+ prompt: string
16
+ createdAt: string
17
+ }
18
+
19
+ interface ImagePickerProps {
20
+ t: Translate
21
+ title: string
22
+ items: GalleryItem[]
23
+ /** 多选(源图)或单选(遮罩)。 */
24
+ multiple: boolean
25
+ /** 初始已选(源图回填)。 */
26
+ initial: string[]
27
+ onConfirm(ids: string[]): void
28
+ onClose(): void
29
+ }
30
+
31
+ export function flattenImages(items: GalleryItem[]): PickableImage[] {
32
+ const out: PickableImage[] = []
33
+ for (const item of [...items].reverse()) {
34
+ for (const imageId of item.imageIds) {
35
+ out.push({ imageId, prompt: item.prompt, createdAt: item.createdAt })
36
+ }
37
+ }
38
+ return out
39
+ }
40
+
41
+ export function ImagePicker(props: ImagePickerProps): ReactElement {
42
+ const { t, title, items, multiple, initial, onConfirm, onClose } = props
43
+ const [selected, setSelected] = useState<string[]>(initial)
44
+ const images = flattenImages(items)
45
+
46
+ const toggle = (imageId: string): void => {
47
+ if (multiple) {
48
+ setSelected((current) =>
49
+ current.includes(imageId)
50
+ ? current.filter((id) => id !== imageId)
51
+ : current.length >= 16
52
+ ? current
53
+ : [...current, imageId],
54
+ )
55
+ return
56
+ }
57
+ setSelected((current) => (current.includes(imageId) ? [] : [imageId]))
58
+ }
59
+
60
+ // 官方 Modal 承载(嵌套 portal:层级高于工作台,遮罩/Escape 由 Modal 处理)。
61
+ return (
62
+ <Modal open onClose={onClose} title={title} headless className="ims-pickerModal">
63
+ <div className="ims-sub">
64
+ <div className="ims-subHead">
65
+ <h3 className="ims-subTitle">{title}</h3>
66
+ <button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
67
+ {t('common.close')}
68
+ </button>
69
+ </div>
70
+ {images.length === 0 ? (
71
+ <p className="ims-empty">{t('picker.empty')}</p>
72
+ ) : (
73
+ <div className="ims-pickerGrid" role="listbox" aria-multiselectable={multiple}>
74
+ {images.map((image) => {
75
+ const on = selected.includes(image.imageId)
76
+ return (
77
+ <button
78
+ key={image.imageId}
79
+ type="button"
80
+ role="option"
81
+ aria-selected={on}
82
+ className={`ims-pickerCell${on ? ' ims-pickerCellOn' : ''}`}
83
+ title={image.prompt}
84
+ onClick={() => toggle(image.imageId)}
85
+ >
86
+ <img src={imageUrl(image.imageId)} alt={image.prompt} loading="lazy" />
87
+ {on ? (
88
+ <span className="ims-pickerMark">{selected.indexOf(image.imageId) + 1}</span>
89
+ ) : null}
90
+ </button>
91
+ )
92
+ })}
93
+ </div>
94
+ )}
95
+ <div className="ims-subFoot">
96
+ <button
97
+ type="button"
98
+ className="ims-btn ims-btnPrimary"
99
+ disabled={selected.length === 0}
100
+ onClick={() => onConfirm(selected)}
101
+ >
102
+ {t('picker.confirm').replace('{n}', String(selected.length))}
103
+ </button>
104
+ <button type="button" className="ims-btn ims-btnGhost" onClick={onClose}>
105
+ {t('common.cancel')}
106
+ </button>
107
+ </div>
108
+ </div>
109
+ </Modal>
110
+ )
111
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * 任务条:面板底部的横向任务流(最新在左)。
3
+ * 活跃任务(排队/生成中)带旋转标与取消按钮;已完成可一键跳画廊详情。
4
+ * 轮询由面板根组件负责,本组件纯渲染。
5
+ * @module image-studio/client/panel/tasks
6
+ */
7
+ import { IconLoadingOutline16 } from '@dsh-plus/shared/client'
8
+ import type { ReactElement } from 'react'
9
+ import type { TaskWire } from '../../dto.ts'
10
+ import type { Translate } from '../i18n.ts'
11
+
12
+ interface TasksBarProps {
13
+ t: Translate
14
+ tasks: TaskWire[]
15
+ onCancel(taskId: string): void
16
+ onView(galleryItemId: string): void
17
+ }
18
+
19
+ const STATE_KEYS = {
20
+ queued: 'tasks.state.queued',
21
+ running: 'tasks.state.running',
22
+ succeeded: 'tasks.state.succeeded',
23
+ failed: 'tasks.state.failed',
24
+ cancelled: 'tasks.state.cancelled',
25
+ } as const
26
+
27
+ export function TasksBar(props: TasksBarProps): ReactElement | null {
28
+ const { t, tasks, onCancel, onView } = props
29
+ if (tasks.length === 0) return null
30
+ return (
31
+ <section className="ims-tasks" aria-label={t('tasks.title')}>
32
+ {tasks.slice(0, 20).map((task) => {
33
+ const active = task.state === 'queued' || task.state === 'running'
34
+ return (
35
+ <div key={task.id} className={`ims-task ims-task-${task.state}`}>
36
+ {active ? <IconLoadingOutline16 className="ims-spin" size={14} /> : null}
37
+ <span className={`ims-taskState ims-taskState-${task.state}`}>
38
+ {t(STATE_KEYS[task.state])}
39
+ </span>
40
+ <span className="ims-taskPrompt" title={task.promptPreview}>
41
+ {task.promptPreview}
42
+ </span>
43
+ <span className="ims-taskModel">{task.model}</span>
44
+ {active ? (
45
+ <button
46
+ type="button"
47
+ className="ims-btn ims-btnGhost ims-btnSmall"
48
+ onClick={() => onCancel(task.id)}
49
+ >
50
+ {t('common.cancel')}
51
+ </button>
52
+ ) : null}
53
+ {task.state === 'succeeded' && task.galleryItemId !== null ? (
54
+ <button
55
+ type="button"
56
+ className="ims-btn ims-btnGhost ims-btnSmall"
57
+ onClick={() => onView(task.galleryItemId as string)}
58
+ >
59
+ {t('tasks.view')}
60
+ </button>
61
+ ) : null}
62
+ {task.state === 'failed' && task.error !== null ? (
63
+ <span className="ims-taskError" title={task.error}>
64
+ {task.error}
65
+ </span>
66
+ ) : null}
67
+ </div>
68
+ )
69
+ })}
70
+ </section>
71
+ )
72
+ }