dsh-router-core 0.3.21 → 0.4.1

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.
Files changed (120) hide show
  1. package/README.md +238 -63
  2. package/cordis.patch.yml +22 -0
  3. package/docs/ext.md +28 -5
  4. package/docs/pool-sticky-block.md +158 -0
  5. package/docs/suppliers.md +28 -5
  6. package/lib/client-registry.js +1300 -549
  7. package/lib/client.js +1300 -549
  8. package/lib/ext-test/index.js +47 -0
  9. package/lib/ext-test/locale/en.json +6 -0
  10. package/lib/ext-test/locale/zh.json +6 -0
  11. package/lib/ext-test/package.json +5 -0
  12. package/lib/http-state-C1vb3gW2.js +48 -0
  13. package/lib/index.js +1169 -254
  14. package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
  15. package/lib/suppliers/nvidia/locale/en.json +6 -0
  16. package/lib/suppliers/nvidia/locale/zh.json +6 -0
  17. package/lib/suppliers/nvidia/package.json +5 -0
  18. package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
  19. package/lib/suppliers/opencode/locale/en.json +6 -0
  20. package/lib/suppliers/opencode/locale/zh.json +6 -0
  21. package/lib/suppliers/opencode/package.json +5 -0
  22. package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
  23. package/lib/suppliers/openrouter/locale/en.json +6 -0
  24. package/lib/suppliers/openrouter/locale/zh.json +6 -0
  25. package/lib/suppliers/openrouter/package.json +5 -0
  26. package/lib/types/ext/contract.d.ts +9 -7
  27. package/lib/types/ext-test/index.d.ts +21 -0
  28. package/lib/types/ext-test/plugin.d.ts +23 -0
  29. package/lib/types/host-version.d.ts +15 -0
  30. package/lib/types/index.d.ts +6 -0
  31. package/lib/types/llm/adapter.d.ts +35 -4
  32. package/lib/types/router/account-pool.d.ts +53 -18
  33. package/lib/types/router/index.d.ts +65 -15
  34. package/lib/types/router/prefix-affinity.d.ts +18 -1
  35. package/lib/types/router/types.d.ts +7 -4
  36. package/lib/types/router/usage-store.d.ts +12 -0
  37. package/lib/types/shared.d.ts +25 -21
  38. package/lib/types/supplier-config.d.ts +15 -3
  39. package/lib/types/suppliers/contract.d.ts +37 -4
  40. package/lib/types/suppliers/http-state.d.ts +23 -0
  41. package/lib/types/suppliers/loader.d.ts +6 -4
  42. package/lib/types/suppliers/nvidia/index.d.ts +18 -0
  43. package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
  44. package/lib/types/suppliers/opencode/index.d.ts +18 -0
  45. package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
  46. package/lib/types/suppliers/openrouter/index.d.ts +18 -0
  47. package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
  48. package/lib/types/suppliers/probe.d.ts +160 -0
  49. package/lib/types/suppliers/registry.d.ts +3 -2
  50. package/package.json +50 -16
  51. package/src/client/CombosTab.tsx +8 -53
  52. package/src/client/ExtDetail.tsx +32 -196
  53. package/src/client/ExtTab.tsx +25 -42
  54. package/src/client/ExtTestPanel.tsx +392 -0
  55. package/src/client/LastHitDock.tsx +141 -0
  56. package/src/client/RouterComponentsSection.tsx +241 -0
  57. package/src/client/RouterView.tsx +12 -5
  58. package/src/client/StatsTab.tsx +8 -6
  59. package/src/client/SupplierDetail.tsx +2 -34
  60. package/src/client/comp-button-scope.test.ts +58 -0
  61. package/src/client/comp-panel-layout.test.ts +58 -0
  62. package/src/client/env.d.ts +38 -1
  63. package/src/client/ext-panels.test.ts +44 -0
  64. package/src/client/ext-panels.ts +49 -0
  65. package/src/client/index.tsx +86 -0
  66. package/src/client/last-hit-store.ts +89 -0
  67. package/src/client/model-choice.test.ts +41 -0
  68. package/src/client/model-choice.ts +37 -0
  69. package/src/client/model-hint-copy.test.ts +7 -2
  70. package/src/client/model-hint-copy.ts +2 -2
  71. package/src/client/router-components.test.ts +114 -0
  72. package/src/client/router-components.ts +104 -0
  73. package/src/client/router.css +2592 -1635
  74. package/src/client/supplier-visibility.test.ts +57 -0
  75. package/src/client/supplier-visibility.ts +63 -0
  76. package/src/ext/contract.ts +9 -7
  77. package/src/ext-test/ext-test.test.ts +60 -0
  78. package/src/ext-test/index.ts +35 -0
  79. package/src/ext-test/locale/en.json +6 -0
  80. package/src/ext-test/locale/zh.json +6 -0
  81. package/src/ext-test/plugin.ts +34 -0
  82. package/src/ext-test/row.json +5 -0
  83. package/src/host-version.test.ts +61 -0
  84. package/src/host-version.ts +101 -0
  85. package/src/index.ts +165 -68
  86. package/src/llm/adapter.test.ts +175 -4
  87. package/src/llm/adapter.ts +109 -16
  88. package/src/router/account-pool.test.ts +189 -83
  89. package/src/router/account-pool.ts +120 -31
  90. package/src/router/disconnect-leak.test.ts +7 -12
  91. package/src/router/first-byte-budget.test.ts +160 -0
  92. package/src/router/index.ts +276 -62
  93. package/src/router/models-cache.test.ts +54 -9
  94. package/src/router/precise-dispatch.test.ts +6 -6
  95. package/src/router/prefix-affinity.ts +22 -1
  96. package/src/router/supplier-switch.test.ts +220 -0
  97. package/src/router/types.ts +7 -5
  98. package/src/router/usage-store.ts +16 -0
  99. package/src/router/usage.test.ts +1 -1
  100. package/src/shared.ts +25 -24
  101. package/src/supplier-config.ts +33 -13
  102. package/src/suppliers/contract.ts +56 -4
  103. package/src/suppliers/loader.test.ts +38 -4
  104. package/src/suppliers/loader.ts +33 -18
  105. package/src/suppliers/nvidia/index.ts +27 -0
  106. package/src/suppliers/nvidia/locale/en.json +6 -0
  107. package/src/suppliers/nvidia/locale/zh.json +6 -0
  108. package/src/suppliers/nvidia/row.json +5 -0
  109. package/src/suppliers/opencode/index.ts +27 -0
  110. package/src/suppliers/opencode/locale/en.json +6 -0
  111. package/src/suppliers/opencode/locale/zh.json +6 -0
  112. package/src/suppliers/opencode/row.json +5 -0
  113. package/src/suppliers/openrouter/index.ts +27 -0
  114. package/src/suppliers/openrouter/locale/en.json +6 -0
  115. package/src/suppliers/openrouter/locale/zh.json +6 -0
  116. package/src/suppliers/openrouter/row.json +5 -0
  117. package/src/suppliers/probe.test.ts +266 -0
  118. package/src/suppliers/probe.ts +475 -0
  119. package/src/suppliers/registry.ts +103 -23
  120. package/src/types.d.ts +2 -1
@@ -1,20 +1,18 @@
1
1
  /**
2
2
  * 扩展详情页 —— 从设置 → 路由 → 扩展 的卡片点进来。
3
3
  *
4
- * 通用:返回键 + 扩展名/说明/就绪态。`watch` 扩展再渲染进程监控面板:
5
- * 实时采样「agent 启动的进程」,每行 PID / 命令 / CPU% / 内存%,**只读**——不提供
6
- * kill(杀进程是大事,交由用户在 CLI 自己操作)。面板提供「复制 PID」和
7
- * 「复制 kill 命令」两个剪贴板动作方便用户去终端处理。
8
- * 进程数据走后端 `/router/api/ext/:id/processes`;`ps` 瞬时失败时保留上一帧,别刷成
9
- * 整屏错误。
4
+ * **只读:开关不在这一层。** 启停的唯一入口是官方插件页(设置 → 插件 →
5
+ * dsh-router-core 详情页的「路由组件」一节),那里直接调核心的
6
+ * `PATCH /router/api/ext`,自检与持久化都由核心裁决。同一件事只留一个开关,
7
+ * 否则两处状态各说各话。
8
+ *
9
+ * 列表页只列**已启用**的扩展(关闭的连卡片都不出现),所以这里也不必再显示
10
+ * 「已启用/未启用」——那行字只能是常量。布局同供应商详情:返回链接 + 图标 +
11
+ * 名称/副行,下面跟扩展说明卡与未就绪横幅。有 `api()` 支持的自定义扩展可在未来
12
+ * 按需加独立面板,此处不内置任何具体扩展的专属视图。
10
13
  */
11
- import { useEffect, useRef, useState } from 'react'
12
- import {
13
- ROUTER_API_BASE,
14
- type RouterExtItem,
15
- type RouterWatchProcess,
16
- type RouterWatchResponse,
17
- } from '../shared.ts'
14
+ import type { RouterExtItem } from '../shared.ts'
15
+ import { extPanel } from './ext-panels.ts'
18
16
 
19
17
  function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
20
18
  return (
@@ -26,42 +24,19 @@ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
26
24
 
27
25
  const I = {
28
26
  back: 'M19 12H5M12 19l-7-7 7-7',
29
- refresh: 'M20.5 12a8.5 8.5 0 1 1-2.6-6.1M20.5 4v5h-5',
30
- node: 'M8 5.5L12 3l4 2.5v5L12 13l-4-2.5v-5zM4 9v5l4 2.5M12 13v5l4-2.5M20 9v5l-4 2.5',
31
27
  bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
32
- copy: 'M8 8h11v11H8zM5 14H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
33
- check: 'M4 12.5l5 5L20 6.5',
34
- }
35
-
36
- /** 刷新按钮(带禁用中旋转)。 */
37
- function RefreshBtn({ onClick, busy }: { onClick: () => void; busy: boolean }): JSX.Element {
38
- return (
39
- <button
40
- type="button"
41
- className="dshr-iconButton"
42
- onClick={onClick}
43
- disabled={busy}
44
- title="刷新进程"
45
- aria-label="刷新进程"
46
- >
47
- <span className={busy ? 'dshr-spin' : undefined}><Icon d={I.refresh} size={16} /></span>
48
- </button>
49
- )
50
- }
51
-
52
- function fmtPct(v: number | undefined): string {
53
- const n = v ?? 0
54
- return `${n.toFixed(1)}%`
55
28
  }
56
29
 
57
30
  interface ExtDetailProps {
58
31
  item: RouterExtItem
59
32
  onBack: () => void
60
- /** 开关切换(由列表页持有状态与 PATCH;这里只做触发)。 */
61
- onToggle: (item: RouterExtItem, value: boolean) => void
62
33
  }
63
34
 
64
- export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Element {
35
+ export function ExtDetail({ item, onBack }: ExtDetailProps): JSX.Element {
36
+ // 扩展**自带**详情面板时用它(插件自检就是这么做的:选供应商/模型/连接 + 跑测试)。
37
+ // 没有就退回下面这张通用只读页 —— 保留返回链接与标题,导航形状与有没有自定义
38
+ // 面板无关,用户点进去的预期是稳定的。
39
+ const Custom = extPanel(item.id)
65
40
  return (
66
41
  <div className="dshr-tabBody">
67
42
  {/* Header(同供应商详情:返回链接 + 图标 + 名称/副行) */}
@@ -72,33 +47,31 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
72
47
  </button>
73
48
  <div className="dshr-providerTitleRow">
74
49
  <div className="dshr-providerIcon" style={{ color: 'var(--rs-faint)', background: 'var(--rs-layer-2)' }}>
75
- <Icon d={I.bolt} size={22} />
50
+ {item.icon !== undefined
51
+ ? (
52
+ <img
53
+ className="dshr-providerImg"
54
+ src={item.icon}
55
+ alt=""
56
+ // 同上:失败就藏,露出备用图标位置(不显示碎图)
57
+ onError={(e) => { e.currentTarget.style.display = 'none' }}
58
+ />
59
+ )
60
+ : <Icon d={I.bolt} size={22} />}
76
61
  </div>
77
62
  <div className="dshr-providerMeta">
78
63
  <h1 className="dshr-providerName">{item.name}</h1>
79
64
  <p className="dshr-providerCount">
80
65
  <span className="dshr-mono">{item.id}</span>
81
- {item.enabled ? ' · 已启用' : ' · 未启用'}
82
66
  {item.ready === false ? ' · 未就绪' : ''}
83
67
  </p>
84
68
  </div>
85
- {/* 开关放在标题行右侧(同供应商详情的编辑按钮位) */}
86
- <button
87
- type="button"
88
- className={`dshr-toggle dshr-extDetailToggle ${item.enabled ? 'dshr-toggle-on' : ''}`}
89
- role="switch"
90
- aria-checked={!!item.enabled}
91
- disabled={item.ready === false && !item.enabled}
92
- onClick={() => void onToggle(item, !item.enabled)}
93
- title={item.ready === false && !item.enabled
94
- ? '当前不可用,无法开启'
95
- : item.enabled ? `关闭 ${item.name}` : `开启 ${item.name}`}
96
- >
97
- <span className="dshr-toggleKnob" />
98
- </button>
99
69
  </div>
100
70
  </div>
101
71
 
72
+ {/* 扩展自带的面板(注册表里没有就落回下面这两块通用内容) */}
73
+ {Custom !== undefined ? <Custom /> : (
74
+ <>
102
75
  {/* 扩展说明 */}
103
76
  {item.description !== undefined && item.description !== '' && (
104
77
  <section className="dshr-card">
@@ -113,145 +86,8 @@ export function ExtDetail({ item, onBack, onToggle }: ExtDetailProps): JSX.Eleme
113
86
  <span>{item.detail !== undefined && item.detail !== '' ? item.detail : '当前不可用'}</span>
114
87
  </div>
115
88
  )}
116
-
117
- {item.id === 'watch' ? <WatchPanel /> : null}
89
+ </>
90
+ )}
118
91
  </div>
119
92
  )
120
93
  }
121
-
122
- /** watch 的进程监控面板:轮询采样(只读)+ 复制 PID / 复制 kill 命令。 */
123
- function WatchPanel(): JSX.Element {
124
- const [processes, setProcesses] = useState<RouterWatchProcess[]>([])
125
- const [error, setError] = useState('')
126
- const [loading, setLoading] = useState(true)
127
- // 刚复制成的那条(pid + 文本),显示「已复制」提示,1.2s 后清除。
128
- const [copied, setCopied] = useState<{ pid: number; text: string } | null>(null)
129
- const alive = useRef(true)
130
- const now = useRef(Date.now())
131
-
132
- useEffect(() => {
133
- alive.current = true
134
- return () => { alive.current = false }
135
- }, [])
136
-
137
- const load = async (): Promise<void> => {
138
- try {
139
- const res = await fetch(`${ROUTER_API_BASE}/ext/watch/processes`, { cache: 'no-store' })
140
- if (!res.ok) {
141
- // 尽量把后端的原因露出来(如「extension not found」/「extension api not found」),
142
- // 别只剩 `HTTP 404`——它分不清是扩展没装、还是 api 没命中。
143
- let reason = ''
144
- try {
145
- const err = await res.json() as { error?: string }
146
- if (err.error) reason = ` — ${err.error}`
147
- } catch { /* 非 JSON 响应体,忽略 */ }
148
- if (alive.current) setError(`HTTP ${res.status}${reason}`)
149
- return
150
- }
151
- // 响应体非 JSON(历史上 404 空 body 会在这里抛 Unexpected end of JSON input)。
152
- // 分开 try,别把"非 JSON"混进网络错误的文案里。
153
- let data: RouterWatchResponse
154
- try {
155
- data = await res.json() as RouterWatchResponse
156
- } catch {
157
- if (alive.current) setError('响应不是 JSON')
158
- return
159
- }
160
- if (!alive.current) return
161
- setError(data.ok ? '' : (data.error ?? '加载进程失败'))
162
- if (data.ok && data.processes !== undefined) setProcesses(data.processes) // ?: 失败保留上一帧
163
- } catch (err) {
164
- if (alive.current) setError((err as Error).message) // 失败保留上一帧
165
- } finally {
166
- if (alive.current) setLoading(false)
167
- }
168
- }
169
-
170
- // 进入面板后轮询采样(3s;离开卸载)。
171
- useEffect(() => {
172
- void load()
173
- const timer = setInterval(() => { void load() }, 3000)
174
- return () => clearInterval(timer)
175
- // eslint-disable-next-line react-hooks/exhaustive-deps
176
- }, [])
177
-
178
- const copy = (pid: number, text: string): void => {
179
- const stamp = Date.now()
180
- now.current = stamp
181
- void navigator.clipboard.writeText(text)
182
- .then(() => {
183
- if (alive.current && now.current === stamp) {
184
- setCopied({ pid, text })
185
- setTimeout(() => { if (alive.current) setCopied((c) => c?.pid === pid ? null : c) }, 1200)
186
- }
187
- })
188
- .catch(() => { if (alive.current) setError('复制失败') })
189
- }
190
-
191
- return (
192
- <section className="dshr-card">
193
- <div className="dshr-cardHead">
194
- <span className="dshr-cardIcon"><Icon d={I.node} /></span>
195
- <div className="dshr-cardTitle">运行进程</div>
196
- <span className="dshr-cardMeta">agent 启动 · 只读 · 3s 采样</span>
197
- <RefreshBtn onClick={() => void load()} busy={loading} />
198
- </div>
199
-
200
- {error !== '' && (
201
- <div className="dshr-alert"><strong>采样失败</strong><span>{error}(保留上一帧)</span></div>
202
- )}
203
-
204
- {!loading && processes.length === 0 ? (
205
- <div className="dshr-empty">
206
- 暂无会话中 agent 启动的进程。运行诸如 <code>node server.js</code> 的长驻命令后
207
- 会出现在这里。
208
- </div>
209
- ) : (
210
- <div className="dshr-rankList">
211
- <div className="dshr-rankRow dshr-rankHead">
212
- <span className="dshr-rankName">PID / 命令</span>
213
- <span className="dshr-rankNum">CPU</span>
214
- <span className="dshr-rankNum">内存</span>
215
- <span className="dshr-rankNum" />
216
- </div>
217
- {processes.map((p) => {
218
- const done = copied?.pid === p.pid
219
- return (
220
- <div key={p.pid} className="dshr-rankRow">
221
- <span className="dshr-rankName dshr-mono" title={p.command}>
222
- <span className="dshr-muted">{p.pid}</span>&ensp;{p.command}
223
- </span>
224
- <span className="dshr-rankNum">{fmtPct(p.cpu)}</span>
225
- <span className="dshr-rankNum">{fmtPct(p.mem)}</span>
226
- <span className="dshr-rankNum dshr-procOps">
227
- {done
228
- ? <span className="dshr-muted"><Icon d={I.check} size={13} /> 已复制</span>
229
- : (
230
- <>
231
- <button
232
- type="button"
233
- className="dshr-miniButton"
234
- onClick={() => copy(p.pid, String(p.pid))}
235
- title={`复制 PID ${p.pid}`}
236
- >
237
- <Icon d={I.copy} size={13} /> PID
238
- </button>
239
- <button
240
- type="button"
241
- className="dshr-miniButton"
242
- onClick={() => copy(p.pid, `kill -TERM ${p.pid}`)}
243
- title={`复制 kill -TERM ${p.pid}`}
244
- >
245
- <Icon d={I.copy} size={13} /> kill
246
- </button>
247
- </>
248
- )}
249
- </span>
250
- </div>
251
- )
252
- })}
253
- </div>
254
- )}
255
- </section>
256
- )
257
- }
@@ -1,12 +1,16 @@
1
1
  /**
2
- * 扩展 (Ext) 页 —— 列出所有注册进 `router.ext` 的扩展插件,每个一个开关。
2
+ * 扩展 (Ext) 页 —— 列出**已启用**的扩展插件,只读。
3
3
  *
4
- * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
- * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK),
6
- * 开关与渲染都在核心。
4
+ * 数据来自 `/router/api/ext`:核心把自己存的开关(`ext.json`)+ 扩展器报的
5
+ * 运行时事实(`ready` / `detail`)合并后给面板。扩展器只实现具体能力(如 RTK)。
7
6
  *
8
- * 布局贴近 EndpointTab 的「Require API key」开关行:每个扩展器一张卡片。
9
- * 未装任何扩展 → 空状态提示。
7
+ * 两个刻意的形状(与「开关在官方插件页」这一决定配套):
8
+ * 1. **只列已启用的** —— 关闭的扩展连卡片都不出现。这里是「正在生效的东西」的
9
+ * 展台,不是配置页;要去改开关,去设置 → 插件 → dsh-router-core 详情页的
10
+ * 「路由组件」一节。
11
+ * 2. **本页不发 PATCH** —— 启停只有一个入口,避免两处状态各说各话。
12
+ *
13
+ * 未装任何扩展,或装了但都没启用 → 同一条空状态提示。
10
14
  */
11
15
  import { useEffect, useRef, useState } from 'react'
12
16
  import { ROUTER_API_BASE, type RouterExtItem, type RouterExtResponse } from '../shared.ts'
@@ -21,8 +25,8 @@ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
21
25
  }
22
26
 
23
27
  const I = {
24
- bolt: 'M13 2L4 14h6l-1 8 9-12h-6l1-8z',
25
- puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a1 1 0 0 1 1 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
28
+ bolt: 'M13 2L4 14h6l-1 8 9-12h-6z',
29
+ puzzle: 'M4 7h3a2 2 0 0 1 4 0h5a2 2 0 0 1 2 1v4a2 2 0 0 0 0 4v4H8v-3a2 2 0 0 1 0-4H4V7z',
26
30
  }
27
31
 
28
32
  export function ExtTab(): JSX.Element {
@@ -55,36 +59,13 @@ export function ExtTab(): JSX.Element {
55
59
  }
56
60
  }
57
61
 
58
- const toggle = async (item: RouterExtItem, value: boolean): Promise<void> => {
59
- // 乐观更新
60
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: value } : i))
61
- setError('')
62
- try {
63
- const response = await fetch(`${ROUTER_API_BASE}/ext`, {
64
- method: 'PATCH',
65
- headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ id: item.id, enabled: value }),
67
- cache: 'no-store',
68
- })
69
- const data = await response.json() as RouterExtResponse
70
- if (mounted.current && data.enhancers) {
71
- setItems(data.enhancers)
72
- } else if (mounted.current && !data.ok) {
73
- setError(data.error ?? '切换失败')
74
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
75
- }
76
- } catch (err) {
77
- if (mounted.current) {
78
- setError((err as Error).message)
79
- setItems(prev => prev.map(i => i.id === item.id ? { ...i, enabled: !value } : i))
80
- }
81
- }
82
- }
62
+ // 只展已启用的。`enabled` 缺失按未启用读(与插件页同一口径:没拿到的事实不当真)。
63
+ const shown = items.filter(item => item.enabled === true)
83
64
 
84
- // 打开详情视图:用 live item(否则详情里的开关状态不会随列表更新)。
85
- const selected = selectedId !== null ? items.find((i) => i.id === selectedId) ?? null : null
65
+ // 打开详情视图:用 live item(详情里显示的是加载那一刻的运行时事实)。
66
+ const selected = selectedId !== null ? shown.find((i) => i.id === selectedId) ?? null : null
86
67
  if (selected !== null) {
87
- return <ExtDetail item={selected} onBack={() => setSelectedId(null)} onToggle={toggle} />
68
+ return <ExtDetail item={selected} onBack={() => setSelectedId(null)} />
88
69
  }
89
70
 
90
71
  return (
@@ -95,17 +76,18 @@ export function ExtTab(): JSX.Element {
95
76
 
96
77
  {!loaded ? (
97
78
  <div className="dshr-empty">加载中…</div>
98
- ) : items.length === 0 ? (
79
+ ) : shown.length === 0 ? (
99
80
  <div className="dshr-keyEmpty">
100
81
  <span className="dshr-keyEmptyIcon"><Icon d={I.puzzle} size={30} /></span>
101
- <p className="dshr-keyEmptyTitle">暂无扩展</p>
82
+ <p className="dshr-keyEmptyTitle">暂无启用的扩展</p>
102
83
  <p className="dshr-keyEmptyDesc">
103
- 安装扩展插件(如 <code>dsh-router-ext-watch</code>)后,这里会出现开关
84
+ 开关在 <strong>设置 → 插件 → dsh-router-core</strong> 的「路由组件」一节;
85
+ 启用后这里会列出它
104
86
  </p>
105
87
  </div>
106
88
  ) : (
107
89
  <div className="dshr-supplierGrid">
108
- {items.map(item => {
90
+ {shown.map(item => {
109
91
  const notReady = item.ready === false
110
92
  return (
111
93
  <section key={item.id} className="dshr-supplierCard" onClick={() => setSelectedId(item.id)}>
@@ -130,7 +112,8 @@ export function ExtTab(): JSX.Element {
130
112
  )}
131
113
 
132
114
  <div className="dshr-supplierMeta">
133
- <span className="dshr-muted">{item.enabled ? '已启用' : '未启用'}</span>
115
+ {/* 描述留在详情页(卡片放不下);这一行放稳定的 id,够短不会撑破布局 */}
116
+ <span className="dshr-mono dshr-muted">{item.id}</span>
134
117
  <span className="dshr-chevron">›</span>
135
118
  </div>
136
119
  </section>
@@ -140,4 +123,4 @@ export function ExtTab(): JSX.Element {
140
123
  )}
141
124
  </div>
142
125
  )
143
- }
126
+ }