dsh-router-core 0.3.0 → 0.3.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.
@@ -11,7 +11,7 @@ export declare const ROUTER_VIEW_SELECTOR = "[data-dsh-router-view]";
11
11
  /** Active-state attribute on <html> that shows the panel. */
12
12
  export declare const ROUTER_ACTIVE_ATTR = "data-dsh-router-active";
13
13
  /** The panel's visible title (also the sidebar entry label). */
14
- export declare const ROUTER_TITLE = "\u8DEF\u7531\u7CFB\u7EDF";
14
+ export declare const ROUTER_TITLE = "\u8DEF\u7531";
15
15
  /** Same-origin API base the client panel fetches (host half serves it). */
16
16
  export declare const ROUTER_API_BASE = "/router/api";
17
17
  /** Bearer key env / credential reference protecting /v1/*. */
@@ -44,6 +44,22 @@ export interface RouterModelsResponse {
44
44
  context_length?: number;
45
45
  }>;
46
46
  }
47
+ /** 一键签到:单个供应商的汇总结果(`/suppliers/:id/checkin` 的响应体)。 */
48
+ export interface RouterCheckinResponse {
49
+ ok: boolean;
50
+ error?: string;
51
+ total: number;
52
+ /** 真正 claim 成功的链接数。 */
53
+ succeeded: number;
54
+ /** 今日已签到的链接数(幂等成功)。 */
55
+ already: number;
56
+ results?: Array<{
57
+ uid: string;
58
+ ok: boolean;
59
+ status?: string;
60
+ message?: string;
61
+ }>;
62
+ }
47
63
  /** `/router/api/health` response. */
48
64
  export interface RouterHealthResponse {
49
65
  ok: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -33,7 +33,10 @@
33
33
  "inject": [
34
34
  "react",
35
35
  "react-dom",
36
- "cordis"
36
+ "cordis",
37
+ "@deepseek-ai/dsh-client-runtime",
38
+ "@deepseek-ai/dsh-client-ui-slots",
39
+ "@deepseek-ai/dsh-client-locale"
37
40
  ],
38
41
  "platform": "web"
39
42
  }
@@ -45,20 +48,25 @@
45
48
  "lib/types/**/*.d.ts",
46
49
  "src",
47
50
  "cordis.patch.yml",
51
+ "docs",
48
52
  "README.md",
49
53
  "LICENSE"
50
54
  ],
51
55
  "scripts": {
52
- "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
56
+ "build": "node scripts/check-css.mjs && rm -rf lib && tsc -p tsconfig.build.json && tsdown && npm run build:client",
53
57
  "build:client": "DSH_CLIENT_ID=dsh-router DSH_CLIENT_FILE=client.js vite build -c client/vite.config.ts && DSH_CLIENT_ID=dsh-external/dsh-router DSH_CLIENT_FILE=client-registry.js vite build -c client/vite.config.ts",
54
58
  "typecheck": "tsc --noEmit",
55
59
  "test": "node --test \"src/**/*.test.ts\"",
56
60
  "bundle": "tsdown",
57
- "watch": "tsdown --watch"
61
+ "watch": "tsdown --watch",
62
+ "check:css": "node scripts/check-css.mjs"
58
63
  },
59
64
  "license": "MIT",
60
65
  "peerDependencies": {
61
66
  "@deepseek-ai/cordis": "^4.0.1",
67
+ "@deepseek-ai/dsh-client-locale": "*",
68
+ "@deepseek-ai/dsh-client-runtime": "*",
69
+ "@deepseek-ai/dsh-client-ui-slots": "*",
62
70
  "@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
63
71
  "cordis": "^4.0.0-rc.7",
64
72
  "react": "^18.2.0",
@@ -67,6 +75,15 @@
67
75
  "peerDependenciesMeta": {
68
76
  "cordis": {
69
77
  "optional": true
78
+ },
79
+ "@deepseek-ai/dsh-client-locale": {
80
+ "optional": true
81
+ },
82
+ "@deepseek-ai/dsh-client-runtime": {
83
+ "optional": true
84
+ },
85
+ "@deepseek-ai/dsh-client-ui-slots": {
86
+ "optional": true
70
87
  }
71
88
  },
72
89
  "devDependencies": {
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 概览的「签到」汇总卡:一键签到所有支持签到的供应商 + 显示今天点过没。
3
+ *
4
+ * **「今日已签到」的口径(天花板,刻意不撒谎)**:这里只知道「今天在这个浏览器
5
+ * 点过这个按钮没」,存在 localStorage,**不知道上游是不是真的签上了**。真凭据是
6
+ * 上游的 `checked_in`,但那得逐个链接查(traywork 侧实测:积分不可信、
7
+ * 只有 checked_in 算数),而当前契约里没有「查签到状态」这个能力。
8
+ * 所以卡片文案是「今日已点」而不是「已签到」,悬停说明里写清这一点。
9
+ *
10
+ * 要真状态的升级路径:给 supplier 契约加 `checkinStatus?()`,核心加一条
11
+ * `/suppliers/:id/checkin/status` 汇总路由,这里改成读它即可(卡片不用改)。
12
+ */
13
+ import { useEffect, useState } from 'react'
14
+ import {
15
+ ROUTER_API_BASE,
16
+ type RouterCheckinResponse,
17
+ type RouterHealthResponse,
18
+ } from '../shared.ts'
19
+
20
+ /* ---------------- SVG 图标 ---------------- */
21
+
22
+ function Icon({ d, size = 14 }: { d: string; size?: number }): JSX.Element {
23
+ return (
24
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
25
+ <path d={d} />
26
+ </svg>
27
+ )
28
+ }
29
+
30
+ const I = {
31
+ check: 'M4 12.5l5 5L20 6.5',
32
+ loading: 'M12 3a9 9 0 1 0 9 9',
33
+ }
34
+
35
+ /** localStorage key:值 = 本地日期 YYYY-MM-DD。 */
36
+ const CHECKIN_STAMP_KEY = 'dsh-router:checkin-stamp'
37
+
38
+ /** 本地日切(跟用量统计的 localDateKey 同口径,别用 UTC)。 */
39
+ function todayKey(ts = Date.now()): string {
40
+ const d = new Date(ts)
41
+ const p = (n: number): string => String(n).padStart(2, '0')
42
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
43
+ }
44
+
45
+ function readStamp(): string | null {
46
+ try {
47
+ return localStorage.getItem(CHECKIN_STAMP_KEY)
48
+ } catch {
49
+ // 隐私模式 / 存储被禁:退化为「不记得」,不影响签到本身能用
50
+ return null
51
+ }
52
+ }
53
+
54
+ function writeStamp(): void {
55
+ try {
56
+ localStorage.setItem(CHECKIN_STAMP_KEY, todayKey())
57
+ } catch { /* 存不了就只影响这个提示,签到照常 */ }
58
+ }
59
+
60
+ interface CheckinCardProps {
61
+ /** 供应商健康快照(用来筛出支持 checkinNow 的)。 */
62
+ health: RouterHealthResponse | null
63
+ /** 签完回调,让概览刷新快照(积分会变)。 */
64
+ onRefresh: () => void
65
+ }
66
+
67
+ /** 可签到的供应商(能力 checkinNow)。 */
68
+ function checkinable(health: RouterHealthResponse | null): Array<{ id: string; name: string }> {
69
+ return (health?.suppliers ?? [])
70
+ .filter((s) => (s.capabilities ?? []).includes('checkinNow'))
71
+ .map((s) => ({ id: s.id, name: s.name }))
72
+ }
73
+
74
+ export function CheckinCard({ health, onRefresh }: CheckinCardProps): JSX.Element {
75
+ const [busy, setBusy] = useState(false)
76
+ const [stamp, setStamp] = useState<string | null>(null)
77
+ const [result, setResult] = useState<string>('')
78
+ const [error, setError] = useState<string>('')
79
+
80
+ // 挂载时读一次;跨天自动失效因为存的是日期串
81
+ useEffect(() => { setStamp(readStamp()) }, [])
82
+
83
+ const targets = checkinable(health)
84
+ const clickedToday = stamp === todayKey()
85
+
86
+ const run = async (): Promise<void> => {
87
+ if (busy || targets.length === 0) return
88
+ setBusy(true)
89
+ setError('')
90
+ setResult('')
91
+ let okCount = 0
92
+ let alreadyCount = 0
93
+ let totalCount = 0
94
+ const failures: string[] = []
95
+ // 顺序发:签到是有副作用的上游调用,并发会把上游风控招来(见 traework 9074)
96
+ for (const t of targets) {
97
+ try {
98
+ const res = await fetch(`${ROUTER_API_BASE}/suppliers/${t.id}/checkin`, { method: 'POST', cache: 'no-store' })
99
+ const data = await res.json() as RouterCheckinResponse
100
+ if (data.error !== undefined) {
101
+ failures.push(`${t.name}:${data.error}`)
102
+ continue
103
+ }
104
+ okCount += data.succeeded
105
+ alreadyCount += data.already
106
+ totalCount += data.total
107
+ if (!data.ok) {
108
+ const first = (data.results ?? []).find((r) => r.status !== 'ok' && r.status !== 'already')
109
+ failures.push(`${t.name}:${first?.message ?? '有链接签到失败'}`)
110
+ }
111
+ } catch (err) {
112
+ failures.push(`${t.name}:${(err as Error).message}`)
113
+ }
114
+ }
115
+ // 只要发出去过就记「今日已点」——点过就是点过,成败另说(文案也这么说)
116
+ writeStamp()
117
+ setStamp(todayKey())
118
+ const parts = [`${okCount}/${totalCount} 成功`]
119
+ if (alreadyCount > 0) parts.push(`${alreadyCount} 今日已签`)
120
+ setResult(parts.join(' · '))
121
+ if (failures.length > 0) setError(failures.join(';'))
122
+ onRefresh()
123
+ setBusy(false)
124
+ }
125
+
126
+ /**
127
+ * 第三行文案:所有状态/结果都压在这一行里,行数恒定。
128
+ * 优先级:失败 > 本次结果 > 今日已点 > 默认供应商数。
129
+ * 失败时截断显示(全量挂 title),不换行。
130
+ */
131
+ const hintText = (): string => {
132
+ if (targets.length === 0) return '没有支持签到的供应商'
133
+ if (busy) return '签到中…'
134
+ if (error !== '') return `失败 · ${error}`
135
+ if (result !== '') return result
136
+ return clickedToday ? `今日已点 · ${targets.length} 个供应商` : `${targets.length} 个供应商`
137
+ }
138
+
139
+ /** 悬停说明:成功/失败都在这儿补全,正文只放得下一行 */
140
+ const hintTitle = (): string => {
141
+ if (targets.length === 0) return '没有供应商实现了 checkinNow 能力'
142
+ const lines = [`对 ${targets.length} 个支持签到的供应商逐个签到:${targets.map((t) => t.name).join('、')}`]
143
+ if (clickedToday) lines.push('今天在这个浏览器点过签到(不代表上游一定签上了)')
144
+ if (result !== '') lines.push(`上次结果:${result}`)
145
+ if (error !== '') lines.push(`失败:${error}`)
146
+ return lines.join('\n')
147
+ }
148
+
149
+ return (
150
+ <div
151
+ className={`dshr-statCard dshr-statCard-checkin${clickedToday ? ' dshr-statCard-done' : ''}`}
152
+ title={hintTitle()}
153
+ >
154
+ <span className="dshr-statLabel">签到</span>
155
+ {targets.length === 0
156
+ ? (
157
+ <span className="dshr-statValue">—</span>
158
+ )
159
+ : (
160
+ <button
161
+ type="button"
162
+ className={`dshr-checkinButton${busy ? ' dshr-checkinButton-busy' : ''}`}
163
+ onClick={() => { void run() }}
164
+ disabled={busy}
165
+ title={hintTitle()}
166
+ >
167
+ <span className={busy ? 'dshr-spin' : undefined}>
168
+ <Icon d={busy ? I.loading : I.check} size={14} />
169
+ </span>
170
+ {busy ? '签到中…' : '一键签到'}
171
+ </button>
172
+ )}
173
+ {/* 恒定第三行:状态/结果全塞进 hint,不成功后多出一行
174
+ —— 汇总卡是 3 行结构(label / value / hint),多一行就跟邻卡不齐 */}
175
+ <span className={`dshr-statHint${error !== '' ? ' dshr-statHint-err' : ''}`} title={error !== '' ? error : undefined}>
176
+ {hintText()}
177
+ </span>
178
+ </div>
179
+ )
180
+ }
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * 路由系统 (Routing System) panel — a simplified 9router.
3
3
  *
4
- * Three tabs fed by the host half (/router/api/*), mirroring 9router's
5
- * dashboard: 供应商 (supplier cards → detail: accounts + add-account + models),
6
- * 组合 (combo fallback chains), 端点与密钥 (endpoint URL + API keys + auth).
4
+ * Four tabs fed by the host half (/router/api/*), mirroring 9router's
5
+ * dashboard: 概览 (usage), 供应商 (supplier cards → detail: accounts +
6
+ * add-account + models), 组合 (combo fallback chains),
7
+ * 端点与密钥 (endpoint URL + API keys + auth).
7
8
  *
8
- * Masthead: 返回会话 button on the LEFT, refresh on the right.
9
- * No online/offline concept — suppliers render whatever state they have.
9
+ * **挂载在「设置」里**(`settings.section` 座位),所以布局规则跟中心栏劫持时期不同:
10
+ * - 没有 masthead / 返回会话按钮 —— 设置面板自带标题栏和关闭
11
+ * - **tab 条固定、只有内容区滚**:根节点限高 `height:100%`,内容区
12
+ * `flex:1 + min-height:0 + overflow-y:auto` 自己滚。
13
+ * 根节点**必须限高** —— 不限高内容会把外壳撑开,退化成整页滚、
14
+ * tab 条跟着滚上去
15
+ * - 根节点外面套了一层 `display: contents`(宿主 slot renderer 给的),
16
+ * 不过实测 `height:100%` 仍能穿过它解析到外壳高度
17
+ * - 内容宽度实测 564px(面板 612 − 左右 padding 24),卡片 grid 按这个宽度排
10
18
  */
11
19
  import { useEffect, useRef, useState, type ReactNode } from 'react'
12
20
  import {
13
21
  ROUTER_API_BASE,
14
- ROUTER_TITLE,
15
22
  type RouterCombosResponse,
16
23
  type RouterHealthResponse,
17
24
  type RouterStatusResponse,
@@ -38,8 +45,11 @@ async function getJson<T>(path: string): Promise<T> {
38
45
  }
39
46
 
40
47
  interface RouterViewProps {
41
- /** 关闭面板、返回会话。 */
42
- onBack: () => void
48
+ /**
49
+ * 设置页不提供「返回会话」(关闭由设置面板自己负责),所以这里是可选的:
50
+ * 中心栏劫持那套老挂载方式还留着时会传。
51
+ */
52
+ onBack?: () => void
43
53
  }
44
54
 
45
55
  export function RouterView({ onBack }: RouterViewProps): JSX.Element {
@@ -93,7 +103,15 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
93
103
  <section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
94
104
  <div className="dshr-supplierRow">
95
105
  {supplier.icon !== undefined && (
96
- <img className="dshr-supplierIcon" src={supplier.icon} alt="" />
106
+ <img
107
+ className="dshr-supplierIcon"
108
+ src={supplier.icon}
109
+ alt=""
110
+ // 加载失败就藏起来(别留碎图)。图标不是关键信息,
111
+ // 旁边有供应商名字。历史上的坑:icon 曾是
112
+ // http://localhost:20128/... 这种外链,9router 没跑就全是坏图。
113
+ onError={(e) => { e.currentTarget.style.display = 'none' }}
114
+ />
97
115
  )}
98
116
  <div className="dshr-supplierName">{supplier.name}</div>
99
117
  </div>
@@ -112,40 +130,34 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
112
130
  }
113
131
 
114
132
  return (
115
- <div className="dshr-shell">
116
- <header className="dshr-masthead">
117
- <button type="button" className="dshr-backButton" onClick={onBack} title="返回会话">
118
- <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
119
- <path d="M10 3 5 8l5 5" />
120
- </svg>
121
- <span>返回会话</span>
122
- </button>
123
- <div className="dshr-brand">
124
- <div className="dshr-brandTitle">{ROUTER_TITLE}</div>
125
- </div>
126
- </header>
127
-
128
- <nav className="dshr-nav">
129
- <button type="button" aria-current={tab === 'overview' ? 'page' : undefined} onClick={() => setTab('overview')}>
133
+ <div className="dshr-settings">
134
+ {/* tab 切换:设置页已有页面标题,这里只做二级切换 */}
135
+ <nav className="dshr-tabs" role="tablist" aria-label="路由分区">
136
+ <button type="button" role="tab" aria-selected={tab === 'overview'} className={`dshr-tab${tab === 'overview' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('overview')}>
130
137
  概览
131
138
  </button>
132
- <button type="button" aria-current={tab === 'suppliers' ? 'page' : undefined} onClick={() => setTab('suppliers')}>
139
+ <button type="button" role="tab" aria-selected={tab === 'suppliers'} className={`dshr-tab${tab === 'suppliers' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('suppliers')}>
133
140
  供应商 <span className="dshr-navCount">{suppliers.length}</span>
134
141
  </button>
135
- <button type="button" aria-current={tab === 'combos' ? 'page' : undefined} onClick={() => setTab('combos')}>
142
+ <button type="button" role="tab" aria-selected={tab === 'combos'} className={`dshr-tab${tab === 'combos' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('combos')}>
136
143
  组合 <span className="dshr-navCount">{combos.length}</span>
137
144
  </button>
138
- <button type="button" aria-current={tab === 'endpoint' ? 'page' : undefined} onClick={() => setTab('endpoint')}>
145
+ <button type="button" role="tab" aria-selected={tab === 'endpoint'} className={`dshr-tab${tab === 'endpoint' ? ' dshr-tab-on' : ''}`} onClick={() => setTab('endpoint')}>
139
146
  端点与密钥
140
147
  </button>
148
+ {/* 返回会话只在老的中心栏挂载方式下出现(设置页有关闭按钮) */}
149
+ {onBack !== undefined && (
150
+ <button type="button" className="dshr-tabBack" onClick={onBack}>返回会话</button>
151
+ )}
141
152
  </nav>
142
153
 
143
- <div className="dshr-content">
154
+ <div className="dshr-settingsBody">
144
155
  {/* ---------------- 概览(用量看板) ---------------- */}
145
156
  {tab === 'overview' && (
146
157
  <StatsTab
147
158
  active
148
159
  refreshing={refreshing}
160
+ health={snapshot.health}
149
161
  onRefresh={() => void refresh()}
150
162
  />
151
163
  )}
@@ -25,11 +25,13 @@ import {
25
25
  ROUTER_API_BASE,
26
26
  type RouterChartBucket,
27
27
  type RouterChartResponse,
28
+ type RouterHealthResponse,
28
29
  type RouterPeriod,
29
30
  type RouterRankRow,
30
31
  type RouterStatsResponse,
31
32
  type RouterUsageRecord,
32
33
  } from '../shared.ts'
34
+ import { CheckinCard } from './CheckinCard.tsx'
33
35
 
34
36
  /* ---------------- SVG 图标 ---------------- */
35
37
 
@@ -366,9 +368,14 @@ interface StatsTabProps {
366
368
  refreshing: boolean
367
369
  /** 掩码:面板是否可见(不可见时不做周期轮询)。 */
368
370
  active: boolean
371
+ /**
372
+ * 供应商健康快照:签到卡要按 `checkinNow` 能力筛出可签到的供应商。
373
+ * 不传就不显示签到卡(老的中心栏挂载方式下 RouterView 也会传,这里留可选)。
374
+ */
375
+ health?: RouterHealthResponse | null
369
376
  }
370
377
 
371
- export function StatsTab({ onRefresh, refreshing, active }: StatsTabProps): JSX.Element {
378
+ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProps): JSX.Element {
372
379
  const [period, setPeriod] = useState<RouterPeriod>('today')
373
380
  const [stats, setStats] = useState<RouterStatsResponse | null>(null)
374
381
  const [chart, setChart] = useState<RouterChartBucket[]>([])
@@ -489,6 +496,9 @@ export function StatsTab({ onRefresh, refreshing, active }: StatsTabProps): JSX.
489
496
  hint={(s?.avgTtfbMs ?? 0) > 0 ? `首字节 ${fmtDuration(s?.avgTtfbMs)}` : undefined}
490
497
  tone="warn"
491
498
  />
499
+ {/* 签到卡放在最后:它是操作(按钮)不是指标,但跟指标同宽同高度,
500
+ 排在统计卡后面不会让人误以为是又一个 token 计数 */}
501
+ {health !== undefined && <CheckinCard health={health} onRefresh={onRefresh} />}
492
502
  </div>
493
503
 
494
504
  {estimated && (
@@ -560,7 +560,15 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
560
560
  <div className="dshr-providerTitleRow">
561
561
  <div className="dshr-providerIcon">
562
562
  {supplier.icon !== undefined
563
- ? <img className="dshr-providerImg" src={supplier.icon} alt="" />
563
+ ? (
564
+ <img
565
+ className="dshr-providerImg"
566
+ src={supplier.icon}
567
+ alt=""
568
+ // 同上:失败就藏,露出备用图标位置(不显示碎图)
569
+ onError={(e) => { e.currentTarget.style.display = 'none' }}
570
+ />
571
+ )
564
572
  : <Icon d={I.link} size={22} />}
565
573
  </div>
566
574
  <div className="dshr-providerMeta">
@@ -1,18 +1,68 @@
1
1
  /**
2
- * Client half of dsh-router: mounts the 路由系统 sidebar entry and its
3
- * center-column panel. Pure DOM + fetch — no injected DSH services needed:
4
- * the panel reads /router/api/* (same-origin), which the host half serves.
2
+ * Client half of dsh-router.
3
+ *
4
+ * 挂载点是「设置 → 路由」(官方 `settings.section` 座位),不再是侧边栏
5
+ * DOM 注入 + 中心栏劫持。`slots` / `locale` 由宿主运行时提供(profile 的
6
+ * node_modules 里 @deepseek-ai 是空的:这些包不入 node_modules,按
7
+ * package.json 的 dsh.client.inject 声明即可)。
8
+ *
9
+ * 拿不到 slots 时(老宿主)回退到原来的中心栏面板,不至于整个插件失效。
5
10
  */
6
11
  import { mountRouterWorkspace } from './workspace-mount.tsx'
12
+ import { RouterSettingsSection } from './settings-section.tsx'
13
+ import { registerSettingsNavIcon } from './settings-nav-icon.ts'
7
14
  import './router.css'
8
15
 
9
- /** No hard service dependencies; the web shell injects react/cordis. */
10
- export const inject: string[] = []
16
+ /**
17
+ * 设置导航里这一页的显示名。同时是注册 label 和「认领图标」时匹配的文案,
18
+ * 两处必须一致 —— 认领逻辑按可见文案找自己那一行(见 settings-nav-icon.ts)。
19
+ */
20
+ const SECTION_LABEL = '路由'
21
+
22
+ /**
23
+ * 必须声明:Cordis 的 ctx 是 Proxy,未声明就访问 `ctx.slots` 会直接抛
24
+ * `cannot get property "slots" without inject`(实测错误,不是理论风险)——
25
+ * 连 `ctx.slots === undefined` 这种防御性判断都跑不到,getter 先炸。
26
+ */
27
+ export const inject: string[] = ['slots']
28
+
29
+ interface SlotsFace {
30
+ inject: (key: string, cb: () => unknown) => () => void
31
+ register: (reg: Record<string, unknown>, component: unknown) => unknown
32
+ }
33
+
34
+ interface Ctx {
35
+ slots?: SlotsFace
36
+ effect: (fn: () => (() => void) | void, label?: string) => void
37
+ }
38
+
39
+ /** 挂载「设置 → 路由」页;拿不到 slots 时返回 undefined 表示跳过。 */
40
+ function mountSettingsSection(ctx: Ctx): (() => void) | undefined {
41
+ const slots = ctx.slots
42
+ if (slots === undefined) return undefined
43
+ // order 10 = 与「模型」「远程访问」同值,靠注册顺序定先后,实测排成
44
+ // 模型 → 路由 → 远程访问(正是要的位置)。
45
+ // 排序规则(宿主实现):priority ?? 0 优先,再 order ?? 0,数值升序,
46
+ // 同值保持注册顺序。所以想插在两个同 order 的条目中间是做不到的——
47
+ // 试过 9.99(跑到模型上面)、10.001/10.05/11(都跑到远程访问后面)。
48
+ // 只能取同值,让注册顺序决定。
49
+ return slots.inject('settings.section', () => slots.register({
50
+ name: 'settings.section',
51
+ id: 'dsh-router',
52
+ order: 10,
53
+ label: SECTION_LABEL,
54
+ }, RouterSettingsSection))
55
+ }
11
56
 
12
- /** Mount the routing-system workspace once the browser context is ready. */
13
- export function apply(ctx: { effect: (fn: () => () => void, label?: string) => void }): void {
14
- ctx.effect(
15
- () => mountRouterWorkspace(),
16
- 'dsh-router: routing system workspace',
17
- )
57
+ export function apply(ctx: Ctx): void {
58
+ const disposeSettings = mountSettingsSection(ctx)
59
+ if (disposeSettings === undefined) {
60
+ // 老宿主(无 slots):回退到侧边栏入口 + 中心栏面板
61
+ console.warn('[dsh-router] 宿主未提供 slots,回退到侧边栏面板')
62
+ ctx.effect(() => mountRouterWorkspace(), 'dsh-router: routing system workspace')
63
+ return
64
+ }
65
+ ctx.effect(() => disposeSettings, 'dsh-router: settings section')
66
+ // 换掉宿主的默认齿轮(契约没有 icon 字段,只能注册后认领自己的行)
67
+ ctx.effect(() => registerSettingsNavIcon(SECTION_LABEL), 'dsh-router: settings nav icon')
18
68
  }