# dsh-WeCom-notify 开发经验(Development Notes) > 本文沉淀 v0.3.0(设置面板 + 多 webhook key 同时通知)开发过程中的关键知识, > 尤其是「DSH 插件 client 设置面板」的正确姿势与踩坑记录。 > 面向后续维护者与想给 dsh 写「设置页插件」的开发者。 --- ## 1. 插件 client 设置面板的正确姿势(本次最大坑) dsh 的「设置」页(设置 → 各 section)由 `settings.section` 插槽承载: `kind: 'list'`、`scope: 'root'`(契约见 `@deepseek-ai/dsh-client-ui-settings` 的 `contract/slots.d.ts`)。 **注册契约:component 是 React 组件,且必须是 `register()` 的第二参数:** ```ts ctx.slots.inject('settings.section', () => ctx.slots.register( { name: 'settings.section', id: 'wecom-notify', // section 导航 key order: 40, // 导航排序 label: () => '企业微信通知', // 显示名(可函数跟随 locale) }, WecomNotifyPanel, // ⚠️ React 组件作第二参数! ), ) ``` **❌ 错误写法(曾导致面板空白)**:照抄 dsh-super-injector 的 ```ts ctx.slots.register({ ..., component: () => ({ render() {...} }) }) // 单参数! ``` 两个问题叠加: 1. `SlotRegistry.register(rawOptions, component)` 的组件在**第二参数**;单参调用时 component 恒为 `undefined`; 2. 即便传入,`() => ({ render() })` 返回普通对象,也不是合法 ReactNode。 web-react renderer 渲染时执行 `jsx(Comp, props)`,失败被 SlotErrorBoundary 吞掉 → **导航项「企业微信通知」正常显示(label 在 options 里),内容区空白**。诊断要点: 导航 label 出现 ≠ 组件渲染成功。 > 佐证:官方 `dsh-client-ui-settings-plugins` 的 `PluginsSettingsSection` 就是 > `register(options, ReactComponent)` 形态。super-injector 的「插件」面板用的 > 错误写法,实际同样空白。 **命令式 DOM 逻辑保留方案**(不必重写成全 React):React 组件外壳 + `useEffect` 挂载: ```tsx function WecomNotifyPanel() { const hostRef = useRef(null) useEffect(() => { const root = hostRef.current if (!root) return const page = buildPage() // 原有 document.createElement 构建逻辑 root.appendChild(page) return () => { root.textContent = '' } }, []) return createElement('div', { ref: hostRef }) } ``` client bundle 可直接 `import { useEffect, useRef } from 'react'`(tsdown 保持 `require("react")` external,运行时由 dsh web 提供——官方插件同款)。 ## 2. client 面板必须「包名装配」,file:// 入口带不上 浏览器端 client 模块由 **`@deepseek-ai/dsh-client-modules`** 发现:它扫描 host Loader entries 中**声明了 `dsh.client` 的包**(`entry.options.name` 必须等于包名, 且有活跃 fiber 且未 disabled),读取 `package.json`: - `dsh.client: { platform: 'web', inject: [...] }` → 进入 manifest - `exports["./client"]` → 定位 `lib/client.js` 路径 - manifest 的 id = 包名,浏览器加载 `/plugins/<包名>/client.js` 因此: - ❌ patch 挂载 `name: 'file:///.../lib/index.js'`:entry 不是包 → 永远没有 client 面板(host 功能完整,仅无 UI)。 - ✅ 包名装配:junction 链接到 profile node_modules + bundles/dependencies 声明 (见 README「方式 A」)。`cordis.patch.yml` 里 entry 的 `name` 也要写包名。 `ModuleLoader.load({ id: '包名', ... })` 的 id 必须与包名一致(tsdown banner 里的 `PLUGIN_ID` 就是包名),否则 manifest id 与注册 id 对不上。 ## 3. tsdown client bundle 模板 ```ts // tsdown.config.ts const clientBundle: UserConfig = { entry: { client: 'src/client/index.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false, deps: { neverBundle: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-runtime/client'], alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id), }, outputOptions: { entryFileNames: 'client.js', banner: 'window.__ModuleLoader__.load({ id: ' + JSON.stringify(PLUGIN_ID) + ', factory: (require) => {', footer: 'return module.exports; } });', intro: 'var module = { exports: {} }; var exports = module.exports;', codeSplitting: false, }, } ``` - 产物是 CJS + ModuleLoader banner(`module.exports` 里应有 `inject` 与 `apply`)。 - 类型检查:host 的 tsconfig `exclude: ["src/client"]`;client 单独 `tsconfig.client.json`(`lib: ["es2022", "dom"]`、`types: []`)。react 类型 解析依赖构建时的 junction link,缺失时 typecheck fails-soft(不影响产物)。 ## 4. host 侧 webServer API ```ts export const inject = ['tools', 'webServer'] // 声明服务 ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/wecom-notify/api', handler: async (req, res) => { /* ... */ }, }), 'wecom-notify: settings-api') ``` - `register` **返回 disposer**(删除路由),配合 `ctx.effect` 卸载即清理。 - `req`/`res` 是 Node http 的 IncomingMessage/ServerResponse;读 body 用 `for await (const c of req)`。 - 浏览器端同源 `fetch('/wecom-notify/api/config')`(相对路径)即可。 - `ctx.webServer` 不在 `@deepseek-ai/cordis` 基础类型里:`declare module '@deepseek-ai/cordis' { interface Context { webServer: ... } }` 声明合并。 ## 5. 配置设计:持久化文件 > cordis config > 环境变量 - 设置面板保存 → `/wecom-notify/config.json`(0600,含密钥)。 DSH_HOME 解析:`process.env.DSH_HOME || ~/.dsh`。 - **文件存在时整体优先**(保存的是完整生效快照,面板里清空 = 显式清空); 删文件即回到静态配置。启动时 `applyStored(loadConfig(config), readStoredConfig())`。 - **热更新**:notifier 持有可变配置引用,`updateConfig()` 整体替换—— 保存即生效,无需重启(配置 API 与发送调度解耦)。 - 兼容旧字段:单 `webhookKey`/`WECHAT_WEBHOOK_KEY` 自动并入 `webhookKeys` 数组。 ## 6. 开发环境隔离(DSH_HOME) 全局 profile 动不得时,用独立开发环境: ```bash DSH_HOME=/path/to/.dsh-dev dsh web --port 3081 # 与全局 3080 错开 ``` - `.dsh-dev` 里建 `profiles/web/`(package.json + cordis.patch.yml), 装配方式与正式 profile 完全一致,但全部落在工作区,不污染 `~/.dsh`。 - 验证链:`curl /wecom-notify/api/config`(GET/POST)→ 真实发送 → 重启看持久化 `source: "file"` → `curl /plugins/<包名>/client.js`(面板资源 200)。 ## 7. cordis.patch.yml 结构(loader patch 语义) 顶层必须是 **YAML 数组**(`[]` 合法空态),元素两类: ```yaml # ① insert:插入新条目(无 id 则追加到顶层列表) - insert: - id: wechat-notify name: 'dsh-wecom-notify' # ⚠️ bundle patch 里必须写包名! config: {} # ② 按 id 覆盖 / 禁用(applyEntryPatches:定位 → 合并字段;name 可做校验) - id: wechat-notify disabled: true ``` - 语义真源:`dsh-app-boot` 的 `applyEntryPatches`(id 定位、insert 追加、name 不匹配跳过、匹配不到警告)。 - **bundle patch(插件自带 cordis.patch.yml)里 entry name 写相对路径是错的**: 相对路径基于 **profile 目录**解析(`/lib/index.js`)——必须写包名, 由 profile node_modules 的 junction/依赖解析。 - `disabled` 支持 `!!js` 表达式(可对 env 求值)。 ## 8. 工具链教训 | 坑 | 现象 | 对策 | |---|---|---| | `dev_uninject_plugin` 会改写 profile patch | 曾把 `- insert:` 与子条目压成一行 → YAML 解析崩溃,dsh 命令全挂 | 动 patch 前先备份;卸载走完立刻核对文件;损坏就恢复备份 | | `npm install` 404(tsdown 传递依赖 `@deepseek-ai/dsh-compact` 未发布) | 装不上 tsdown | build.sh 自动回退 `DSH_CHECKOUT/node_modules/.bin/tsdown` | | Node ≥22.6 直跑 .ts(node --test / scripts) | `a ?? b \|\| c` 报 ERR_INVALID_TYPESCRIPT_SYNTAX | `??` 与 `\|\|` 混用必须加括号 | | 事件监听器抛异常会炸事件循环 | 插件崩溃 | 监听器体包 try/catch,永不抛出 | | client 类型漂移(`SlotsService` 等) | 依赖包版本升级即破 | 按使用面自写结构类型,构建不校验类型、运行时以实际服务为准 | ## 9. 验收清单(改完插件跑一遍) ```bash npm test # 51 用例 npm run typecheck # host 类型 DSH_CHECKOUT= npm run build # host + client + client typecheck(fails-soft) # 开发实例验证 DSH_HOME= dsh web --port 3081 curl /wecom-notify/api/config # GET 生效配置 curl -X POST /wecom-notify/api/config -d '{...}' # 保存(热生效 + 持久化) curl -X POST /wecom-notify/api/test -d '{...}' # 真实发送,逐群结果 curl /plugins/<包名>/client.js # 面板资源 200 # 重启实例 → GET /config 应显示 source: "file"(持久化恢复) ```