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
+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)
},
})
}