@dsh-plus/image-studio 0.1.34 → 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.
@@ -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
  }
@@ -559,29 +559,6 @@ export const imageStudioCss = `
559
559
 
560
560
  /* ── 配置卡片附加件(imsc- 基础类由 cardCss('imsc') 生成)── */
561
561
  .imsc-loading{padding:14px 16px;margin:0;font-size:13px;color:var(--dsw-alias-label-tertiary)}
562
- .imsc-groupTitle { margin: 14px 0 6px; font-size: 13px; font-weight: 600; }
563
- .imsc-presetBox {
564
- border-top: 1px solid var(--dsw-alias-border-l3);
565
- padding: 10px 0 2px;
566
- display: grid;
567
- gap: 8px;
568
- }
569
- .imsc-presetHead { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
570
- .imsc-presetName { font-size: 13px; font-weight: 600; }
571
- .imsc-grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
572
- .imsc-textarea {
573
- box-sizing: border-box;
574
- width: 100%;
575
- min-height: 64px;
576
- padding: 6px 10px;
577
- border: 1px solid var(--dsw-alias-border-l2);
578
- border-radius: 8px;
579
- background: var(--dsw-alias-bg-layer-3);
580
- color: var(--dsw-alias-label-primary);
581
- font-family: var(--ds-font-family-code);
582
- font-size: 12px;
583
- resize: vertical;
584
- }
585
562
  .imsc-credRow { display: flex; align-items: center; gap: 8px; }
586
563
  .imsc-credRow .imsc-input { flex: 1; min-width: 0; }
587
564
 
@@ -617,7 +594,6 @@ export const imageStudioCss = `
617
594
  .ims-paramControl { width: 108px; }
618
595
  .ims-btn { min-height: 40px; }
619
596
  .ims-btnSmall { min-height: 32px; }
620
- .imsc-grid2 { grid-template-columns: 1fr; }
621
597
  }
622
598
  `
623
599
 
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * 凭据引用名派生与校验:提供商预设 id → IMAGE_STUDIO_PRESET_<ID>。
3
- * 规则对齐《插件存储规范》:大写下划线、全大写、前缀属主。
4
- * 纯函数,零依赖。
3
+ * 命名与归一走 `@dsh-plus/shared` 的公共助手,本模块只补预设 id 的形态校验。
4
+ * 纯函数,零依赖(浏览器半亦可安全 import)。
5
5
  * @module image-studio/credentials
6
6
  */
7
7
 
8
- /** 引用名前缀(属主段)。 */
9
- const REF_PREFIX = 'IMAGE_STUDIO_PRESET_'
8
+ import { credentialRefName } from '@dsh-plus/shared'
9
+
10
+ /** 属主段(插件 id)。 */
11
+ const REF_OWNER = 'image-studio'
12
+ /** 业务段前缀(与属主一起构成 IMAGE_STUDIO_PRESET_<ID>)。 */
13
+ const REF_KIND = 'preset'
10
14
 
11
15
  /** 预设 id 是否合法(kebab-case,用于引用名派生)。 */
12
16
  export function isValidPresetId(id: string): boolean {
@@ -18,5 +22,5 @@ export function credentialRefNameOf(presetId: string): string {
18
22
  if (!isValidPresetId(presetId)) {
19
23
  throw new Error(`非法提供商预设 id:${JSON.stringify(presetId)}`)
20
24
  }
21
- return `${REF_PREFIX}${presetId.replaceAll('-', '_').toUpperCase()}`
25
+ return credentialRefName(REF_OWNER, REF_KIND, presetId)
22
26
  }
@@ -11,7 +11,7 @@ import { randomUUID } from 'node:crypto'
11
11
  import { appendFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
12
12
  import { join } from 'node:path'
13
13
 
14
- import { atomicWriteFile, commitTmpFile, pluginDataPath } from '@dsh-plus/shared'
14
+ import { atomicWriteFile, commitTmpFile, pluginCachePath } from '@dsh-plus/shared'
15
15
  import { isImageId } from '../images/id.ts'
16
16
  import { extOfMime } from '../images/mime.ts'
17
17
 
@@ -27,9 +27,12 @@ export interface UploadEntry {
27
27
  size: number
28
28
  }
29
29
 
30
- /** 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。 */
30
+ /**
31
+ * 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试环境设置)。
32
+ * 上传是带 TTL 的暂存派生物(`uploadTtlHours` 回收),故落缓存目录。
33
+ */
31
34
  function uploadsRoot(): string {
32
- return pluginDataPath('image-studio', 'uploads')
35
+ return pluginCachePath('image-studio', 'uploads')
33
36
  }
34
37
 
35
38
  function indexFile(): string {