dsh-router-core 0.1.0
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/README.md +151 -0
- package/cordis.patch.yml +9 -0
- package/lib/client-registry.js +3006 -0
- package/lib/client.js +3006 -0
- package/lib/index.js +1756 -0
- package/lib/types/credential-store.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/keys.d.ts +39 -0
- package/lib/types/llm/adapter.d.ts +31 -0
- package/lib/types/router/index.d.ts +73 -0
- package/lib/types/router/types.d.ts +80 -0
- package/lib/types/shared.d.ts +138 -0
- package/lib/types/supplier-config.d.ts +28 -0
- package/lib/types/suppliers/contract.d.ts +98 -0
- package/lib/types/suppliers/loader.d.ts +41 -0
- package/lib/types/suppliers/registry.d.ts +33 -0
- package/package.json +86 -0
- package/src/client/CombosTab.tsx +458 -0
- package/src/client/EndpointTab.tsx +360 -0
- package/src/client/Modal.tsx +46 -0
- package/src/client/RouterView.tsx +186 -0
- package/src/client/SupplierDetail.tsx +943 -0
- package/src/client/index.tsx +18 -0
- package/src/client/router.css +2096 -0
- package/src/client/sidebar-entry.ts +167 -0
- package/src/client/workspace-controller.ts +26 -0
- package/src/client/workspace-mount.tsx +117 -0
- package/src/credential-store.ts +64 -0
- package/src/index.ts +468 -0
- package/src/keys.ts +143 -0
- package/src/llm/adapter.ts +273 -0
- package/src/router/index.ts +273 -0
- package/src/router/types.ts +85 -0
- package/src/shared.ts +152 -0
- package/src/supplier-config.ts +144 -0
- package/src/suppliers/contract.ts +73 -0
- package/src/suppliers/loader.ts +191 -0
- package/src/suppliers/nvidia/plugin.ts +268 -0
- package/src/suppliers/opencode/plugin.ts +200 -0
- package/src/suppliers/openrouter/plugin.ts +282 -0
- package/src/suppliers/registry.ts +365 -0
- package/src/types.d.ts +45 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组合 — 布局交互贴近 9router combos 页。
|
|
3
|
+
* 组合 = 命名的一组模型(完整名 alias/id),请求 model 传组合名时,
|
|
4
|
+
* 按策略(回退/轮询)命中其中一个。可创建/编辑/删除。
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import {
|
|
8
|
+
ROUTER_API_BASE,
|
|
9
|
+
type RouterCombo,
|
|
10
|
+
type RouterComboAlias,
|
|
11
|
+
type RouterComboStrategy,
|
|
12
|
+
type RouterComboSupplierGroup,
|
|
13
|
+
} from '../shared.ts'
|
|
14
|
+
import { Modal } from './Modal.tsx'
|
|
15
|
+
|
|
16
|
+
/* ---------------- SVG 图标 ---------------- */
|
|
17
|
+
|
|
18
|
+
function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
|
|
19
|
+
return (
|
|
20
|
+
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
21
|
+
<path d={d} />
|
|
22
|
+
</svg>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const I = {
|
|
27
|
+
layers: 'M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5M3 17l9 5 9-5',
|
|
28
|
+
add: 'M12 5v14M5 12h14',
|
|
29
|
+
copy: 'M9 9h11v11H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
|
|
30
|
+
check: 'M4 12.5l5 5L20 6.5',
|
|
31
|
+
edit: 'M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3zM13.5 6.5l3 3',
|
|
32
|
+
delete: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M10 11v6M14 11v6',
|
|
33
|
+
grip: 'M9 6h.01M9 12h.01M9 18h.01M15 6h.01M15 12h.01M15 18h.01',
|
|
34
|
+
search: 'M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM21 21l-4.35-4.35',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CombosTabProps {
|
|
38
|
+
/** 当前组合列表(models 为裸模型 id)。 */
|
|
39
|
+
combos: RouterCombo[]
|
|
40
|
+
/** 供应商前缀信息(展示时拼全名 alias/id)。 */
|
|
41
|
+
aliases: RouterComboAlias[]
|
|
42
|
+
onRefresh: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ComboForm {
|
|
46
|
+
name: string
|
|
47
|
+
strategy: RouterComboStrategy
|
|
48
|
+
models: string[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function CombosTab({ combos, aliases, onRefresh }: CombosTabProps): JSX.Element {
|
|
52
|
+
/** 当前供应商前缀(单供应商取第一个)。 */
|
|
53
|
+
const primaryAlias = aliases[0]?.alias ?? ''
|
|
54
|
+
|
|
55
|
+
/** 模型 → 所属供应商前缀(按 groups 分组匹配;找不到兜底 primaryAlias)。 */
|
|
56
|
+
const aliasOf = (model: string): string => {
|
|
57
|
+
for (const g of groups) {
|
|
58
|
+
if (g.models.some(m => m.id === model)) return g.supplier.alias
|
|
59
|
+
}
|
|
60
|
+
return primaryAlias
|
|
61
|
+
}
|
|
62
|
+
const [showForm, setShowForm] = useState(false)
|
|
63
|
+
const [editing, setEditing] = useState<RouterCombo | null>(null)
|
|
64
|
+
const [form, setForm] = useState<ComboForm>({ name: '', strategy: 'fallback', models: [] })
|
|
65
|
+
const [formError, setFormError] = useState('')
|
|
66
|
+
const [saving, setSaving] = useState(false)
|
|
67
|
+
const [deleteTarget, setDeleteTarget] = useState<RouterCombo | null>(null)
|
|
68
|
+
const [copied, setCopied] = useState<string | null>(null)
|
|
69
|
+
const [toast, setToast] = useState<string | null>(null)
|
|
70
|
+
const [modelQuery, setModelQuery] = useState('')
|
|
71
|
+
const [groups, setGroups] = useState<RouterComboSupplierGroup[]>([])
|
|
72
|
+
const [loadingGroups, setLoadingGroups] = useState(false)
|
|
73
|
+
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
74
|
+
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
|
75
|
+
const mounted = useRef(true)
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
mounted.current = true
|
|
79
|
+
void loadGroups()
|
|
80
|
+
return () => { mounted.current = false }
|
|
81
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
/** 打开弹窗时才拉取可用模型(按供应商分组)。 */
|
|
85
|
+
const loadGroups = async (): Promise<void> => {
|
|
86
|
+
setLoadingGroups(true)
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetch(`${ROUTER_API_BASE}/combos`, { cache: 'no-store' })
|
|
89
|
+
const data = await response.json() as { ok: boolean; groups?: RouterComboSupplierGroup[] }
|
|
90
|
+
if (mounted.current) setGroups(data.groups ?? [])
|
|
91
|
+
} catch {
|
|
92
|
+
if (mounted.current) setGroups([])
|
|
93
|
+
} finally {
|
|
94
|
+
if (mounted.current) setLoadingGroups(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const showToast = (msg: string): void => {
|
|
99
|
+
setToast(msg)
|
|
100
|
+
window.setTimeout(() => {
|
|
101
|
+
if (mounted.current) setToast(null)
|
|
102
|
+
}, 2500)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const openCreate = (): void => {
|
|
106
|
+
setForm({
|
|
107
|
+
name: '',
|
|
108
|
+
strategy: 'fallback',
|
|
109
|
+
models: [],
|
|
110
|
+
})
|
|
111
|
+
setModelQuery('')
|
|
112
|
+
setFormError('')
|
|
113
|
+
setEditing(null)
|
|
114
|
+
setShowForm(true)
|
|
115
|
+
void loadGroups()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const openEdit = (combo: RouterCombo): void => {
|
|
119
|
+
setForm({
|
|
120
|
+
name: combo.name,
|
|
121
|
+
strategy: combo.strategy,
|
|
122
|
+
models: [...combo.models],
|
|
123
|
+
})
|
|
124
|
+
setModelQuery('')
|
|
125
|
+
setFormError('')
|
|
126
|
+
setEditing(combo)
|
|
127
|
+
setShowForm(true)
|
|
128
|
+
void loadGroups()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const patchForm = (patch: Partial<ComboForm>): void => {
|
|
132
|
+
setForm(prev => ({ ...prev, ...patch }))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toggleModel = (id: string): void => {
|
|
136
|
+
patchForm({
|
|
137
|
+
models: form.models.includes(id)
|
|
138
|
+
? form.models.filter(m => m !== id)
|
|
139
|
+
: [...form.models, id],
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const handleDragStart = (index: number): void => {
|
|
144
|
+
setDragIndex(index)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const handleDragOver = (e: React.DragEvent, index: number): void => {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
if (dragOverIndex !== index) setDragOverIndex(index)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const handleDrop = (index: number): void => {
|
|
153
|
+
if (dragIndex === null || dragIndex === index) {
|
|
154
|
+
setDragIndex(null)
|
|
155
|
+
setDragOverIndex(null)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const next = [...form.models]
|
|
159
|
+
const [moved] = next.splice(dragIndex, 1)
|
|
160
|
+
if (moved === undefined) { setDragIndex(null); setDragOverIndex(null); return }
|
|
161
|
+
next.splice(index, 0, moved)
|
|
162
|
+
patchForm({ models: next })
|
|
163
|
+
setDragIndex(null)
|
|
164
|
+
setDragOverIndex(null)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const removeModel = (index: number): void => {
|
|
168
|
+
patchForm({ models: form.models.filter((_, i) => i !== index) })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const save = async (): Promise<void> => {
|
|
172
|
+
if (form.name.trim() === '') { setFormError('组合名不能为空'); return }
|
|
173
|
+
if (form.models.length === 0) { setFormError('至少需要一个模型'); return }
|
|
174
|
+
setSaving(true)
|
|
175
|
+
setFormError('')
|
|
176
|
+
try {
|
|
177
|
+
const isEdit = editing !== null
|
|
178
|
+
const response = await fetch(`${ROUTER_API_BASE}/combos/${isEdit ? 'update' : 'create'}`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify(isEdit
|
|
182
|
+
? { id: editing!.id, name: form.name.trim(), strategy: form.strategy, models: form.models }
|
|
183
|
+
: { name: form.name.trim(), strategy: form.strategy, models: form.models }),
|
|
184
|
+
cache: 'no-store',
|
|
185
|
+
})
|
|
186
|
+
const data = await response.json() as { ok: boolean; error?: string }
|
|
187
|
+
if (data.ok) {
|
|
188
|
+
setShowForm(false)
|
|
189
|
+
onRefresh()
|
|
190
|
+
showToast(isEdit ? '组合已更新' : '组合已创建')
|
|
191
|
+
} else {
|
|
192
|
+
setFormError(data.error ?? '保存失败')
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
setFormError((err as Error).message)
|
|
196
|
+
} finally {
|
|
197
|
+
setSaving(false)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const doDelete = async (id: string): Promise<void> => {
|
|
202
|
+
const response = await fetch(`${ROUTER_API_BASE}/combos/remove`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ id }),
|
|
206
|
+
cache: 'no-store',
|
|
207
|
+
})
|
|
208
|
+
const data = await response.json() as { ok: boolean; error?: string }
|
|
209
|
+
setDeleteTarget(null)
|
|
210
|
+
if (data.ok) {
|
|
211
|
+
onRefresh()
|
|
212
|
+
showToast('组合已删除')
|
|
213
|
+
} else {
|
|
214
|
+
showToast(data.error ?? '删除失败')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const copyName = async (name: string, id: string): Promise<void> => {
|
|
219
|
+
try {
|
|
220
|
+
await navigator.clipboard.writeText(name)
|
|
221
|
+
setCopied(id)
|
|
222
|
+
window.setTimeout(() => setCopied(null), 1500)
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const updateStrategy = async (id: string, strategy: RouterComboStrategy): Promise<void> => {
|
|
229
|
+
const combo = combos.find(c => c.id === id)
|
|
230
|
+
if (!combo) return
|
|
231
|
+
const response = await fetch(`${ROUTER_API_BASE}/combos/update`, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: { 'Content-Type': 'application/json' },
|
|
234
|
+
body: JSON.stringify({ id, name: combo.name, strategy, models: combo.models }),
|
|
235
|
+
cache: 'no-store',
|
|
236
|
+
})
|
|
237
|
+
const data = await response.json() as { ok: boolean; error?: string }
|
|
238
|
+
if (data.ok) {
|
|
239
|
+
onRefresh()
|
|
240
|
+
showToast(`策略已切换为${strategy === 'round-robin' ? '轮询' : '回退'}`)
|
|
241
|
+
} else {
|
|
242
|
+
showToast(data.error ?? '更新失败')
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div className="dshr-tabBody">
|
|
248
|
+
{/* Header */}
|
|
249
|
+
<section className="dshr-card dshr-comboIntro">
|
|
250
|
+
<div className="dshr-cardHead">
|
|
251
|
+
<span className="dshr-cardIcon"><Icon d={I.layers} size={16} /></span>
|
|
252
|
+
<div className="dshr-cardTitle">组合</div>
|
|
253
|
+
<span className="dshr-muted dshr-comboIntroDesc">请求传组合名,在一组模型中按策略命中</span>
|
|
254
|
+
<button type="button" className="dshr-primaryButton dshr-cardAction" onClick={openCreate}>
|
|
255
|
+
<Icon d={I.add} size={14} />
|
|
256
|
+
创建组合
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
<div className="dshr-comboIntroBody">
|
|
260
|
+
<span className="dshr-comboHeaderKey">回退</span> 按顺序尝试,前面失败时用下一个
|
|
261
|
+
<span className="dshr-comboHeaderKey dshr-comboHeaderKey-2">轮询</span> 在模型间轮转分配请求
|
|
262
|
+
</div>
|
|
263
|
+
</section>
|
|
264
|
+
|
|
265
|
+
{/* 列表 / 空状态 */}
|
|
266
|
+
{combos.length === 0 ? (
|
|
267
|
+
<div className="dshr-keyEmpty">
|
|
268
|
+
<span className="dshr-keyEmptyIcon"><Icon d={I.layers} size={30} /></span>
|
|
269
|
+
<p className="dshr-keyEmptyTitle">暂无组合</p>
|
|
270
|
+
<p className="dshr-keyEmptyDesc">创建组合,在一组模型中按策略命中</p>
|
|
271
|
+
<button type="button" className="dshr-primaryButton" onClick={openCreate}>
|
|
272
|
+
<Icon d={I.add} size={14} />
|
|
273
|
+
创建组合
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
) : (
|
|
277
|
+
<div className="dshr-comboList">
|
|
278
|
+
{combos.map(combo => (
|
|
279
|
+
<section key={combo.id} className="dshr-card dshr-comboCard">
|
|
280
|
+
<div className="dshr-cardHead">
|
|
281
|
+
<span className="dshr-cardIcon"><Icon d={I.layers} size={16} /></span>
|
|
282
|
+
<code className="dshr-comboName">{combo.name}</code>
|
|
283
|
+
<span className="dshr-muted dshr-cardMeta">{combo.models.length} 模型</span>
|
|
284
|
+
<div className="dshr-comboOps">
|
|
285
|
+
<select
|
|
286
|
+
className="dshr-input dshr-comboStrategySelect"
|
|
287
|
+
value={combo.strategy}
|
|
288
|
+
title="组合策略"
|
|
289
|
+
onChange={(e) => void updateStrategy(combo.id, e.target.value as RouterComboStrategy)}
|
|
290
|
+
>
|
|
291
|
+
<option value="fallback">回退</option>
|
|
292
|
+
<option value="round-robin">轮询</option>
|
|
293
|
+
</select>
|
|
294
|
+
<button type="button" className="dshr-iconBtn dshr-iconBtn-sm" title="复制组合名" onClick={() => void copyName(combo.name, combo.id)}>
|
|
295
|
+
<Icon d={copied === combo.id ? I.check : I.copy} size={15} />
|
|
296
|
+
</button>
|
|
297
|
+
<button type="button" className="dshr-iconBtn dshr-iconBtn-sm" title="编辑" onClick={() => openEdit(combo)}>
|
|
298
|
+
<Icon d={I.edit} size={15} />
|
|
299
|
+
</button>
|
|
300
|
+
<button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-comboOpBtn-danger" title="删除" onClick={() => setDeleteTarget(combo)}>
|
|
301
|
+
<Icon d={I.delete} size={15} />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
<div className="dshr-comboCardBody">
|
|
306
|
+
<div className="dshr-comboChips">
|
|
307
|
+
{combo.models.length === 0
|
|
308
|
+
? <span className="dshr-muted dshr-comboEmptySteps">无模型</span>
|
|
309
|
+
: (
|
|
310
|
+
<>
|
|
311
|
+
{combo.models.slice(0, 4).map((model, i) => (
|
|
312
|
+
<span key={`${model}-${i}`} className="dshr-comboChip">
|
|
313
|
+
<span className="dshr-comboChipNum">{i + 1}</span>
|
|
314
|
+
<span className="dshr-comboChipSupplier">{aliasOf(model) ? `${aliasOf(model)}/${model}` : model}</span>
|
|
315
|
+
</span>
|
|
316
|
+
))}
|
|
317
|
+
{combo.models.length > 4 && (
|
|
318
|
+
<span className="dshr-comboMore">+{combo.models.length - 4} more</span>
|
|
319
|
+
)}
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
<div className="dshr-muted dshr-reason">
|
|
324
|
+
{combo.strategy === 'fallback'
|
|
325
|
+
? '按此顺序尝试模型;当前模型不可用时换下一个。'
|
|
326
|
+
: '请求在模型间轮转分配。'}
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
</section>
|
|
330
|
+
))}
|
|
331
|
+
</div>
|
|
332
|
+
)}
|
|
333
|
+
|
|
334
|
+
{/* ---- 创建/编辑弹窗 ---- */}
|
|
335
|
+
{showForm && (
|
|
336
|
+
<Modal title={editing !== null ? '编辑组合' : '创建组合'} onClose={() => setShowForm(false)} className="dshr-modal-wide">
|
|
337
|
+
<div className="dshr-modalForm">
|
|
338
|
+
<div className="dshr-comboFormSplit">
|
|
339
|
+
<div className="dshr-comboFormDivider" aria-hidden="true" />
|
|
340
|
+
{/* 左:组合名 + 策略 + 已选模型 */}
|
|
341
|
+
<div className="dshr-comboFormLeft">
|
|
342
|
+
<div>
|
|
343
|
+
<p className="dshr-formLabel">组合名</p>
|
|
344
|
+
<input
|
|
345
|
+
className="dshr-input dshr-mono"
|
|
346
|
+
placeholder="my-combo"
|
|
347
|
+
value={form.name}
|
|
348
|
+
onChange={(e) => patchForm({ name: e.target.value })}
|
|
349
|
+
autoFocus
|
|
350
|
+
/>
|
|
351
|
+
<p className="dshr-formHint">只能含字母、数字、-、_ 和 .</p>
|
|
352
|
+
</div>
|
|
353
|
+
<div>
|
|
354
|
+
<p className="dshr-formLabel">策略</p>
|
|
355
|
+
<select
|
|
356
|
+
className="dshr-input"
|
|
357
|
+
value={form.strategy}
|
|
358
|
+
onChange={(e) => patchForm({ strategy: e.target.value as RouterComboStrategy })}
|
|
359
|
+
>
|
|
360
|
+
<option value="fallback">回退 — 按顺序尝试,失败换下一个</option>
|
|
361
|
+
<option value="round-robin">轮询 — 在模型间轮转分配</option>
|
|
362
|
+
</select>
|
|
363
|
+
</div>
|
|
364
|
+
<div>
|
|
365
|
+
<p className="dshr-formLabel">模型(已选 {form.models.length})</p>
|
|
366
|
+
{form.models.length === 0 ? (
|
|
367
|
+
<div className="dshr-comboEmptyStepsBox">
|
|
368
|
+
<Icon d={I.layers} size={20} />
|
|
369
|
+
<p className="dshr-muted">还没有模型</p>
|
|
370
|
+
</div>
|
|
371
|
+
) : (
|
|
372
|
+
<div className="dshr-comboStepList">
|
|
373
|
+
{form.models.map((model, index) => (
|
|
374
|
+
<div
|
|
375
|
+
key={`${model}-${index}`}
|
|
376
|
+
className={`dshr-comboStepRow${dragIndex === index ? ' dshr-comboStepRow-dragging' : ''}${dragOverIndex === index && dragIndex !== null && dragIndex !== index ? ' dshr-comboStepRow-over' : ''}`}
|
|
377
|
+
draggable
|
|
378
|
+
onDragStart={() => handleDragStart(index)}
|
|
379
|
+
onDragOver={(e) => handleDragOver(e, index)}
|
|
380
|
+
onDrop={() => handleDrop(index)}
|
|
381
|
+
onDragEnd={() => { setDragIndex(null); setDragOverIndex(null) }}
|
|
382
|
+
>
|
|
383
|
+
<span className="dshr-comboGrip" title="拖拽排序"><Icon d={I.grip} size={14} /></span>
|
|
384
|
+
<span className="dshr-comboStepNum">{index + 1}</span>
|
|
385
|
+
<code className="dshr-comboSelectedModel">{aliasOf(model) ? `${aliasOf(model)}/${model}` : model}</code>
|
|
386
|
+
<button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-comboStepRemove" title="移除" onClick={() => removeModel(index)}>
|
|
387
|
+
<Icon d={I.delete} size={14} />
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
{/* 右:添加模型 */}
|
|
396
|
+
<div className="dshr-comboFormRight">
|
|
397
|
+
<p className="dshr-formLabel">添加模型</p>
|
|
398
|
+
<div className="dshr-modelSearch dshr-comboModelSearch">
|
|
399
|
+
<span className="dshr-modelSearchIcon"><Icon d={I.search} size={13} /></span>
|
|
400
|
+
<input
|
|
401
|
+
className="dshr-input"
|
|
402
|
+
placeholder="搜索模型…"
|
|
403
|
+
value={modelQuery}
|
|
404
|
+
onChange={(e) => setModelQuery(e.target.value)}
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
<div className="dshr-comboPickList">
|
|
408
|
+
{loadingGroups
|
|
409
|
+
? <p className="dshr-muted dshr-comboPickEmpty">加载中…</p>
|
|
410
|
+
: groups.length === 0
|
|
411
|
+
? <p className="dshr-muted dshr-comboPickEmpty">没有可添加的模型</p>
|
|
412
|
+
: groups.map(g => {
|
|
413
|
+
const items = g.models.filter(m =>
|
|
414
|
+
!form.models.includes(m.id) &&
|
|
415
|
+
(modelQuery === '' || m.id.toLowerCase().includes(modelQuery.toLowerCase())))
|
|
416
|
+
if (items.length === 0) return null
|
|
417
|
+
return (
|
|
418
|
+
<div key={g.supplier.id} className="dshr-comboPickGroup">
|
|
419
|
+
<p className="dshr-comboPickGroupTitle">{g.supplier.name} · {g.supplier.alias}</p>
|
|
420
|
+
{items.map(m => (
|
|
421
|
+
<button key={m.id} type="button" className="dshr-comboPickItem" onClick={() => toggleModel(m.id)}>
|
|
422
|
+
<span className="dshr-comboPickName">{m.id}</span>
|
|
423
|
+
</button>
|
|
424
|
+
))}
|
|
425
|
+
</div>
|
|
426
|
+
)
|
|
427
|
+
})}
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
{formError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{formError}</span></div>}
|
|
432
|
+
<div className="dshr-modalActions">
|
|
433
|
+
<button type="button" className="dshr-miniButton" onClick={() => setShowForm(false)}>取消</button>
|
|
434
|
+
<button type="button" className="dshr-primaryButton" onClick={() => void save()} disabled={saving || form.name.trim() === '' || form.models.length === 0}>
|
|
435
|
+
{saving ? '保存中…' : editing !== null ? '保存' : '创建'}
|
|
436
|
+
</button>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
</Modal>
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{/* ---- 删除确认 ---- */}
|
|
443
|
+
{deleteTarget !== null && (
|
|
444
|
+
<Modal title="删除组合" onClose={() => setDeleteTarget(null)}>
|
|
445
|
+
<div className="dshr-modalForm">
|
|
446
|
+
<p className="dshr-muted">确定删除组合「{deleteTarget.name}」吗?</p>
|
|
447
|
+
<div className="dshr-modalActions">
|
|
448
|
+
<button type="button" className="dshr-miniButton" onClick={() => setDeleteTarget(null)}>取消</button>
|
|
449
|
+
<button type="button" className="dshr-dangerButton" onClick={() => void doDelete(deleteTarget.id)}>删除</button>
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</Modal>
|
|
453
|
+
)}
|
|
454
|
+
|
|
455
|
+
{toast !== null && <div className="dshr-toast">{toast}</div>}
|
|
456
|
+
</div>
|
|
457
|
+
)
|
|
458
|
+
}
|