checkpoint: dsh-worktable v2 稳定版(PRD §10 全部定案并实现)

This commit is contained in:
Aisland
2026-08-16 23:34:22 +08:00
commit d58cbc583b
20 changed files with 2073 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
lib/
*.log
03_local/
.DS_Store
Thumbs.db
+10
View File
@@ -0,0 +1,10 @@
# dsh-worktable 项目索引
- [README](../README.md) — 项目概述与目录结构
- [PRD](../02_process/PRD.md) — 需求与设计(含接手分界线 §11)
- [AGENTS](../AGENTS.md) — 项目规则
- [01_content](../01_content) — 插件包
- [02_process](../02_process) — 过程文档
- [03_local](../03_local) — 本机脚本/日志(不入库)
- [04_test](../04_test) — 测试产物
+61
View File
@@ -0,0 +1,61 @@
/**
* dsh-worktable 构建:
* - lib/index.js 服务端 ESM(cordis 插件,健康路由)
* - lib/client.js 客户端单文件 CJS(window.__ModuleLoader__.load 握手;
* react / @deepseek-ai/* 由宿主模块系统提供,保持 external)
* esbuild 优先用本包 node_modules 的平台二进制,其次复用相邻项目 vendor。
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
mkdirSync(join(here, 'lib'), { recursive: true })
const esbuildCandidates = [
join(here, 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe'),
join(here, '..', '..', 'dsh-travelatlas', 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe'),
]
const esbuildBin = esbuildCandidates.find((p) => existsSync(p))
if (!esbuildBin) {
console.error('[dsh-worktable build] 找不到 esbuild.exe:请先在 01_content 运行 npm install')
process.exit(1)
}
console.log('[dsh-worktable build] esbuild: ' + esbuildBin)
const clientBanner = {
js: "window.__ModuleLoader__.load({ id: 'dsh-worktable', factory: (require) => { var module = { exports: {} }; var exports = module.exports;",
}
const clientFooter = { js: 'return module.exports; } });' }
const jobs = [
{
entryPoints: [join(here, 'src/index.ts')], outfile: 'lib/index.js',
platform: 'node', format: 'esm', target: ['node22'],
external: ['@deepseek-ai/*', 'node:*'],
},
{
entryPoints: [join(here, 'src/client/index.tsx')], outfile: 'lib/client.js',
platform: 'browser', format: 'cjs', target: ['es2022'], jsx: 'automatic',
external: ['@deepseek-ai/*', 'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'scheduler'],
banner: clientBanner, footer: clientFooter,
},
]
for (const job of jobs) {
const args = [
...job.entryPoints,
'--bundle', '--sourcemap', '--log-level=info',
'--platform=' + job.platform,
'--format=' + job.format,
'--target=' + job.target.join(','),
'--outfile=' + join(here, job.outfile),
...job.external.map((e) => '--external:' + e),
]
if (job.jsx) args.push('--jsx=' + job.jsx)
if (job.banner) args.push('--banner:js=' + job.banner.js)
if (job.footer) args.push('--footer:js=' + job.footer.js)
execFileSync(esbuildBin, args, { stdio: 'inherit' })
}
console.log('[dsh-worktable build] done: lib/index.js, lib/client.js')
+4
View File
@@ -0,0 +1,4 @@
# dsh-worktable 服务端插件:注册 /api/worktable/health 健康路由
- insert:
- id: dsh-worktable
name: dsh-worktable
+12
View File
@@ -0,0 +1,12 @@
{
"name": "dsh-worktable",
"description": "工作台:DSH 侧边栏的 agent 级项目容器,收纳如 TravelAtlas 等自装项目",
"version": "0.1.0",
"entry": {
"name": "dsh-worktable",
"inject": []
},
"client": {
"platform": "web"
}
}
+512
View File
@@ -0,0 +1,512 @@
{
"name": "dsh-worktable",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-worktable",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"esbuild": "^0.25.0"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-host-webserver": "*"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {
"optional": true
},
"@deepseek-ai/dsh-host-webserver": {
"optional": true
}
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "dsh-worktable",
"description": "工作台 Worktable:DeepSeek Harness 侧边栏的 agent 级项目容器(应用抽屉)",
"version": "0.1.0",
"license": "MIT",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"files": [
"lib",
"cordis.patch.yml",
"dsh.plugin.json",
"README.md",
"LICENSE"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-locale"
]
}
},
"scripts": {
"build": "node build.mjs",
"check": "node --check lib/index.js && node --check lib/client.js"
},
"peerDependencies": {
"@deepseek-ai/cordis": "*",
"@deepseek-ai/dsh-host-webserver": "*",
"@deepseek-ai/dsh-client-locale": "*"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis": {
"optional": true
},
"@deepseek-ai/dsh-host-webserver": {
"optional": true
},
"@deepseek-ai/dsh-client-locale": {
"optional": true
}
},
"devDependencies": {
"esbuild": "^0.25.0"
}
}
+736
View File
@@ -0,0 +1,736 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { css } from './styles'
import { NS, zh, en, type WorktableKey } from './locales'
/**
* dsh-worktable 客户端(v2):侧边栏底部「工作台」区块。
* 结构:分隔线 → [≡ 手柄][工作台][搜索/视图选项/添加+] → 项目卡片区(子座位)。
* v2 新增(PRD §10 定案):
* - 卡片规范 v2 渐进上报协议:owner props 下发 order/hidden/nameOverrides,
* 卡片可选上报 reportMeta/reportUsed;v1 卡片零改动兼容(按注册序排在最前)。
* - 视图选项只留「排序:手动/最近」(分类已按用户定案取消)。
* - 「管理项目…」编辑模式:改名/隐藏/排序(拖拽 + ↑↓),全存 localStorage。
* - 「+」:接入指引 + 本地快捷方式(名称/图标/链接,点击新标签打开)。
* - 完整 zh/en 词典接入 dsh-client-locale(NS 'worktable')。
* 持久化:dsh.worktable.view.v1(视图)+ dsh.worktable.projects.v1(项目元状态)。
*/
type OrderBy = 'manual' | 'recent'
type DockMode = 'footer' | 'float'
type ViewState = {
query: string
searchOpen: boolean
orderBy: OrderBy
dock: DockMode
floatTop: number | null
}
/** 卡片上报的项目元信息(协议 v2)。 */
export type ProjectMeta = { id: string; name: string; icon?: string }
/** 本地快捷方式(仅存 localStorage)。 */
export type Shortcut = { id: string; name: string; icon: string; href: string }
type ProjectsState = {
/** 手动排序的 id 序列(新注册 id 未出现在其中时追加在尾部)。 */
order: string[]
/** id → 最近使用时间戳(毫秒),来源:卡片点击时 reportUsed。 */
lastUsed: Record<string, number>
/** 被隐藏的项目 id 集。 */
hidden: string[]
/** 显示名覆盖(编辑模式改名;空值删除覆盖)。 */
nameOverrides: Record<string, string>
/** 本地快捷方式条目。 */
shortcuts: Shortcut[]
}
const PERSIST_KEY = 'dsh.worktable.view.v1'
const PROJECTS_KEY = 'dsh.worktable.projects.v1'
const MIN_TOP = 56
const SNAP_PX = 32
/** 插件市场外链(GitHub 仓库,已核实可访问;PRD 提及的 dshfind.com 未验证,不用死链)。 */
const MARKET_URL = 'https://github.com/hikariming/dshfind'
/** 已报到卡片的 CSS order 偏移:未上报(order=0)的 v1 卡片永远排在已上报卡片之前。 */
const ORDER_OFFSET = 1000
/** 落点判定余量:松手时指针越出有效落点区(超出底部/顶部/侧边)即视为「无有效落点」。 */
const OVER_BOTTOM_PX = 24
const OVER_TOP_PX = 24
const OVER_SIDE_PX = 80
const DEFAULT_VIEW: ViewState = {
query: '',
searchOpen: false,
orderBy: 'manual',
dock: 'footer',
floatTop: null,
}
const DEFAULT_PROJECTS: ProjectsState = {
order: [],
lastUsed: {},
hidden: [],
nameOverrides: {},
shortcuts: [],
}
function loadView(): ViewState {
try {
const raw = localStorage.getItem(PERSIST_KEY)
if (!raw) return { ...DEFAULT_VIEW }
const p = JSON.parse(raw)
// 显式挑字段:旧版遗留的 groupBy 等未知字段直接忽略。
return {
query: typeof p.query === 'string' ? p.query : '',
searchOpen: p.searchOpen === true,
orderBy: p.orderBy === 'recent' ? 'recent' : 'manual',
dock: p.dock === 'float' ? 'float' : 'footer',
floatTop: typeof p.floatTop === 'number' ? p.floatTop : null,
}
} catch {
return { ...DEFAULT_VIEW }
}
}
function loadProjects(): ProjectsState {
try {
const raw = localStorage.getItem(PROJECTS_KEY)
if (!raw) return { ...DEFAULT_PROJECTS }
const p = JSON.parse(raw)
return {
order: Array.isArray(p.order) ? p.order.filter((x: unknown): x is string => typeof x === 'string') : [],
lastUsed: p.lastUsed && typeof p.lastUsed === 'object' ? p.lastUsed : {},
hidden: Array.isArray(p.hidden) ? p.hidden.filter((x: unknown): x is string => typeof x === 'string') : [],
nameOverrides: p.nameOverrides && typeof p.nameOverrides === 'object' ? p.nameOverrides : {},
shortcuts: Array.isArray(p.shortcuts)
? p.shortcuts.filter((s: any) => s && typeof s.id === 'string' && typeof s.name === 'string' && typeof s.href === 'string')
: [],
}
} catch {
return { ...DEFAULT_PROJECTS }
}
}
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi)
type FloatRect = { top: number }
/** 从组件挂载点向上找 sidebar 容器:className 含 SidebarRoot/sidebar,或标签 aside/nav,到 body 为止。 */
function findSidebar(start: HTMLElement | null): HTMLElement | null {
let el: HTMLElement | null = start
while (el && el !== document.body) {
const tag = el.tagName.toLowerCase()
if (tag === 'aside' || tag === 'nav') return el
if (typeof el.className === 'string' && /SidebarRoot|sidebar/i.test(el.className)) return el
el = el.parentElement
}
return null
}
/** 子座位注册 id 序列(模块级 store;apply 里订阅 slots 变化写入)。 */
const registryStore: { ids: string[]; listeners: Set<() => void> } = { ids: [], listeners: new Set() }
function WorktableSection(props: any) {
const wide = props.wide !== false
const renderProjectSlot = typeof props.renderSlot === 'function' ? props.renderSlot : null
/** locale 座席 t;宿主未安装 locale 服务时回退 zh 词典(保持独立可用)。 */
const t = (key: WorktableKey, params?: Record<string, string>): string => {
if (typeof props.t === 'function') {
try {
return props.t(key, params)
} catch {
/* 回退 zh */
}
}
let s: string = zh[key] ?? key
if (params) for (const [k, v] of Object.entries(params)) s = s.replace('{' + k + '}', v)
return s
}
const [view, setView] = useState<ViewState>(loadView)
const [projects, setProjects] = useState<ProjectsState>(loadProjects)
const [metas, setMetas] = useState<Record<string, ProjectMeta>>({})
const [registeredIds, setRegisteredIds] = useState<string[]>(() => [...registryStore.ids])
const [managing, setManaging] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [viewOptionsOpen, setViewOptionsOpen] = useState(false)
const [scName, setScName] = useState('')
const [scIcon, setScIcon] = useState('')
const [scHref, setScHref] = useState('')
const [scError, setScError] = useState(false)
const [float, setFloat] = useState<FloatRect | null>(() =>
view.dock === 'float' && view.floatTop != null ? { top: view.floatTop } : null,
)
const rootRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{ startY: number; startX: number; startRect: DOMRect; dragging: boolean; prevFloat: FloatRect | null } | null>(null)
const dragIdRef = useRef<string | null>(null)
const [railRect, setRailRect] = useState<{ left: number; width: number } | null>(null)
const [bottomInset, setBottomInset] = useState(0)
const bottomInsetRef = useRef(0)
const [floatGeo, setFloatGeo] = useState<{ left: number; width: number | null } | null>(null)
const floatRef = useRef<FloatRect | null>(null)
const persistView = (patch: Partial<ViewState>) => {
setView((prev) => {
const next = { ...prev, ...patch }
try { localStorage.setItem(PERSIST_KEY, JSON.stringify(next)) } catch {}
return next
})
}
const persistProjects = (patch: Partial<ProjectsState> | ((prev: ProjectsState) => ProjectsState)) => {
setProjects((prev) => {
const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch }
try { localStorage.setItem(PROJECTS_KEY, JSON.stringify(next)) } catch {}
return next
})
}
// 子座位注册变化 → 刷新 id 序列
useEffect(() => {
const sync = () => setRegisteredIds([...registryStore.ids])
registryStore.listeners.add(sync)
return () => { registryStore.listeners.delete(sync) }
}, [])
// 侧边栏折叠/展开:保持原停靠位置(不再回弹 footer);折叠态由项目图标框承载。
// 折叠且浮动时:等折叠动画结束(320/750ms 双次重测,取收敛后的几何)再以 fixed 定位
// 图标框到拖前高度——避免在过渡帧上测得展开态宽度导致图标框偏离窄栏中心。
const measureRailRect = useCallback(() => {
const el = rootRef.current
if (!el) return
const r = el.getBoundingClientRect()
if (r.width > 0) {
setRailRect((prev) => (prev && Math.abs(prev.left - r.left) < 1 && Math.abs(prev.width - r.width) < 1 ? prev : { left: r.left, width: r.width }))
}
}, [])
useLayoutEffect(() => {
if (!wide) {
const t1 = window.setTimeout(measureRailRect, 320)
const t2 = window.setTimeout(measureRailRect, 750)
return () => { window.clearTimeout(t1); window.clearTimeout(t2) }
}
}, [wide, measureRailRect])
// ── 悬浮窗宽度/水平定位:与 sidebar 几何联动(ResizeObserver 实时跟随,无轮询) ──
// dockWidth = sidebar 宽 − paddingLeft − paddingRight − 40(每边内缩 20)
// left = sidebar 左边缘视口坐标 + paddingLeft + 20
// 降级:找不到 sidebar 或宽度 ≤0 → left 固定 14px,宽度不设内联(交 CSS min/max 处理)
const measureFloatGeo = useCallback(() => {
const sidebar = findSidebar(rootRef.current)
if (!sidebar) {
setFloatGeo({ left: 14, width: null })
return
}
const rect = sidebar.getBoundingClientRect()
const cs = getComputedStyle(sidebar)
const padLeft = parseFloat(cs.paddingLeft) || 0
const padRight = parseFloat(cs.paddingRight) || 0
const margin = 20
const width = rect.width - padLeft - padRight - margin * 2
if (width > 0) {
setFloatGeo({ left: Math.round(rect.left + padLeft + margin), width: Math.round(width) })
} else {
setFloatGeo({ left: 14, width: null })
}
}, [])
useLayoutEffect(() => {
measureFloatGeo()
const sidebar = findSidebar(rootRef.current)
if (!sidebar) return
const ro = new ResizeObserver(() => measureFloatGeo())
ro.observe(sidebar)
return () => ro.disconnect()
}, [measureFloatGeo])
// 窗口尺寸变化 → 回弹默认停靠(浮动的列宽已失效)
useEffect(() => {
const onResize = () => {
setFloat(null); persistView({ dock: 'footer' })
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// ── 协议 v2 回调(引用稳定,避免卡片 reportMeta effect 循环)──
const reportMeta = useCallback((meta: ProjectMeta) => {
if (!meta || typeof meta.id !== 'string' || !meta.id) return
setMetas((prev) => {
const cur = prev[meta.id]
if (cur && cur.name === meta.name && cur.icon === meta.icon) return prev
return { ...prev, [meta.id]: { id: meta.id, name: typeof meta.name === 'string' ? meta.name : meta.id, icon: meta.icon } }
})
}, [])
const reportUsed = useCallback((id: string) => {
if (typeof id !== 'string' || !id) return
const now = Date.now()
persistProjects((prev) => {
if (prev.lastUsed[id] === now) return prev
return { ...prev, lastUsed: { ...prev.lastUsed, [id]: now } }
})
}, [])
// ── 有效排序 ──
// 手动:持久化 order(过滤已卸载 id)→ 新注册 id 追加尾部;
// 最近:有 lastUsed 的按时间降序在前,其余按手动序在后。
const effectiveOrder = useMemo(() => {
const known = new Set(registeredIds)
const stored = projects.order.filter((id) => known.has(id))
const rest = registeredIds.filter((id) => !stored.includes(id))
let list = [...stored, ...rest]
if (view.orderBy === 'recent') {
const hot = list.filter((id) => projects.lastUsed[id] != null)
const cold = list.filter((id) => projects.lastUsed[id] == null)
hot.sort((a, b) => (projects.lastUsed[b] ?? 0) - (projects.lastUsed[a] ?? 0))
list = [...hot, ...cold]
}
return list
}, [registeredIds, projects.order, projects.lastUsed, view.orderBy])
const ownerProps = {
query: view.query.trim(),
wide,
managing,
order: effectiveOrder,
hidden: projects.hidden,
nameOverrides: projects.nameOverrides,
reportMeta,
reportUsed,
}
// ── 拖动(≡ 手柄,与 v1 相同)──
const onHandlePointerDown = (e: any) => {
const root = rootRef.current
if (!root) return
const startRect = root.getBoundingClientRect()
dragRef.current = { startY: e.clientY, startX: e.clientX, startRect, dragging: false, prevFloat: float }
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) } catch {}
setViewOptionsOpen(false); setAddOpen(false)
}
const onHandlePointerMove = (e: any) => {
const d = dragRef.current
if (!d) return
const dy = e.clientY - d.startY
if (!d.dragging && Math.abs(dy) < 6) return
d.dragging = true
// 浮动上限按区块实际高度计算:停靠位紧邻其下方,向上拖即可自然落位
const maxTop = Math.max(MIN_TOP, window.innerHeight - d.startRect.height - 12)
setFloat({ top: clamp(d.startRect.top + dy, MIN_TOP, maxTop) })
}
const onHandlePointerUp = (e: any) => {
const d = dragRef.current
dragRef.current = null
if (!d || !d.dragging) return
// 落点判定:以「松手瞬间指针位置」判断是否有有效落点(快速甩出范围 = 无落点)
const maxTop = Math.max(MIN_TOP, window.innerHeight - d.startRect.height - 12)
const rawTop = d.startRect.top + (e.clientY - d.startY)
const outOfRange =
rawTop > maxTop + OVER_BOTTOM_PX ||
rawTop < MIN_TOP - OVER_TOP_PX ||
e.clientX < d.startRect.left - OVER_SIDE_PX ||
e.clientX > d.startRect.right + OVER_SIDE_PX
if (outOfRange) {
// 无有效落点:回归拖前位置(不持久化任何变化)
setFloat(d.prevFloat)
return
}
setFloat((cur) => {
if (!cur) return null
if (Math.abs(cur.top - d.startRect.top) < SNAP_PX) {
persistView({ dock: 'footer', floatTop: null })
return null
}
persistView({ dock: 'float', floatTop: cur.top })
return cur
})
}
const resetDock = () => {
setFloat(null); persistView({ dock: 'footer', floatTop: null })
}
// Esc 关闭搜索
const onSearchKeyDown = (e: any) => {
if (e.key === 'Escape') {
persistView({ searchOpen: false, query: '' })
}
}
// ── 编辑模式动作 ──
const moveBy = (id: string, delta: number) => {
const list = [...effectiveOrder]
const i = list.indexOf(id)
const j = clamp(i + delta, 0, list.length - 1)
if (i < 0 || i === j) return
list.splice(i, 1)
list.splice(j, 0, id)
persistProjects({ order: list })
}
const moveTo = (id: string, targetId: string) => {
if (id === targetId) return
const list = [...effectiveOrder]
const from = list.indexOf(id)
const to = list.indexOf(targetId)
if (from < 0 || to < 0) return
list.splice(from, 1)
list.splice(to, 0, id)
persistProjects({ order: list })
}
const toggleHidden = (id: string) => {
persistProjects((prev) => {
const hidden = prev.hidden.includes(id)
? prev.hidden.filter((x) => x !== id)
: [...prev.hidden, id]
return { ...prev, hidden }
})
}
const renameProject = (id: string, name: string) => {
persistProjects((prev) => {
const next = { ...prev.nameOverrides }
if (name.trim()) next[id] = name
else delete next[id]
return { ...prev, nameOverrides: next }
})
}
const resetProjects = () => {
persistProjects((prev) => ({ ...prev, order: [], hidden: [], nameOverrides: {} }))
}
// ── 快捷方式 ──
const addShortcut = () => {
const name = scName.trim()
const href = scHref.trim()
if (!name || !/^https?:\/\//i.test(href)) { setScError(true); return }
const shortcut: Shortcut = { id: 'sc-' + Date.now().toString(36), name, icon: scIcon.trim() || '🔗', href }
persistProjects((prev) => ({ ...prev, shortcuts: [...prev.shortcuts, shortcut] }))
setScName(''); setScIcon(''); setScHref(''); setScError(false)
}
const removeShortcut = (id: string) => {
persistProjects((prev) => ({ ...prev, shortcuts: prev.shortcuts.filter((s) => s.id !== id) }))
}
const query = view.query.trim()
const queryLower = query.toLowerCase()
const visibleShortcuts = projects.shortcuts.filter((s) =>
!queryLower || (s.name + ' ' + s.href).toLowerCase().includes(queryLower),
)
const isFloat = float != null
const floatStyle = isFloat
? {
position: 'fixed' as const,
top: float.top,
left: floatGeo?.left ?? 14,
...(floatGeo?.width != null ? { width: floatGeo.width } : {}),
zIndex: 70,
}
: undefined
useEffect(() => { bottomInsetRef.current = bottomInset }, [bottomInset])
useEffect(() => { floatRef.current = float }, [float])
// ── 底部悬浮面板避让 ──
// 停靠态下检测「侧边栏列内、贴近底部、且与区块自然位置重叠」的 fixed 面板
// (如 dsh-usage 的余额 dock),把区块整体抬到面板上方,双方互不遮挡、都可调整位置。
const measureBottomOverlay = useCallback(() => {
if (isFloat) return
const root = rootRef.current
if (!root) return
const self = root.getBoundingClientRect()
if (self.width < 10) return
const naturalBottom = self.bottom + bottomInsetRef.current
const loX = self.left - 8
const hiX = self.right + 8
const regionTop = window.innerHeight - 300
let needed = 0
const nodes = document.querySelectorAll<HTMLElement>('body *')
for (const el of nodes) {
const r = el.getBoundingClientRect()
if (r.width < 40 || r.height < 16) continue
if (r.top < regionTop) continue
if (r.left > 80) continue
if (r.right < loX || r.left > hiX) continue
const cs = getComputedStyle(el)
if (cs.position !== 'fixed') continue
if (cs.visibility === 'hidden' || Number(cs.opacity) === 0) continue
if (r.top >= naturalBottom + 4) continue
const overlap = Math.min(r.bottom, naturalBottom + 400) - r.top
if (overlap > 8) {
needed = Math.max(needed, Math.min(naturalBottom - r.top + 8, 340))
}
}
setBottomInset((prev) => (Math.abs(prev - needed) < 2 ? prev : needed))
}, [isFloat])
// 停靠/折叠变化时立即重测;停靠期间每 2s 轮询(悬浮面板自身可移动)
useEffect(() => {
if (!isFloat) measureBottomOverlay()
}, [isFloat, wide, measureBottomOverlay])
useEffect(() => {
const timer = window.setInterval(() => {
if (!floatRef.current) measureBottomOverlay()
}, 2000)
return () => window.clearInterval(timer)
}, [measureBottomOverlay])
const dockedStyle = !isFloat && bottomInset > 0 ? { marginBottom: bottomInset } : undefined
if (!wide) {
const projectIcons = registeredIds.map((id) => metas[id]?.icon ?? '📦')
const shortcutIcons = projects.shortcuts.map((s) => s.icon)
const icons = [...projectIcons, ...shortcutIcons]
const railNames = [
...registeredIds.map((id) => projects.nameOverrides[id] ?? metas[id]?.name ?? id),
...projects.shortcuts.map((s) => s.name),
]
// 浮动态:fixed 定位到拖前高度(左/宽取折叠列实测几何);停靠态:文档流原位
const railStyle = isFloat && railRect
? { position: 'fixed' as const, top: float.top, left: railRect.left, width: railRect.width, zIndex: 70 }
: bottomInset > 0 ? { marginBottom: bottomInset } : undefined
return (
<div ref={rootRef} className="dsh-wt_section dsh-wt_rail" style={railStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_railBox" title={railNames.join(' · ') || t('rail.title')}>
{icons.length > 0
? icons.map((icon, i) => <span key={i} className="dsh-wt_railIcon">{icon}</span>)
: <span className="dsh-wt_railIcon">≡</span>}
</div>
</div>
)
}
return (
<div ref={rootRef} className={'dsh-wt_section' + (isFloat ? ' dsh-wt_float' : '')} style={isFloat ? floatStyle : dockedStyle}>
<div className="dsh-wt_divider" />
<div className="dsh-wt_header">
<button
type="button"
className="dsh-wt_handle"
data-float={isFloat ? 'true' : 'false'}
title={t('handle.title')}
aria-label={t('handle.aria')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>≡</button>
<span
className="dsh-wt_title"
title={t('handle.title')}
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onPointerCancel={onHandlePointerUp}
onDoubleClick={resetDock}
>{t('title')}</span>
<div className="dsh-wt_actions">
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.search')}
title={t('menu.search')}
onClick={() => persistView({ searchOpen: !view.searchOpen, query: view.searchOpen ? '' : view.query })}
>🔍</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.viewOptions')}
title={t('menu.viewOptions')}
onClick={() => { setViewOptionsOpen((v) => !v); setAddOpen(false) }}
>☰</button>
<button
type="button"
className="dsh-wt_iconBtn"
aria-label={t('menu.add')}
title={t('menu.add')}
onClick={() => { setAddOpen((v) => !v); setViewOptionsOpen(false) }}
>+</button>
</div>
</div>
{view.searchOpen && (
<div className="dsh-wt_search">
<input
autoFocus
type="text"
placeholder={t('search.placeholder')}
value={view.query}
onChange={(e) => persistView({ query: e.target.value })}
onKeyDown={onSearchKeyDown}
/>
<button type="button" className="dsh-wt_searchClear" aria-label={t('search.close')}
onClick={() => persistView({ searchOpen: false, query: '' })}>✕</button>
</div>
)}
{addOpen && (
<div className="dsh-wt_menu dsh-wt_add">
<span className="dsh-wt_menuLabel">{t('add.guideTitle')}</span>
<p className="dsh-wt_addText">{t('add.guideBody')}</p>
<a className="dsh-wt_menuItem dsh-wt_addLink" href={MARKET_URL} target="_blank" rel="noreferrer noopener">
{t('add.market')} ↗
</a>
<span className="dsh-wt_menuLabel">{t('add.shortcutTitle')}</span>
<p className="dsh-wt_addText">{t('add.shortcutDesc')}</p>
<div className="dsh-wt_addForm">
<input type="text" placeholder={t('add.shortcutNamePh')} value={scName}
onChange={(e) => { setScName(e.target.value); setScError(false) }} />
<input type="text" className="dsh-wt_addIcon" placeholder={t('add.shortcutIcon')} value={scIcon} maxLength={4}
onChange={(e) => setScIcon(e.target.value)} />
<input type="text" placeholder={t('add.shortcutHref')} value={scHref}
onChange={(e) => { setScHref(e.target.value); setScError(false) }} />
<button type="button" className="dsh-wt_addBtn" onClick={addShortcut}>{t('add.shortcutAdd')}</button>
</div>
{scError && <p className="dsh-wt_addError">{t('add.shortcutInvalid')}</p>}
</div>
)}
{viewOptionsOpen && (
<div className="dsh-wt_menu">
<span className="dsh-wt_menuLabel">{t('sort.label')}</span>
<button type="button" className="dsh-wt_menuItem" data-on={view.orderBy === 'manual'}
onClick={() => { persistView({ orderBy: 'manual' }); setViewOptionsOpen(false) }}>{t('sort.manual')}</button>
<button type="button" className="dsh-wt_menuItem" data-on={view.orderBy === 'recent'}
onClick={() => { persistView({ orderBy: 'recent' }); setViewOptionsOpen(false) }}>{t('sort.recent')}</button>
<div className="dsh-wt_menuSep" />
<button type="button" className="dsh-wt_menuItem"
onClick={() => { setManaging(true); setViewOptionsOpen(false) }}>{t('menu.manage')}</button>
</div>
)}
{managing && (
<div className="dsh-wt_manage">
<div className="dsh-wt_manageHead">
<span className="dsh-wt_manageTitle">{t('manage.title')}</span>
<button type="button" className="dsh-wt_manageDone" onClick={() => setManaging(false)}>{t('manage.done')}</button>
</div>
{effectiveOrder.map((id) => {
const meta = metas[id]
const display = projects.nameOverrides[id] ?? meta?.name ?? id
const isHidden = projects.hidden.includes(id)
return (
<div
key={id}
className={'dsh-wt_manageRow' + (isHidden ? ' dsh-wt_manageRowOff' : '')}
draggable
onDragStart={(e: any) => { dragIdRef.current = id; try { e.dataTransfer.effectAllowed = 'move' } catch {} }}
onDragOver={(e: any) => {
e.preventDefault()
const dragId = dragIdRef.current
if (dragId && dragId !== id) moveTo(dragId, id)
}}
onDrop={(e: any) => e.preventDefault()}
onDragEnd={() => { dragIdRef.current = null }}
>
<span className="dsh-wt_manageGrip" aria-hidden>≡</span>
<span className="dsh-wt_manageIcon" aria-hidden>{meta?.icon ?? '📦'}</span>
<input
className="dsh-wt_manageInput"
value={display}
placeholder={t('manage.renamePh')}
onChange={(e) => renameProject(id, e.target.value)}
/>
<button type="button" className="dsh-wt_manageBtn" title={t('manage.up')} onClick={() => moveBy(id, -1)}>↑</button>
<button type="button" className="dsh-wt_manageBtn" title={t('manage.down')} onClick={() => moveBy(id, 1)}>↓</button>
<button type="button" className="dsh-wt_manageBtn" title={isHidden ? t('manage.show') : t('manage.hide')} onClick={() => toggleHidden(id)}>
{isHidden ? '🙈' : '👁'}
</button>
</div>
)
})}
{projects.shortcuts.map((s) => (
<div key={s.id} className="dsh-wt_manageRow dsh-wt_manageRowSc">
<span className="dsh-wt_manageGrip" aria-hidden>🔗</span>
<span className="dsh-wt_manageIcon" aria-hidden>{s.icon}</span>
<span className="dsh-wt_manageScName">{s.name}</span>
<button type="button" className="dsh-wt_manageBtn" title={t('manage.deleteShortcut')} onClick={() => removeShortcut(s.id)}>✕</button>
</div>
))}
<button type="button" className="dsh-wt_manageReset" onClick={resetProjects}>{t('manage.reset')}</button>
</div>
)}
<div className="dsh-wt_projects" data-managing={managing ? 'true' : undefined}>
{renderProjectSlot
? renderProjectSlot('sidebar.worktable.project', ownerProps)
: <div className="dsh-wt_empty">{t('empty')}</div>}
</div>
{visibleShortcuts.length > 0 && (
<div className="dsh-wt_shortcuts">
{visibleShortcuts.map((s) => (
<a
key={s.id}
className="dsh-wt_shortcut"
href={s.href}
target="_blank"
rel="noreferrer noopener"
title={s.href}
>
<span className="dsh-wt_shortcutIcon" aria-hidden>{s.icon}</span>
<span className="dsh-wt_shortcutName">{s.name}</span>
<span className="dsh-wt_shortcutBadge">{t('shortcut.badge')}</span>
</a>
))}
</div>
)}
</div>
)
}
export const inject = ['slots', 'locale']
export function apply(ctx: any) {
ctx.effect(() => {
const style = document.createElement('style')
style.setAttribute('data-dsh-plugin', 'dsh-worktable')
style.textContent = css
document.head.appendChild(style)
return () => { style.remove() }
}, 'dsh-worktable: styles')
// locale 词典(宿主 locale 服务缺席时由 t 的回退分支兜底)
ctx.effect(() => {
if (ctx.locale?.register) return ctx.locale.register(NS, { zh, en })
}, 'dsh-worktable: dictionaries')
// 子座位注册 id 序列跟踪(供排序/编辑模式使用)
const syncIds = () => {
const entries = ctx.slots.entries('sidebar.worktable.project')
const ids: string[] = []
for (const entry of entries) {
const id = entry?.options?.id
if (typeof id === 'string' && id && !ids.includes(id)) ids.push(id)
}
registryStore.ids = ids
for (const fn of registryStore.listeners) fn()
}
const disposeSubscribe = ctx.slots.subscribe('sidebar.worktable.project', syncIds)
syncIds()
ctx.effect(() => disposeSubscribe, 'dsh-worktable: project registry watch')
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
name: 'sidebar.footer.action',
id: 'dsh-worktable',
order: 20,
children: {
'sidebar.worktable.project': {
kind: 'list',
scope: 'root',
},
},
}, WorktableSection), 'dsh-worktable: worktable section')
}
export { WorktableSection }
+94
View File
@@ -0,0 +1,94 @@
/**
* dsh-worktable locale namespace(NS 'worktable'):
* 侧边栏区块、视图菜单、管理项目、添加面板、快捷方式等全部文案。
* zh 为键集唯一来源;en 与 zh 键集完全对齐。
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'title': '工作台',
'rail.title': '工作台(展开侧边栏查看)',
'handle.title': '按住拖动位置 · 双击复位',
'handle.aria': '拖动工作台位置',
'menu.search': '搜索项目',
'menu.viewOptions': '视图选项',
'menu.add': '添加项目',
'search.placeholder': '搜索项目…',
'search.close': '关闭搜索',
'sort.label': '排序方式',
'sort.manual': '手动',
'sort.recent': '最近',
'menu.manage': '管理项目…',
'add.guideTitle': '接入新项目',
'add.guideBody': '工作台里的项目就是 DSH 插件——插件注册一张「工作台项目卡片」即自动入驻(协议见项目 PRD §5.3)。已安装但未入驻的插件需在插件代码里完成注册。',
'add.market': '浏览插件市场',
'add.shortcutTitle': '本地快捷方式',
'add.shortcutDesc': '把任意网址收进工作台,点击新标签打开(仅存本机)。',
'add.shortcutNamePh': '名称',
'add.shortcutIcon': '图标(emoji,可选)',
'add.shortcutHref': '链接(http/https)',
'add.shortcutAdd': '添加',
'add.shortcutInvalid': '请填写名称与合法的 http/https 链接',
'manage.title': '管理项目',
'manage.done': '完成',
'manage.renamePh': '显示名',
'manage.hide': '隐藏',
'manage.show': '显示',
'manage.up': '上移',
'manage.down': '下移',
'manage.deleteShortcut': '删除快捷方式',
'manage.reset': '恢复默认',
'shortcut.badge': '本地',
'empty': '暂无项目',
} satisfies Record<string, string>
/** The worktable namespace key union. */
export type WorktableKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'title': 'Worktable',
'rail.title': 'Worktable (expand sidebar to view)',
'handle.title': 'Drag to move · double-click to reset',
'handle.aria': 'Drag worktable position',
'menu.search': 'Search projects',
'menu.viewOptions': 'View options',
'menu.add': 'Add project',
'search.placeholder': 'Search projects…',
'search.close': 'Close search',
'sort.label': 'Sort by',
'sort.manual': 'Manual',
'sort.recent': 'Recent',
'menu.manage': 'Manage projects…',
'add.guideTitle': 'Add a project',
'add.guideBody': 'Projects in the worktable are DSH plugins — a plugin joins by registering a worktable project card (protocol in the project PRD §5.3). Installed plugins that have not registered need to do so in their code.',
'add.market': 'Browse plugin marketplace',
'add.shortcutTitle': 'Local shortcut',
'add.shortcutDesc': 'Pin any URL into the worktable; opens in a new tab (stored locally only).',
'add.shortcutNamePh': 'Name',
'add.shortcutIcon': 'Icon (emoji, optional)',
'add.shortcutHref': 'URL (http/https)',
'add.shortcutAdd': 'Add',
'add.shortcutInvalid': 'Enter a name and a valid http/https URL',
'manage.title': 'Manage projects',
'manage.done': 'Done',
'manage.renamePh': 'Display name',
'manage.hide': 'Hide',
'manage.show': 'Show',
'manage.up': 'Move up',
'manage.down': 'Move down',
'manage.deleteShortcut': 'Delete shortcut',
'manage.reset': 'Reset defaults',
'shortcut.badge': 'Local',
'empty': 'No projects yet',
} satisfies Record<WorktableKey, string>
/** Locale namespace id registered under ctx.locale. */
export const NS = 'worktable'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The worktable sidebar copy. */
[NS]: WorktableKey
}
}
+73
View File
@@ -0,0 +1,73 @@
export const css = [
// 区块整体
'.dsh-wt_section{box-sizing:border-box;display:flex;flex-direction:column;gap:6px;width:100%;padding:0}',
'.dsh-wt_float{min-width:176px;max-width:264px;background:var(--dsw-alias-bg-base,#0b0e14);border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:10px;padding:8px;box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
'.dsh-wt_rail{pointer-events:none}',
'.dsh-wt_railBox{display:flex;flex-wrap:wrap;gap:3px;justify-content:center;padding:5px 3px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14)}',
'.dsh-wt_railIcon{font-size:17px;line-height:22px}',
// 分隔线
'.dsh-wt_divider{flex:none;height:1px;background:var(--dsw-alias-border-l1,#262b36);margin:2px 0}',
// 头部
'.dsh-wt_header{display:flex;align-items:center;gap:6px;padding:0 2px}',
'.dsh-wt_handle{display:flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:13px;line-height:1;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none}',
'.dsh-wt_handle:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_handle:active{cursor:grabbing}',
'.dsh-wt_handle[data-float=true]{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_title{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--dsw-alias-label-secondary,#9aa4b2);cursor:grab;user-select:none;-webkit-user-select:none;touch-action:none}',
'.dsh-wt_actions{margin-left:auto;display:flex;align-items:center;gap:2px}',
'.dsh-wt_iconBtn{display:flex;align-items:center;justify-content:center;width:24px;height:22px;padding:0;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:12px;line-height:1;cursor:pointer}',
'.dsh-wt_iconBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
// 搜索行
'.dsh-wt_search{display:flex;align-items:center;gap:6px;padding:3px 6px 3px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03))}',
'.dsh-wt_search input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px}',
'.dsh-wt_search input::placeholder{color:var(--dsw-alias-label-tertiary,#6e7683)}',
'.dsh-wt_searchClear{display:flex;align-items:center;justify-content:center;width:18px;height:18px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:11px;cursor:pointer}',
'.dsh-wt_searchClear:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
// 菜单与提示
'.dsh-wt_menu{position:relative;z-index:12;display:flex;flex-direction:column;gap:1px;padding:5px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14);box-shadow:var(--dsw-shadow-lv2,0 8px 24px rgba(0,0,0,.4))}',
'.dsh-wt_menuLabel{font-size:10px;letter-spacing:.05em;color:var(--dsw-alias-label-tertiary,#6e7683);padding:3px 7px 1px}',
'.dsh-wt_menuItem{display:flex;align-items:center;padding:5px 8px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:16px;cursor:pointer;text-align:left}',
'.dsh-wt_menuItem:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_menuItem[data-on=true]{color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
// 菜单分隔线
'.dsh-wt_menuSep{height:1px;background:var(--dsw-alias-border-l1,#262b36);margin:4px 2px}',
// 添加面板
'.dsh-wt_add{max-height:340px;overflow:auto}',
'.dsh-wt_addText{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,#9aa4b2);padding:2px 8px 4px;margin:0}',
'.dsh-wt_addLink{color:var(--dsw-alias-state-accent-primary,#4f8ef7);text-decoration:none}',
'.dsh-wt_addForm{display:flex;flex-direction:column;gap:5px;padding:4px 8px 8px}',
'.dsh-wt_addForm input{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03));border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:6px;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;padding:5px 8px;outline:none}',
'.dsh-wt_addForm input:focus{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7)}',
'.dsh-wt_addForm .dsh-wt_addIcon{width:64px}',
'.dsh-wt_addBtn{display:flex;align-items:center;justify-content:center;padding:5px 8px;border:none;border-radius:6px;background:var(--dsw-alias-state-accent-primary,#4f8ef7);color:#fff;font:inherit;font-size:12px;line-height:16px;cursor:pointer}',
'.dsh-wt_addBtn:hover{filter:brightness(1.08)}',
'.dsh-wt_addError{font-size:11px;color:var(--dsw-alias-state-danger,#e5484d);padding:0 8px 8px;margin:0}',
// 管理项目(编辑模式)
'.dsh-wt_manage{position:relative;z-index:12;display:flex;flex-direction:column;gap:4px;padding:6px;border:1px solid var(--dsw-alias-border-l2,#3a4150);border-radius:8px;background:var(--dsw-alias-bg-base,#0b0e14)}',
'.dsh-wt_manageHead{display:flex;align-items:center;justify-content:space-between;padding:1px 2px 4px}',
'.dsh-wt_manageTitle{font-size:11px;font-weight:600;letter-spacing:.04em;color:var(--dsw-alias-label-primary,#e6e8eb)}',
'.dsh-wt_manageDone{display:flex;align-items:center;padding:2px 8px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-state-accent-primary,#4f8ef7);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_manageDone:hover{background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_manageRow{display:flex;align-items:center;gap:6px;padding:4px 6px;border:1px solid transparent;border-radius:6px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.03))}',
'.dsh-wt_manageRow:hover{border-color:var(--dsw-alias-border-l2,#3a4150)}',
'.dsh-wt_manageRowOff{opacity:.45}',
'.dsh-wt_manageGrip{flex:none;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:12px;cursor:grab;user-select:none;-webkit-user-select:none}',
'.dsh-wt_manageIcon{flex:none;width:18px;font-size:13px;text-align:center}',
'.dsh-wt_manageInput{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--dsw-alias-label-primary,#e6e8eb);font:inherit;font-size:12px;line-height:18px;padding:2px 0}',
'.dsh-wt_manageBtn{flex:none;display:flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:none;border-radius:4px;background:transparent;color:var(--dsw-alias-label-secondary,#9aa4b2);font-size:11px;line-height:1;cursor:pointer}',
'.dsh-wt_manageBtn:hover{color:var(--dsw-alias-label-primary,#e6e8eb);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06))}',
'.dsh-wt_manageScName{flex:1;min-width:0;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa4b2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.dsh-wt_manageReset{align-self:flex-start;margin-top:2px;padding:3px 8px;border:none;border-radius:5px;background:transparent;color:var(--dsw-alias-label-tertiary,#6e7683);font:inherit;font-size:11px;line-height:16px;cursor:pointer}',
'.dsh-wt_manageReset:hover{color:var(--dsw-alias-state-danger,#e5484d);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
// 快捷方式
'.dsh-wt_shortcuts{display:flex;flex-direction:column;gap:4px}',
'.dsh-wt_shortcut{display:flex;align-items:center;gap:7px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1,#262b36);border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.02));color:var(--dsw-alias-label-primary,#e6e8eb);text-decoration:none;font-size:12px;line-height:18px}',
'.dsh-wt_shortcut:hover{border-color:var(--dsw-alias-state-accent-primary,#4f8ef7);background:var(--dsw-alias-fill-l1,rgba(255,255,255,.05))}',
'.dsh-wt_shortcutIcon{flex:none;font-size:13px}',
'.dsh-wt_shortcutName{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dsh-wt_shortcutBadge{flex:none;font-size:9px;line-height:14px;padding:0 5px;border-radius:8px;background:var(--dsw-alias-fill-l1,rgba(255,255,255,.06));color:var(--dsw-alias-label-tertiary,#6e7683)}',
// 项目区
'.dsh-wt_projects{display:flex;flex-direction:column;gap:4px}',
'.dsh-wt_projects[data-managing=true]{opacity:.55;pointer-events:none}',
'.dsh-wt_empty{padding:8px 10px;border:1px dashed var(--dsw-alias-border-l1,#262b36);border-radius:8px;color:var(--dsw-alias-label-tertiary,#6e7683);font-size:11px;text-align:center}',
].join('\n')
+24
View File
@@ -0,0 +1,24 @@
import { Context } from '@deepseek-ai/cordis'
export const name = 'dsh-worktable'
/** 依赖 webServer 服务(web profile 由 dsh-web-app 挂载) */
export const inject = ['webServer']
export const HEALTH_PATH = '/api/worktable/health'
export function apply(ctx: Context) {
const webServer = (ctx as any).webServer
if (!webServer) {
ctx.logger?.warn('[dsh-worktable] ctx.webServer 不可用(headless profile?),跳过健康路由')
return
}
webServer.register({
kind: 'exact',
path: HEALTH_PATH,
handler: (_req: any, res: any) => {
const body = JSON.stringify({ plugin: 'dsh-worktable', version: '0.1.0', ok: true })
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
res.end(body)
},
})
}
+232
View File
@@ -0,0 +1,232 @@
# dsh-worktable(工作台)PRD
> 版本:v0.2 草案 · 日期:2026-08-16 · 状态:v1 原型已上线;§10 待设计内容已定案并实现(v2,代码完成、待重启后 GUI 验收)
> 关联项目:dsh-travelatlas(第一个入驻项目)、上游参考 dsh-reminder(文件夹结构)
## 1. 项目定位
**dsh-worktable 是 DeepSeek Harness Web GUI 的一个「工作台」容器插件**:在左侧侧边栏的「工作区」
(官方会话/工作区浏览区)下方划出一条分隔线,其下开辟「工作台」区块,用于收纳**不同于 DSH 默认模式的
agent 级项目**(如旅行图鉴 TravelAtlas),让用户可以像管理应用抽屉一样管理自己安装的项目。
- 一句话:**侧边栏里的「应用抽屉」,agent 级项目的家。**
- 与官方关系:纯增量插件,不替换、不禁用任何官方插件(与 dsh-plugin-ya-workspace-sidebar 的替换路线相反)。
## 2. 背景与问题
- DSH 的侧边栏只有「工作区」(会话浏览)与「设置」两层,没有承载用户自装项目的位置;
- 现有项目(如 dsh-travelatlas)只能挤在 `sidebar.footer.action` 底部,各自为政、无统一入口与元信息;
- 用户希望有一个与「工作区」对等的「工作台」区域,统一收纳、搜索、整理自己的 agent 级项目。
## 3. 目标 / 非目标
### 目标(v1 原型,本窗口已完成)
- [x] 侧边栏底部(会话列表下方、设置行上方)渲染「工作台」区块:分隔线 + 标题 + 三按钮 + 项目列表;
- [x] 标题左侧 ≡ 拖动手柄:按住上下拖动整个区块,松手停靠(浮动位置持久化);
- [x] 三按钮照抄官方「工作区」头部:搜索(展开输入框过滤项目)、视图选项(分组/排序)、添加(占位符);
- [x] 项目注册协议:子座位 `sidebar.worktable.project`,任何插件注册即可入驻;
- [x] dsh-travelatlas 迁入成为第一个项目(含工作台缺席时的降级回退)。
### 目标(v2,§10 定案,本窗口已完成)
- [x] 视图选项简化:取消分组方式,只留排序(手动/最近),旧 groupBy 状态忽略;
- [x] 卡片规范 v2 渐进上报协议(owner props 扩展,全部可选,v1 卡片零改动兼容);
- [x] 「管理项目…」编辑模式:显示名改名 / 隐藏 / 手动排序(拖拽 + ↑↓),持久化;
- [x] 添加(+) 真实逻辑:接入指引面板 + 本地快捷方式条目(新标签打开);
- [x] 使用埋点:卡片点击上报,「最近」排序生效;
- [x] 完整 zh/en 词典接入 dsh-client-locale(NS worktable);
- [x] dsh-travelatlas 卡片升级协议 v2(报到/埋点/排序/隐藏/改名)。
### 非目标(明确不做)
- 不替换 ui-sidebar / ui-workspace / 官方任何组件;
- 不做「工作台自身的独立路由主页」——管理能力并入区块内编辑模式(见 §5.5);
- 不做项目市场/安装器(生态里已有 dsh-plugin-hub / dshfind;工作台仅提供外链)。
## 4. 用户故事
- 作为用户,我想在侧边栏一个固定的地方看到我装的 agent 级项目,而不是散落各处;
- 作为用户,我想拖动工作台区块到侧边栏里更顺手的高度,并且下次打开还记得;
- 作为用户,我想像搜索会话一样搜索我的项目;
- 作为插件作者,我想用几行代码让我的项目入驻工作台(拿到卡片 + 打开逻辑)。
## 5. 功能规格
### 5.1 区块结构(自上而下)
```
══════════ 分隔线 ══════════
[≡] 工作台 [🔍][视图选项][+]
┌───────────────────────────┐
│ 项目卡片(0..n,来自子座位) │
└───────────────────────────┘
```
- 标题文案:`工作台`(locale 键 `worktable.title`,en: `Worktable`);
- 底部悬浮面板避让:停靠态下检测侧边栏列内贴底的 fixed 面板(如 dsh-usage 余额 dock),
与区块重叠时以 margin-bottom 整体让位到面板上方,双方互不遮挡、各自可拖动;停靠期间 2s 轮询跟随面板移动。
- ≡ 手柄:`pointerdown` 捕获,垂直拖动 >6px 进入浮动模式(position:fixed 跟随指针,限制在侧边栏列宽内、
顶部不低于品牌行下沿、底部不超出设置行上沿);松手:与默认停靠位(footer 原位)距离 <32px 则回弹停靠,
否则保持浮动位置;持久化键 `dsh.worktable.view.v1`(字段 `query/searchOpen/orderBy/dock/floatTop`,旧版 groupBy 字段忽略);
双击 ≡ 复位到默认停靠;标题与 ≡ 均可作为拖拽手柄;浮动上限按区块实际高度计算(停靠位紧邻其下);
松手时按指针落点判定——越出有效落点区(底部/顶部/侧边余量 24/24/80px)即回归拖前位置;
- 悬浮窗几何与 sidebar 联动(只宽度/水平定位,高度由拖拽决定):向上遍历父链识别 sidebar
(className 含 SidebarRoot/sidebar,或 aside/nav,到 body 为止);ResizeObserver 实时跟随;
dockWidth = sidebar 宽 − paddingLeft − paddingRight − 40px(每边内缩 20px);
left = sidebar 左边缘视口坐标 + paddingLeft + 20px;找不到 sidebar 或宽度 ≤0 时降级
left 固定 14px、宽度不设内联(交 CSS min-width:176px / max-width:264px);
侧边栏折叠/展开保持原停靠位置——折叠态以「项目图标框」(收纳所有项目 emoji)显示在拖前高度,
展开即复原;图标框在折叠动画结束后(320/750ms 双次重测)按收敛后的折叠列几何水平居中;
仅窗口尺寸变化时回弹 footer 停靠。
### 5.2 三按钮(照抄官方工作区头部,逻辑作用于项目列表)
| 按钮 | 图标(primitives) | 行为 |
| --- | --- | --- |
| 搜索 | 🔍 | 点击展开输入行(Esc / 点 ✕ 收起);输入即过滤项目卡片与快捷方式(query 经座位 owner props 传给每个卡片,卡片自行判断是否隐藏) |
| 视图选项 | ☰ | 排序方式(手动/最近),选择即持久化到 `dsh.worktable.view.v1`;另含「管理项目…」入口(§5.5)。分组方式已按用户定案取消 |
| 添加 | + | 展开「添加项目」面板:接入指引(注册即入驻说明 + 插件市场外链)+ 本地快捷方式表单(§5.6) |
> 官方工作区头部三按钮 = 搜索 / 视图选项(ViewOptionsMenu) / 添加工作区(+),已逆向确认。
### 5.3 项目注册协议(子座位,v2 卡片规范)
工作台组件在注册 `sidebar.footer.action` 时声明子座位:
```ts
ctx.slots.register({
name: 'sidebar.footer.action',
id: 'dsh-worktable',
order: 20,
children: { 'sidebar.worktable.project': { kind: 'list', scope: 'root', owner: ProjectOwnerProps } },
}, WorktableSection)
```
卡片注册约定:`id` 为项目唯一 id(如 `travelatlas`),`order` 为默认排序(注册序)。
**owner props v2(渐进上报协议,全部可选)**:
| 字段 | 类型 | 含义与卡片行为 |
| --- | --- | --- |
| `query` | string | 当前搜索词;卡片自行判断是否返回 null |
| `wide` | boolean | 侧边栏是否展开 |
| `order` | string[] | 当前排序下的 id 序列;卡片用 `style={{ order: indexOf(自身id) + 1000 }}` 参与排序(+1000 偏移保证未上报的 v1 卡片在前) |
| `hidden` | string[] | 被隐藏的 id 集;包含自身 id 时返回 null |
| `nameOverrides` | Record<string,string> | 显示名覆盖表;卡片优先显示覆盖名(编辑模式改名) |
| `managing` | boolean | 编辑模式标记(卡片可据此减弱交互) |
| `reportMeta(meta)` | 回调 | mount 时上报 `{ id, name, icon }`,供管理条渲染;回调引用稳定 |
| `reportUsed(id)` | 回调 | 点击时上报使用时间戳(「最近」排序埋点) |
兼容性:未上报元信息的 v1 卡片零改动照常显示(按注册序排在最前),只是不参与排序/隐藏/改名。
机制依据:列表座位渲染器输出 `display:contents` 锚点且错误边界不产生 DOM 包裹,卡片根节点即
`.dsh-wt_projects` 的直接 flex 子项,CSS order 生效(已核实 web-react 渲染器实现)。
参考实现:dsh-travelatlas `src/client/index.tsx` 的 `WorktableCard`。
### 5.4 降级回退协议(对项目插件)
项目插件应实现:先 `ctx.slots.inject('sidebar.worktable.project', ...)` 注册工作台卡片;若工作台插件未安装
(座位永不出现),超时(~2.5s)后回退到 `sidebar.footer.action` 注册独立入口。参考 dsh-travelatlas
`src/client/index.tsx` 的实现。
### 5.5 管理项目(编辑模式)
- 入口:视图选项菜单「管理项目…」;「完成」退出(编辑状态不持久化)。
- 管理条逐项目列出(含未上报元信息的卡片,名称回退为注册 id):≡ 拖拽排序(HTML5 drag)+ ↑↓ 按钮 +
改名输入框 + 隐藏/显示切换(🙈/👁,隐藏后卡片区消失、管理条内可恢复)。
- 快捷方式条目在管理条中显示并可删除(✕);「恢复默认」清空排序/隐藏/改名覆盖(保留 lastUsed 与快捷方式)。
- 编辑模式下项目卡片区整体弱化(opacity + pointer-events:none)。
- 所有变更写入 `dsh.worktable.projects.v1`(见 §6)。
### 5.6 本地快捷方式(添加面板)
- 「+」展开添加面板:接入指引(项目=插件、注册即入驻,协议见 §5.3)+ 插件市场外链
(https://github.com/hikariming/dshfind,已核实可访问;dshfind.com 未验证、不链死链)。
- 快捷方式表单:名称 + 图标(emoji,可选,默认 🔗)+ 链接(校验 http/https);提交后立即出现在
项目卡片区下方,标「本地」角标;点击新标签打开(noopener)。
- 快捷方式参与搜索过滤;只存 localStorage,无任何网络请求。
### 5.7 国际化
- 词典:`01_content/src/client/locales.ts`,NS `worktable`,zh 为键集唯一来源,en 全量对齐。
- 接入方式(照 dsh-reminder):client inject `['slots','locale']`,apply 中 `ctx.locale.register(NS, { zh, en })`;
package.json `dsh.client.inject` 声明 `@deepseek-ai/dsh-client-locale`(peerDependencies 可选)。
- 宿主 locale 服务缺席时 t 回退 zh 词典,保证工作台独立可用。
## 6. 架构与技术方案
- 插件包:`01_content/`(dsh.plugin.json + cordis.patch.yml + build.mjs,参照 dsh-travelatlas / dsh-usage);
- 服务端:最小 cordis 插件,注册 `GET /api/worktable/health`(`inject: ['webServer']`),无业务路由;
- 客户端:单文件 CJS(`window.__ModuleLoader__.load` 握手),external react / @deepseek-ai/*;
- 注入座位:`sidebar.footer.action`(order 20,位于 dsh-usage 之后);
- 服务注入:`['slots','locale']`(locale 词典见 §5.7;宿主缺席时回退 zh 词典);
- 图标为 emoji 字符(🔍/☰/+/≡);
- 排序机制:owner props 下发 order 序列,卡片以 CSS order 参与排序(渲染器 display:contents 锚点已核实,见 §5.3);
- 子座位注册跟踪:apply 中 `ctx.slots.subscribe` + `entries()` 维护模块级 id 序列;
- 持久化:
- `dsh.worktable.view.v1`:query/searchOpen/orderBy/dock/floatTop(旧 groupBy 忽略);
- `dsh.worktable.projects.v1`:order/lastUsed/hidden/nameOverrides/shortcuts;
- 卡片上报的 meta 注册表仅存内存,不持久化;
- 样式:暗色优先,跟随 `--dsw-alias-*` 设计变量(与 dsh-usage / dsh-travelatlas 一致)。
## 7. 与 dsh-travelatlas 的关系
- travelatlas 是「第一个入驻项目」,不是工作台的一部分;
- travelatlas 客户端(2026-08-16 重写):图鉴视图 = 官方 `conversation.view` 会话头标签页(iframe 到
/travelatlas/site/),工作台卡片与降级入口点击时程序化切到该标签页;工作台缺席时回退 `sidebar.footer.action` 独立入口;
- 卡片协议 v2 已接入(2026-08-16 与并行重写冲突后被覆盖,已重新应用并构建);
- 项目图标 🌏(地球·亚洲)为 travelatlas 官方 emoji(2026-08-16 定案);工作台折叠态图标框
按各项目上报的自身 emoji 展示(协议 §5.3 reportMeta.icon)。
## 8. 隐私与安全
- 无个人数据采集;所有状态仅存 localStorage;
- 搜索仅在本机过滤项目名;
- 不读写任何工作区文件、不请求任何网络资源(除插件自身静态资源)。
## 9. 验收清单(v1)
- [ ] 侧边栏底部出现分隔线 + 「工作台」标题 + 三按钮 + 项目卡片(🌍 旅行 Atlas);
- [ ] ≡ 拖动区块上下移动,松手停靠,刷新后位置保持;双击 ≡ 复位;
- [ ] 搜索框展开/收起正常,输入能过滤项目卡片,Esc / ✕ 可关;
- [ ] 视图选项下拉可切换分组/排序并持久化;
- [ ] 添加(+) 点击显示「待定」占位提示;
- [ ] 卸载 dsh-worktable 后,dsh-travelatlas 自动回退为底部独立入口(不白屏);
- [ ] 与 dsh-usage、dsh-reminder、官方侧边栏折叠态共存无异常。
### 验收清单(v2,§10 定案,待重启后 GUI 验证)
- [ ] 视图选项菜单只含排序(手动/最近)+ 管理项目入口,选择持久化;
- [ ] 切「最近」后点击 travelatlas 卡片,卡片置顶;
- [ ] 管理项目:改名/隐藏/拖拽或 ↑↓ 排序生效,刷新保持;「恢复默认」清空排序/隐藏/改名;
- [ ] 隐藏后卡片从卡片区消失,管理条中可恢复显示;
- [ ] 「+」面板:接入指引 + 市场外链打开正常;快捷方式校验生效,添加后新标签打开、搜索可过滤、编辑模式可删除;
- [ ] 语言切 en 时工作台文案跟随(标题/菜单/面板);
- [ ] v1 兼容:未上报卡片仍显示且排在已上报卡片之前;
- [ ] travelatlas 降级回退不受影响(卸载工作台后回退底部入口)。
## 10. 定案记录(v2,本窗口与用户讨论后定案)
> 以下条目原为「待设计内容」,已于 2026-08-16 与用户逐项讨论定案并实现,规格并入 §5/§6。
| # | 议题 | 定案 |
| --- | --- | --- |
| 1 | 工作台自身内容 | 不做独立路由页;☰ 菜单「管理项目…」→ 区块内编辑模式(改名/隐藏/排序),见 §5.5 |
| 2 | 添加(+) 逻辑 | 接入指引面板 + 本地快捷方式条目,见 §5.6 |
| 3 | 卡片规范 v2 | 渐进上报协议(owner props 扩展,全部可选),见 §5.3 |
| 4 | 分类 | **取消分类**(用户定案:每项目占一行、自成工作台,无需分组);视图菜单只留排序 |
| 5 | 国际化 | zh/en 词典接入 dsh-client-locale,见 §5.7 |
已知边界:未上报元信息的 v1 卡片按注册序排在最前、不参与排序/隐藏/改名(协议兼容取舍);
市场外链用 GitHub 仓库 https://github.com/hikariming/dshfind(已核实可访问;dshfind.com 未验证、不链死链)。
## 11. 已实现 vs 待实现(接手分界线)
| 部分 | 状态 | 位置 |
| --- | --- | --- |
| 侧边栏区块 + 三按钮 + ≡ 拖动 | ✅ v1 已实现 | `01_content/src/client/` |
| 项目子座位协议 + travelatlas 入驻 | ✅ v1 已实现 | 同上 + dsh-travelatlas/src/client |
| 服务端健康路由 | ✅ 已实现 | `01_content/src/index.ts` |
| 视图菜单去分组、编辑模式、添加面板、快捷方式、埋点、i18n | ✅ 本窗口(v2)已实现并构建,待重启后 GUI 验收 | `01_content/src/client/`(§5.3–§5.7) |
| travelatlas 卡片协议 v2 | ✅ 本窗口已实现并构建(并行重写覆盖后已重新应用) | `dsh-travelatlas/src/client/index.tsx` |
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,156 @@
# 2026-08-16 dsh-worktable v2:PRD §10 定案并实现
> 日期 / 主题 / 状态:2026-08-16 · 工作台 v2(管理/添加/卡片规范 v2/排序埋点/i18n) · 代码完成,待重启后 GUI 验收
## 任务目标
按 PRD §10 与用户逐项讨论定案后实现:①管理视图(编辑模式)②添加(+) 真实逻辑 ③卡片规范 v2
(渐进上报协议)④分类(取消)⑤i18n 接入 dsh-client-locale;并升级 dsh-travelatlas 卡片协议。
## 定案记录(与用户问答确认)
1. **管理功能**:☰ 菜单「管理项目…」→ 区块内编辑模式(改名/隐藏/拖拽+↑↓排序),全存 localStorage;不做独立路由页;不做真正插件启停(宿主职责)。
2. **添加(+)**:接入指引面板(注册即入驻说明 + 市场外链)+ 本地快捷方式条目(名称/图标/链接,点击新标签打开,标「本地」角标)。
3. **卡片规范 v2**:owner props 扩展(query/wide/order/hidden/nameOverrides/managing/reportMeta/reportUsed),全部可选;v1 卡片零改动兼容(按注册序排最前)。
4. **分类**:取消(用户定案:每项目占一行、自成工作台,无需分组);视图菜单只留「排序:手动/最近」;旧 groupBy 状态忽略。
5. **i18n**:NS worktable zh/en 词典,ctx.locale.register + package.json 声明 dsh-client-locale;宿主缺席回退 zh。
6. **市场外链**:https://github.com/hikariming/dshfind(已核实可访问;dshfind.com 未验证,不链死链)。
## 实际改动
- 工作台 `01_content/src/client/locales.ts`(新增):zh/en 词典(31 键)+ NS + 类型声明。
- 工作台 `01_content/src/client/index.tsx`(重构):
- 视图菜单去分组,只留排序(手动/最近)+「管理项目…」;
- 管理面板:改名/隐藏/拖拽(HTML5 drag)/↑↓ 排序/恢复默认;编辑态弱化卡片区;
- 添加面板:接入指引 + 市场外链 + 快捷方式表单(http/https 校验);
- 排序:effectiveOrder(手动=持久化序+注册序补齐;最近=lastUsed 降序+其余手动序);
- 协议回调 reportMeta/reportUsed(useCallback 稳定引用)+ 模块级注册 id 跟踪
(ctx.slots.subscribe + entries);
- 持久化 loadView/loadProjects 显式挑字段(旧 groupBy 忽略)。
- 工作台 `01_content/src/client/styles.ts`:新增菜单分隔/添加面板/管理面板/快捷方式/编辑弱化样式;移除旧 tip 规则。
- 工作台 `01_content/package.json`:dsh.client.inject + peerDependencies 增加 @deepseek-ai/dsh-client-locale。
- travelatlas `src/client/index.tsx`:WorktableCard 升级协议 v2(WORKTABLE_CARD_ID、reportMeta、
reportUsed、hidden/order/nameOverrides 消费,约 20 行);浮层/降级回退/站点未动。
- 构建:两插件 npm run build + node --check 全部通过。
- 健康检查:/api/worktable/health 200(旧进程)。
## 完成状态
代码实现完成;GUI 验收待重启后进行(bundle 层启动时组合)。
## 验证方式与结果
- 工作台、travelatlas 均 npm run build + node --check 通过;
- /api/worktable/health 200;
- 机制预核实(读宿主源码):列表座位渲染器 display:contents 锚点 + 错误边界无 DOM 包裹
→ 卡片根节点为 .dsh-wt_projects 直接 flex 子项,CSS order 排序可行;
- 待验证:重启后 GUI 验收清单(PRD §9 v2 项)。
## 尚未验证 / 待续事项
- 重启后的 GUI 验收(排序/编辑模式/添加面板/i18n/隐藏);
- 未上报 v1 卡片的兼容展示(当前 profile 无此类卡片,需造测试插件验证);
- 管理面板 HTML5 拖拽在侧边栏内的交互体验;
- 宿主无插件清单 API(/api/plugins 等已探测 404),「自动发现已装项目」本版不可行——PRD §10 已知边界。
## 后续待办
- 重启 dsh web(schedule-restart.ps1 已安排)后按 PRD §9 v2 清单验收;
- 观察 travelatlas 降级回退是否正常(卸载工作台场景)。
## 涉及文件清单
| 文件 | 操作 |
| --- | --- |
| dsh-worktable/01_content/src/client/locales.ts | 新增 |
| dsh-worktable/01_content/src/client/index.tsx | 重构 |
| dsh-worktable/01_content/src/client/styles.ts | 增删样式 |
| dsh-worktable/01_content/package.json | 声明 locale 依赖 |
| dsh-worktable/02_process/PRD.md | §3/§5/§6/§9/§10/§11 同步 |
| dsh-travelatlas/src/client/index.tsx | 卡片协议 v2(最小 diff) |
| 本文件 | 新增工作记录 |
## 补记(22:30 左右,重启验收阶段)
- 22:22 计划任务杀掉旧 dsh web 后,用户手动重启(22:22:59 起的新服务);工作台 v2 bundle
经字节级验证已在服务器生效,用户硬刷新后确认 GUI 正常(☰ 菜单/添加面板均为 v2)。
- **travelatlas 并行重写冲突**:22:10:44 其 src/client/index.tsx 被重写为 conversation.view
会话标签页架构(旧全屏浮层废弃),覆盖了本窗口早前应用在旧卡片上的 v2 协议改动。
- 处理:经用户确认后,把 v2 协议重新应用到新卡片(WorktableCard 约 20 行,不动 conversation.view
逻辑),重新构建并通过 node --check;验证服务器实时下发新 bundle(served len=9933、
含 WORKTABLE_CARD_ID/reportMeta),无需再次重启。
- **偶发排版错乱(已自愈)**:用户反馈界面排版乱、随后自行恢复。排查确认宿主资源未变、
travelatlas 样式为局部类、3080 仅一台服务器(npx 与 bin.js 为父子进程)。推断为页面刷新时
恰逢 travelatlas bundle 重建落盘(22:33:17)的写入竞争或重启后页面半载状态;无需代码修复。
教训:对外交付前应避免「用户可刷新的时间窗」内重打包插件。
## 补记(拖动与折叠体验修正)
- 需求(用户反馈):① 侧边栏折叠再展开、以及快速拖动越界松手时,不应重置到 footer,
而应回归上一次位置;② 折叠态不应显示「≡」,应显示一个框框收纳所有项目 emoji,
框框位置与展开前一致。
- 实现:① 移除 wide 变化时的 dock 重置(折叠/展开保持原停靠;仅窗口 resize 仍回弹 footer);
拖动新增越界判定(超出底部/顶部/侧边 24/24/80px 即「无有效落点」),松手回归拖前位置且不持久化;
② 折叠态渲染 .dsh-wt_railBox:项目 emoji(无报到卡片回退 📦)+ 快捷方式图标;
浮动态先以文档流实测折叠列几何(useLayoutEffect),再 fixed 定位到拖前高度;无项目时显示「≡」。
- 验证:构建 + node --check 通过;服务端实时下发(served len=38441,含 railBox/overshoot 标记),
无需重启,用户刷新后实测。
## 补记(与 dsh-usage 余额 dock 的共存修正)
- 问题(用户反馈):侧边栏新出现 dsh-usage 的余额悬浮面板(.u_dock:position:fixed、
bottom:72px、z-index:30,覆盖在侧边栏底部),遮挡了工作台停靠区与 ≡ 手柄,导致无法拖动。
- 实现:① 停靠态自动避让——扫描侧边栏列内贴底(距底 <300px、left<80px)的 fixed 面板,
与区块自然位置重叠时以 margin-bottom 把区块抬到面板上方(上限 340px,含多重面板取最大值);
停靠期间每 2s 轮询跟随面板自身移动;浮动态不避让(z-index 70 天然在上)。
② 标题文字也作为拖拽手柄(≡ 与标题均可拖、双击复位),加大抓取面积。
③ 检测为通用算法(不硬编码 .u_dock 类名),不依赖 dsh-usage 内部结构。
- 验证:构建 + node --check 通过;服务端实时下发(served len=40909,含 bottomInset/
measureBottomOverlay 标记),无需重启,用户刷新后实测。
## 补记(拖动无法落位的根因修复)
- 问题(用户反馈):能拖动,但松手必回原位。
- 根因:避让让位后停靠位置低于「固定 380px 估算的浮动上限 maxTop」,旧越界闩锁把起始位置
本身判定为越界 → 每次松手都触发「回归拖前位置」。
- 修复:① 浮动上限改为按区块实际高度计算(window.innerHeight - startRect.height - 12),
停靠位紧邻浮动范围下沿;② 取消拖动过程越界闩锁,改为「松手瞬间按指针位置判定落点」——
指针在有效落点区内则落位,越出(底/顶 24px、侧边 80px)才回归拖前位置。
- 验证:构建 + node --check 通过;服务端实时下发(served len=40969),无需重启。
## 补记(折叠态图标框水平偏移修复)
- 问题(用户反馈):侧边栏收起后 emoji 图标框偏右,不在窄栏中间。
- 根因:折叠瞬间在过渡帧上测得「展开态」的列宽/left,fixed 定位用旧几何 → 图标按旧宽度居中而偏位。
- 修复:改为折叠动画结束后(320ms + 750ms 双次重测取收敛值)再测量并定位;测量带收敛守卫
(差值 <1px 不重复 setState)。
- 验证:构建 + node --check 通过;服务端实时下发(served len=41341,含 measureRailRect),无需重启。
## 补记(悬浮窗宽度与 sidebar 联动)
- 需求(用户规格):悬浮窗只做宽度与水平定位的联动(高度仍由拖拽决定):
dockWidth = sidebar 宽 − paddingLeft − paddingRight − 40px(每边内缩 20px);
left = sidebar 左边缘视口坐标 + paddingLeft + 20px;
用 ResizeObserver 监听 sidebar(拖宽/折叠/窗口缩放实时跟随);
sidebar 识别 = 从挂载点向上遍历父链,className 含 SidebarRoot/sidebar 或标签 aside/nav,到 body 为止;
降级:找不到 sidebar 或宽度 ≤0 → left 固定 14px,宽度不设内联(CSS min-width:176px/max-width:264px)。
- 实现:FloatRect 精简为 { top }(left/width 全部由派生几何提供);新增 findSidebar 帮助函数、
floatGeo 状态与 measureFloatGeo(getBoundingClientRect + getComputedStyle 取 padding,取整);
useLayoutEffect 挂载首测 + ResizeObserver 订阅 + 卸载 disconnect,无轮询;
拖动仅更新 top,落点/回弹逻辑不变;.dsh-wt_float 增加 min/max 宽度兜底。
- 验证:构建 + node --check 通过;服务端实时下发(served len=42718,含 findSidebar/ResizeObserver)。
## 补记(折叠图标放大 + 项目 emoji 定案 🌏)
- 需求(用户反馈):① 折叠态图标框里的 emoji 太小 → 放大(12px → 17px,行高 16 → 22px);
② 旅行 Atlas 的 emoji 不是用户给的 → 经询问定案为 🌏(地球·亚洲,U+1F30F);
③ 后续所有项目按各自上报的 emoji 展示(协议 reportMeta.icon 已支持,无需改工作台)。
- 改动:travelatlas src/client/index.tsx 全部图标统一 🌏(卡片/报到/降级入口/分栏标题),
重建并通过检查;worktable styles .dsh-wt_railIcon 放大。
- 备注 1:23:21:58 dsh web 被重启过一次(非本窗口发起,疑并行窗口操作),打断了我的一步验证,
服务恢复后健康检查正常。
- 备注 2:travelatlas 客户端在本窗口期间被并行重写两次(会话标签页版 → 分栏模式版),
每次重写都会覆盖 v2 协议与 emoji;本窗口已两次重新应用。
- 备注 3(验证教训):esbuild 默认 ascii charset,非 ASCII 字符在 bundle 里以 \u{...} 转义
输出,且 pwsh Get-Content/IWR 按 ANSI 解码 UTF-8——校验 emoji 需用 read 工具读 UTF-8
或直接查转义序列(如 1F30F)。
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
# dsh-worktable 项目规则
> 工作台容器插件:侧边栏里收纳 agent 级项目的「应用抽屉」。纯增量,不替换官方插件。
## 边界
- 插件包根目录 = `01_content/`;本仓库其余目录是项目文档与本地工具。
- **不替换、不禁用任何官方插件**(ui-sidebar / ui-workspace / ui-layout)。
- 所有状态只存 localStorage(键 `dsh.worktable.view.v1`),不读写工作区文件。
- dsh-travelatlas 是入驻项目而非本仓库的一部分;协议见 `02_process/PRD.md` §5.3/5.4。
## 构建与验证
```powershell
cd 01_content
npm install
npm run build # lib/index.js + lib/client.js
node --check lib/index.js
```
- 客户端 bundle 必须保持 `window.__ModuleLoader__.load` 握手与 external react/@deepseek-ai/*。
- 变更视图状态结构时同步更新 PRD 的持久化说明。
## 安装 / 重启
- 注册:`dsh plugin --profile web add "link:<repo>/01_content"`(写 ~/.dsh,需用户授权)。
- bundle 层只在启动时组合:改动后必须重启 dsh web 并刷新 GUI。
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 dsh-worktable contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+42
View File
@@ -0,0 +1,42 @@
# dsh-worktable · 工作台
> DeepSeek Harness 侧边栏「工作台」容器插件:在「工作区」下方开辟一个收纳 agent 级项目(如 TravelAtlas 旅行图鉴)的应用抽屉。
> A DeepSeek Harness sidebar "worktable" container: a project drawer below the official workspace section, hosting agent-level projects (e.g. TravelAtlas).
## 目录结构(仿 dsh-reminder)
```
dsh-worktable/
├── 00_index/ # 项目导航
├── 01_content/ # 插件包(dsh bundle:cordis 服务端 + 客户端 CJS)
├── 02_process/ # PRD.md / drafts / research / worklogs
├── 03_local/ # 本机脚本与日志(不入库)
├── 04_test/ # 测试与验证产物
├── README.md # 本文件
└── AGENTS.md # 项目规则
```
## 当前状态
- v2 稳定版:侧边栏区块(拖动/停靠/折叠态项目图标框)+ 排序(手动/最近)+ 管理项目(改名/隐藏/拖拽排序)+ 添加面板(接入指引 + 本地快捷方式)+ zh/en 词典 + 使用埋点 ✅
- 项目协议 v2:`sidebar.worktable.project` 子座位 + 渐进上报协议(reportMeta/reportUsed/order/hidden/nameOverrides,全部可选,v1 卡片兼容)✅
- 悬浮窗几何与 sidebar 联动(ResizeObserver)、底部悬浮面板避让 ✅
- 首个入驻项目:dsh-travelatlas(图标 🌏,协议 v2 参考实现)✅
## 构建与安装
```powershell
cd 01_content
npm install # 安装 esbuild 构建工具
npm run build # esbuild 打包 lib/
# 注册进本机 web profile(写入 ~/.dsh/profiles/web,需用户授权)
dsh plugin --profile web add "link:E:/AI_Workspace/DeepseekHarness/Projects/dsh-worktable/01_content"
# 重启 dsh web 后刷新 GUI 查看侧边栏底部「工作台」
```
## 详见
- 需求与设计:`02_process/PRD.md`
- 项目规则:`AGENTS.md`
- 导航:`00_index/README.md`