@dsh-plus/image-studio 0.1.35 → 0.1.36

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.
package/lib/index.d.ts CHANGED
@@ -306,8 +306,8 @@ declare class ImageStudioService extends Service {
306
306
  //#region src/credentials.d.ts
307
307
  /**
308
308
  * 凭据引用名派生与校验:提供商预设 id → IMAGE_STUDIO_PRESET_<ID>。
309
- * 规则对齐《插件存储规范》:大写下划线、全大写、前缀属主。
310
- * 纯函数,零依赖。
309
+ * 命名与归一走 `@dsh-plus/shared` 的公共助手,本模块只补预设 id 的形态校验。
310
+ * 纯函数,零依赖(浏览器半亦可安全 import)。
311
311
  * @module image-studio/credentials
312
312
  */
313
313
  /** 预设 id 是否合法(kebab-case,用于引用名派生)。 */
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Service } from "@deepseek-ai/cordis";
2
2
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
3
- import { atomicWriteFile, commitTmpFile, pluginDataPath, unwrapVolatile } from "@dsh-plus/shared";
3
+ import { atomicWriteFile, commitTmpFile, credentialRefName, pluginCachePath, pluginDataPath, unwrapVolatile } from "@dsh-plus/shared";
4
4
  import { request } from "node:http";
5
5
  import { request as request$1 } from "node:https";
6
6
  import { HttpsProxyAgent } from "https-proxy-agent";
@@ -959,12 +959,14 @@ function registerImageStudioApi(ctx, service) {
959
959
  //#region src/credentials.ts
960
960
  /**
961
961
  * 凭据引用名派生与校验:提供商预设 id → IMAGE_STUDIO_PRESET_<ID>。
962
- * 规则对齐《插件存储规范》:大写下划线、全大写、前缀属主。
963
- * 纯函数,零依赖。
962
+ * 命名与归一走 `@dsh-plus/shared` 的公共助手,本模块只补预设 id 的形态校验。
963
+ * 纯函数,零依赖(浏览器半亦可安全 import)。
964
964
  * @module image-studio/credentials
965
965
  */
966
- /** 引用名前缀(属主段)。 */
967
- const REF_PREFIX = "IMAGE_STUDIO_PRESET_";
966
+ /** 属主段(插件 id)。 */
967
+ const REF_OWNER = "image-studio";
968
+ /** 业务段前缀(与属主一起构成 IMAGE_STUDIO_PRESET_<ID>)。 */
969
+ const REF_KIND = "preset";
968
970
  /** 预设 id 是否合法(kebab-case,用于引用名派生)。 */
969
971
  function isValidPresetId(id) {
970
972
  return /^[a-z][a-z0-9-]*$/.test(id) && id.length <= 64;
@@ -972,7 +974,7 @@ function isValidPresetId(id) {
972
974
  /** 预设 id → 凭据引用名(kebab → SNAKE 大写)。 */
973
975
  function credentialRefNameOf(presetId) {
974
976
  if (!isValidPresetId(presetId)) throw new Error(`非法提供商预设 id:${JSON.stringify(presetId)}`);
975
- return `${REF_PREFIX}${presetId.replaceAll("-", "_").toUpperCase()}`;
977
+ return credentialRefName(REF_OWNER, REF_KIND, presetId);
976
978
  }
977
979
  //#endregion
978
980
  //#region src/images/id.ts
@@ -1243,9 +1245,12 @@ function defaultIdCounter() {
1243
1245
  * 落点 $DSH_HOME/dsh-plus/image-studio/uploads/{index.jsonl, blobs/}
1244
1246
  * @module image-studio/uploads/store
1245
1247
  */
1246
- /** 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。 */
1248
+ /**
1249
+ * 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。
1250
+ * 上传是带 TTL 的暂存派生物(`uploadTtlHours` 回收),故落缓存目录。
1251
+ */
1247
1252
  function uploadsRoot() {
1248
- return pluginDataPath("image-studio", "uploads");
1253
+ return pluginCachePath("image-studio", "uploads");
1249
1254
  }
1250
1255
  function indexFile() {
1251
1256
  return join(uploadsRoot(), "index.jsonl");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/image-studio",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "dsh-plus service+ui plugin: 图像工作室——OpenAI Images 协议文生图/图生图,全参数开关化,双预设体系,并发生图任务与可二次编辑画廊",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "https-proxy-agent": "^9.1.0",
23
- "@dsh-plus/shared": "0.1.43"
23
+ "@dsh-plus/shared": "0.1.44"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@deepseek-ai/cordis": "^4.0.5",
@@ -10,13 +10,16 @@
10
10
  */
11
11
  import {
12
12
  type CardAction,
13
- CardChrome,
14
13
  CardLoading,
15
14
  type CardStatusState,
15
+ ConfigShell,
16
+ FieldGrid,
16
17
  IDLE_STATUS,
17
18
  type NamespaceSettingsApi,
19
+ NumberField,
18
20
  type PluginConfigViewProps,
19
21
  type Scope,
22
+ Section,
20
23
  TextField,
21
24
  } from '@dsh-plus/shared/client'
22
25
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
@@ -91,29 +94,24 @@ function AdvancedFields(props: {
91
94
  }): ReactElement {
92
95
  const { t, draft, disabled, onPatch } = props
93
96
  return (
94
- <>
95
- <h4 className="imsc-groupTitle">{t('card.advanced')}</h4>
96
- <div className="imsc-grid2">
97
- <TextField
97
+ <Section prefix="imsc" title={t('card.advanced')} description={t('card.advancedHint')}>
98
+ <FieldGrid prefix="imsc" columns={2}>
99
+ <NumberField
98
100
  prefix="imsc"
99
101
  id="imsc-conc"
100
102
  label={t('card.maxConcurrent')}
101
- numeric
102
103
  value={draft.maxConcurrent}
103
104
  disabled={disabled}
104
105
  onEdit={(v) => onPatch({ maxConcurrent: v })}
105
106
  />
106
- <TextField
107
+ <NumberField
107
108
  prefix="imsc"
108
109
  id="imsc-timeout"
109
110
  label={t('card.requestTimeoutMs')}
110
- numeric
111
111
  value={draft.requestTimeoutMs}
112
112
  disabled={disabled}
113
113
  onEdit={(v) => onPatch({ requestTimeoutMs: v })}
114
114
  />
115
- </div>
116
- <div className="imsc-grid2">
117
115
  <TextField
118
116
  prefix="imsc"
119
117
  id="imsc-proxy"
@@ -122,26 +120,24 @@ function AdvancedFields(props: {
122
120
  disabled={disabled}
123
121
  onEdit={(v) => onPatch({ proxy: v })}
124
122
  />
125
- <TextField
123
+ <NumberField
126
124
  prefix="imsc"
127
125
  id="imsc-gmax"
128
126
  label={t('card.galleryMaxItems')}
129
- numeric
130
127
  value={draft.galleryMaxItems}
131
128
  disabled={disabled}
132
129
  onEdit={(v) => onPatch({ galleryMaxItems: v })}
133
130
  />
134
- </div>
135
- <TextField
136
- prefix="imsc"
137
- id="imsc-upload-ttl"
138
- label={t('card.uploadTtlHours')}
139
- numeric
140
- value={draft.uploadTtlHours}
141
- disabled={disabled}
142
- onEdit={(v) => onPatch({ uploadTtlHours: v })}
143
- />
144
- </>
131
+ <NumberField
132
+ prefix="imsc"
133
+ id="imsc-upload-ttl"
134
+ label={t('card.uploadTtlHours')}
135
+ value={draft.uploadTtlHours}
136
+ disabled={disabled}
137
+ onEdit={(v) => onPatch({ uploadTtlHours: v })}
138
+ />
139
+ </FieldGrid>
140
+ </Section>
145
141
  )
146
142
  }
147
143
 
@@ -226,7 +222,7 @@ export function StudioConfigCard(props: CardProps): ReactElement | string | null
226
222
  }
227
223
 
228
224
  return (
229
- <CardChrome
225
+ <ConfigShell
230
226
  prefix="imsc"
231
227
  title={t('card.title')}
232
228
  description={t('card.description')}
@@ -259,6 +255,6 @@ export function StudioConfigCard(props: CardProps): ReactElement | string | null
259
255
  <PromptPresetSection {...sectionProps} />
260
256
  <ParamPresetSection {...sectionProps} />
261
257
  <AdvancedFields t={t} draft={draft} disabled={disabled} onPatch={patch} />
262
- </CardChrome>
258
+ </ConfigShell>
263
259
  )
264
260
  }
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * i18n 字典(zh 为键域事实源,en 全量对译);NS 与 settings 命名空间分离,
3
3
  * 仅承载前端文案 key。面板 / 入口 / 配置卡片共用同一命名空间。
4
+ *
5
+ * 公共键(卡片套件的 save/unsaved/code* 等)来自 shared 的 common 字典并在此合并,
6
+ * 本文件只维护业务键。
4
7
  * @module image-studio/client/i18n
5
8
  */
9
+ import { commonEn, commonZh, mergeDict } from '@dsh-plus/shared/client'
6
10
 
7
11
  /** locale 命名空间。 */
8
12
  export const NS = 'dsh-plus-image-studio'
9
13
 
10
- export const zh = {
14
+ const ownZh = {
11
15
  // 入口与面板骨架
12
16
  'entry.label': '图像',
13
17
  'panel.title': '图像工作室',
@@ -98,9 +102,16 @@ export const zh = {
98
102
  'card.description': '文生图/图生图:提供商与 API Key、参数/提示词预设',
99
103
  'card.summary': '文生图/图生图:提供商与 API Key、参数/提示词预设。',
100
104
  'card.providers': '提供商预设',
105
+ 'card.providersHint':
106
+ '按序尝试的提供商预设:条目内 API Key 走官方 credentials(引用名由预设 ID 派生),保存后不回显。',
101
107
  'card.prompts': '提示词预设',
108
+ 'card.promptsHint': '提示词模板列表:生成/编辑面板按名称选用,{prompt} 等占位符由面板注入。',
102
109
  'card.params': '参数预设',
110
+ 'card.paramsHint':
111
+ '参数预设列表:适用端点决定出现在「文生图」还是「图生图」,参数表为 JSON(如 {"size": {"enabled": true, "value": "1024x1024"}})。',
103
112
  'card.advanced': '高级设置',
113
+ 'card.advancedHint':
114
+ '并发、超时、代理与本地画廊保留策略;留空/0 表示沿用内置默认(并发 2、超时 60000ms、画廊 200 张、上传保留 168 小时)。',
104
115
  'card.add': '添加',
105
116
  'card.field.id': 'ID',
106
117
  'card.field.name': '名称',
@@ -134,12 +145,10 @@ export const zh = {
134
145
  'card.badgeOff': '未配置',
135
146
  }
136
147
 
137
- export type DictKey = keyof typeof zh
138
-
139
148
  /** 面板/卡片共用的翻译函数类型(slot 注入的 bind 结果按此消费)。 */
140
149
  export type Translate = (key: DictKey) => string
141
150
 
142
- export const en: Record<DictKey, string> = {
151
+ const ownEn: Record<keyof typeof ownZh, string> = {
143
152
  'entry.label': 'Image',
144
153
  'panel.title': 'Image Studio',
145
154
  'tab.generation': 'Text to Image',
@@ -222,9 +231,17 @@ export const en: Record<DictKey, string> = {
222
231
  'card.description': 'Text/Image-to-image: providers & API keys, param/prompt presets',
223
232
  'card.summary': 'Text/Image-to-image: providers & API keys, param/prompt presets.',
224
233
  'card.providers': 'Provider presets',
234
+ 'card.providersHint':
235
+ 'Providers tried in order; each entry keeps its API key in official credentials (reference derived from the preset id) and never echoes it back.',
225
236
  'card.prompts': 'Prompt presets',
237
+ 'card.promptsHint':
238
+ 'Prompt templates; the generation/edit panels pick them by name and fill placeholders such as {prompt}.',
226
239
  'card.params': 'Parameter presets',
240
+ 'card.paramsHint':
241
+ 'Parameter presets; the endpoint decides whether it shows up in Text to Image or Image to Image, and the spec is JSON such as {"size": {"enabled": true, "value": "1024x1024"}}.',
227
242
  'card.advanced': 'Advanced',
243
+ 'card.advancedHint':
244
+ 'Concurrency, timeout, proxy and local gallery retention; empty/0 keeps the built-in defaults (concurrency 2, timeout 60000ms, gallery 200, uploads 168h).',
228
245
  'card.add': 'Add',
229
246
  'card.field.id': 'ID',
230
247
  'card.field.name': 'Name',
@@ -258,3 +275,9 @@ export const en: Record<DictKey, string> = {
258
275
  'card.badgeOn': '{n} providers',
259
276
  'card.badgeOff': 'Not configured',
260
277
  }
278
+
279
+ /** 卡片公共文案 + 本插件文案(own 覆盖同键):配置卡片经共享套件渲染,公共键只维护一份。 */
280
+ export const zh = mergeDict(commonZh, ownZh)
281
+ export const en: Record<keyof typeof zh, string> = mergeDict(commonEn, ownEn)
282
+
283
+ export type DictKey = keyof typeof zh
@@ -1,9 +1,20 @@
1
1
  /**
2
2
  * 配置卡片的三组预设编辑区:提供商(含逐条 API Key 管理)、提示词、参数。
3
3
  * 从 card.tsx 拆出,避免单组件过长;草稿类型与纯转换在 ./draft.ts。
4
+ *
5
+ * 排版与控件一律走 @dsh-plus/shared/client 套件(Section / ListEditor / 字段控件),
6
+ * 与其它插件卡片保持一致:条目头提供折叠/上移/下移/删除,新增固定在列表底部。
4
7
  * @module image-studio/client/preset-sections
5
8
  */
6
- import { TextField } from '@dsh-plus/shared/client'
9
+ import {
10
+ Button,
11
+ CodeField,
12
+ ListEditor,
13
+ type ListLabels,
14
+ Section,
15
+ SelectField,
16
+ TextField,
17
+ } from '@dsh-plus/shared/client'
7
18
  import { type ReactElement, useEffect, useState } from 'react'
8
19
 
9
20
  import { isValidPresetId } from '../credentials.ts'
@@ -68,22 +79,18 @@ function CredentialRow(props: { t: Translate; presetId: string; disabled: boolea
68
79
  {t(configured ? 'card.credential.configured' : 'card.credential.unconfigured')}
69
80
  </span>
70
81
  ) : null}
71
- <button
72
- type="button"
73
- className="imsc-btn imsc-btnGhost"
82
+ <Button
83
+ prefix="imsc"
84
+ label={t('card.credential.set')}
74
85
  disabled={disabled || busy || value === '' || !isValidPresetId(presetId)}
75
86
  onClick={() => act(() => setCredential(presetId, value))}
76
- >
77
- {t('card.credential.set')}
78
- </button>
79
- <button
80
- type="button"
81
- className="imsc-btn imsc-btnGhost"
87
+ />
88
+ <Button
89
+ prefix="imsc"
90
+ label={t('card.credential.unset')}
82
91
  disabled={disabled || busy || configured !== true || !isValidPresetId(presetId)}
83
92
  onClick={() => act(() => unsetCredential(presetId))}
84
- >
85
- {t('card.credential.unset')}
86
- </button>
93
+ />
87
94
  </div>
88
95
  )
89
96
  }
@@ -100,26 +107,34 @@ export interface PresetSectionsProps {
100
107
  onPatchPrompt(index: number, patch: Partial<PromptDraft>): void
101
108
  }
102
109
 
110
+ /** 列表编辑器文案(公共字典键,各插件一致)。 */
111
+ function listLabels(t: Translate): ListLabels {
112
+ return {
113
+ add: t('listAdd'),
114
+ remove: t('listRemove'),
115
+ up: t('listMoveUp'),
116
+ down: t('listMoveDown'),
117
+ duplicate: t('listDuplicate'),
118
+ collapse: t('collapse'),
119
+ expand: t('expand'),
120
+ empty: t('listEmpty'),
121
+ }
122
+ }
123
+
103
124
  export function ProviderPresetSection(props: PresetSectionsProps): ReactElement {
104
125
  const { t, draft, disabled, protocols, onPatch, onPatchProvider } = props
105
126
  return (
106
- <>
107
- <h4 className="imsc-groupTitle">{t('card.providers')}</h4>
108
- {draft.providers.map((provider, index) => (
109
- // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id(id 字段本身可编辑),行序即身份
110
- <div className="imsc-presetBox" key={`provider-${index}`}>
111
- <div className="imsc-presetHead">
112
- <span className="imsc-presetName">{provider.name || provider.id || '—'}</span>
113
- <button
114
- type="button"
115
- className="imsc-btn imsc-btnGhost"
116
- disabled={disabled}
117
- onClick={() => onPatch({ providers: draft.providers.filter((_, i) => i !== index) })}
118
- >
119
- {t('common.delete')}
120
- </button>
121
- </div>
122
- <div className="imsc-grid2">
127
+ <Section prefix="imsc" title={t('card.providers')} description={t('card.providersHint')}>
128
+ <ListEditor
129
+ prefix="imsc"
130
+ items={draft.providers}
131
+ labels={listLabels(t)}
132
+ disabled={disabled}
133
+ titleOf={(provider) => provider.name || provider.id || '—'}
134
+ create={() => emptyProviderDraft(protocols[0]?.id ?? 'openai-images')}
135
+ onChange={(providers) => onPatch({ providers })}
136
+ renderItem={(provider, index) => (
137
+ <>
123
138
  <TextField
124
139
  prefix="imsc"
125
140
  id={`imsc-pid-${index}`}
@@ -139,31 +154,19 @@ export function ProviderPresetSection(props: PresetSectionsProps): ReactElement
139
154
  disabled={disabled}
140
155
  onEdit={(v) => onPatchProvider(index, { name: v })}
141
156
  />
142
- </div>
143
- <div className="imsc-grid2">
144
- <div className="imsc-field">
145
- <div className="imsc-head">
146
- <label className="imsc-label" htmlFor={`imsc-pproto-${index}`}>
147
- {t('card.field.protocol')}
148
- </label>
149
- </div>
150
- <select
151
- id={`imsc-pproto-${index}`}
152
- className="imsc-select"
153
- value={provider.protocol}
154
- disabled={disabled}
155
- onChange={(event) => onPatchProvider(index, { protocol: event.target.value })}
156
- >
157
- {(protocols.length > 0
158
- ? protocols
159
- : [{ id: provider.protocol, label: provider.protocol }]
160
- ).map((protocol) => (
161
- <option key={protocol.id} value={protocol.id}>
162
- {protocol.label}
163
- </option>
164
- ))}
165
- </select>
166
- </div>
157
+ <SelectField
158
+ prefix="imsc"
159
+ id={`imsc-pproto-${index}`}
160
+ label={t('card.field.protocol')}
161
+ value={provider.protocol}
162
+ options={
163
+ protocols.length > 0
164
+ ? protocols.map((protocol) => ({ value: protocol.id, label: protocol.label }))
165
+ : [{ value: provider.protocol, label: provider.protocol }]
166
+ }
167
+ disabled={disabled}
168
+ onEdit={(v) => onPatchProvider(index, { protocol: v })}
169
+ />
167
170
  <TextField
168
171
  prefix="imsc"
169
172
  id={`imsc-pmodel-${index}`}
@@ -172,150 +175,113 @@ export function ProviderPresetSection(props: PresetSectionsProps): ReactElement
172
175
  disabled={disabled}
173
176
  onEdit={(v) => onPatchProvider(index, { model: v })}
174
177
  />
175
- </div>
176
- <TextField
177
- prefix="imsc"
178
- id={`imsc-purl-${index}`}
179
- label={t('card.field.baseUrl')}
180
- value={provider.baseUrl}
181
- disabled={disabled}
182
- onEdit={(v) => onPatchProvider(index, { baseUrl: v })}
183
- />
184
- <div className="imsc-field">
185
- <div className="imsc-head">
186
- <label className="imsc-label" htmlFor={`imsc-pheaders-${index}`}>
187
- {t('card.field.extraHeaders')}
188
- </label>
189
- </div>
190
- <textarea
178
+ <TextField
179
+ prefix="imsc"
180
+ id={`imsc-purl-${index}`}
181
+ label={t('card.field.baseUrl')}
182
+ value={provider.baseUrl}
183
+ disabled={disabled}
184
+ onEdit={(v) => onPatchProvider(index, { baseUrl: v })}
185
+ />
186
+ <CodeField
187
+ prefix="imsc"
191
188
  id={`imsc-pheaders-${index}`}
192
- className="imsc-textarea"
189
+ label={t('card.field.extraHeaders')}
193
190
  value={provider.extraHeadersText}
194
191
  disabled={disabled}
195
- onChange={(event) => onPatchProvider(index, { extraHeadersText: event.target.value })}
192
+ onEdit={(v) => onPatchProvider(index, { extraHeadersText: v })}
196
193
  />
197
- </div>
198
- <CredentialRow t={t} presetId={provider.id} disabled={disabled} />
199
- </div>
200
- ))}
201
- <button
202
- type="button"
203
- className="imsc-btn imsc-btnGhost"
204
- disabled={disabled}
205
- onClick={() =>
206
- onPatch({
207
- providers: [
208
- ...draft.providers,
209
- emptyProviderDraft(protocols[0]?.id ?? 'openai-images'),
210
- ],
211
- })
212
- }
213
- >
214
- {t('card.add')} · {t('card.providers')}
215
- </button>
216
- </>
194
+ <CredentialRow t={t} presetId={provider.id} disabled={disabled} />
195
+ </>
196
+ )}
197
+ />
198
+ </Section>
217
199
  )
218
200
  }
219
201
 
220
202
  export function PromptPresetSection(props: PresetSectionsProps): ReactElement {
221
203
  const { t, draft, disabled, onPatch, onPatchPrompt } = props
222
204
  return (
223
- <>
224
- <h4 className="imsc-groupTitle">{t('card.prompts')}</h4>
225
- {draft.prompts.map((prompt, index) => (
226
- // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
227
- <div className="imsc-presetBox" key={`prompt-${index}`}>
228
- <div className="imsc-presetHead">
229
- <input
230
- className="imsc-input"
231
- aria-label={t('card.field.name')}
205
+ <Section prefix="imsc" title={t('card.prompts')} description={t('card.promptsHint')}>
206
+ <ListEditor
207
+ prefix="imsc"
208
+ items={draft.prompts}
209
+ labels={listLabels(t)}
210
+ disabled={disabled}
211
+ titleOf={(prompt) => prompt.name || '—'}
212
+ create={() => emptyPromptDraft()}
213
+ onChange={(prompts) => onPatch({ prompts })}
214
+ renderItem={(prompt, index) => (
215
+ <>
216
+ <TextField
217
+ prefix="imsc"
218
+ id={`imsc-prname-${index}`}
219
+ label={t('card.field.name')}
232
220
  value={prompt.name}
233
221
  disabled={disabled}
234
- onChange={(event) => onPatchPrompt(index, { name: event.target.value })}
222
+ onEdit={(v) => onPatchPrompt(index, { name: v })}
235
223
  />
236
- <button
237
- type="button"
238
- className="imsc-btn imsc-btnGhost"
224
+ <CodeField
225
+ prefix="imsc"
226
+ id={`imsc-prtext-${index}`}
227
+ label={t('card.field.text')}
228
+ value={prompt.text}
239
229
  disabled={disabled}
240
- onClick={() => onPatch({ prompts: draft.prompts.filter((_, i) => i !== index) })}
241
- >
242
- {t('common.delete')}
243
- </button>
244
- </div>
245
- <textarea
246
- className="imsc-textarea"
247
- aria-label={t('card.field.text')}
248
- value={prompt.text}
249
- disabled={disabled}
250
- onChange={(event) => onPatchPrompt(index, { text: event.target.value })}
251
- />
252
- </div>
253
- ))}
254
- <button
255
- type="button"
256
- className="imsc-btn imsc-btnGhost"
257
- disabled={disabled}
258
- onClick={() => onPatch({ prompts: [...draft.prompts, emptyPromptDraft()] })}
259
- >
260
- {t('card.add')} · {t('card.prompts')}
261
- </button>
262
- </>
230
+ onEdit={(v) => onPatchPrompt(index, { text: v })}
231
+ />
232
+ </>
233
+ )}
234
+ />
235
+ </Section>
263
236
  )
264
237
  }
265
238
 
266
239
  export function ParamPresetSection(props: PresetSectionsProps): ReactElement {
267
240
  const { t, draft, disabled, onPatch, onPatchParam } = props
241
+ const endpoints = [
242
+ { value: 'generation', label: t('tab.generation') },
243
+ { value: 'edit', label: t('tab.edit') },
244
+ ]
268
245
  return (
269
- <>
270
- <h4 className="imsc-groupTitle">{t('card.params')}</h4>
271
- {draft.params.map((param, index) => (
272
- // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
273
- <div className="imsc-presetBox" key={`param-${index}`}>
274
- <div className="imsc-presetHead">
275
- <input
276
- className="imsc-input"
277
- aria-label={t('card.field.name')}
246
+ <Section prefix="imsc" title={t('card.params')} description={t('card.paramsHint')}>
247
+ <ListEditor
248
+ prefix="imsc"
249
+ items={draft.params}
250
+ labels={listLabels(t)}
251
+ disabled={disabled}
252
+ titleOf={(param) => param.name || '—'}
253
+ create={() => emptyParamDraft()}
254
+ onChange={(params) => onPatch({ params })}
255
+ renderItem={(param, index) => (
256
+ <>
257
+ <TextField
258
+ prefix="imsc"
259
+ id={`imsc-paname-${index}`}
260
+ label={t('card.field.name')}
278
261
  value={param.name}
279
262
  disabled={disabled}
280
- onChange={(event) => onPatchParam(index, { name: event.target.value })}
263
+ onEdit={(v) => onPatchParam(index, { name: v })}
281
264
  />
282
- <select
283
- className="imsc-select"
284
- aria-label={t('card.field.endpoint')}
265
+ <SelectField
266
+ prefix="imsc"
267
+ id={`imsc-paendpoint-${index}`}
268
+ label={t('card.field.endpoint')}
285
269
  value={param.endpoint}
270
+ options={endpoints}
286
271
  disabled={disabled}
287
- onChange={(event) => onPatchParam(index, { endpoint: event.target.value })}
288
- >
289
- <option value="generation">{t('tab.generation')}</option>
290
- <option value="edit">{t('tab.edit')}</option>
291
- </select>
292
- <button
293
- type="button"
294
- className="imsc-btn imsc-btnGhost"
272
+ onEdit={(v) => onPatchParam(index, { endpoint: v })}
273
+ />
274
+ <CodeField
275
+ prefix="imsc"
276
+ id={`imsc-paspecs-${index}`}
277
+ label={t('card.field.paramSpecs')}
278
+ value={param.specsText}
295
279
  disabled={disabled}
296
- onClick={() => onPatch({ params: draft.params.filter((_, i) => i !== index) })}
297
- >
298
- {t('common.delete')}
299
- </button>
300
- </div>
301
- <textarea
302
- className="imsc-textarea"
303
- aria-label={t('card.field.paramSpecs')}
304
- placeholder='{ "size": { "enabled": true, "value": "1024x1024" } }'
305
- value={param.specsText}
306
- disabled={disabled}
307
- onChange={(event) => onPatchParam(index, { specsText: event.target.value })}
308
- />
309
- </div>
310
- ))}
311
- <button
312
- type="button"
313
- className="imsc-btn imsc-btnGhost"
314
- disabled={disabled}
315
- onClick={() => onPatch({ params: [...draft.params, emptyParamDraft()] })}
316
- >
317
- {t('card.add')} · {t('card.params')}
318
- </button>
319
- </>
280
+ onEdit={(v) => onPatchParam(index, { specsText: v })}
281
+ />
282
+ </>
283
+ )}
284
+ />
285
+ </Section>
320
286
  )
321
287
  }