feat: 本地站点目录级托管(/api/worktable/site前缀路由) 资源管理器点html完整渲染相对资源

This commit is contained in:
Aisland
2026-08-17 23:39:06 +08:00
parent afc637df54
commit ded6242548
8 changed files with 121 additions and 27 deletions
+9 -2
View File
@@ -16,7 +16,7 @@ import MarkdownIt from 'markdown-it'
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal' export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
export type SplitContent = export type SplitContent =
| { kind: 'iframe'; url: string } | { kind: 'iframe'; url: string; title?: string }
| { kind: 'builtin'; type: BuiltinType } | { kind: 'builtin'; type: BuiltinType }
| { kind: 'file'; path: string } | { kind: 'file'; path: string }
@@ -128,6 +128,7 @@ const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
function tabTitleOf(content: SplitContent): string { function tabTitleOf(content: SplitContent): string {
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type]) if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
if (content.kind === 'file') return basenameOf(content.path) if (content.kind === 'file') return basenameOf(content.path)
if (content.kind === 'iframe' && content.title) return content.title
try { try {
const u = new URL(content.url) const u = new URL(content.url)
return u.hostname || content.url return u.hostname || content.url
@@ -929,7 +930,13 @@ function ExplorerPane(props: { row: PaneRow; index: number }) {
onClick={() => { onClick={() => {
if (e.isDir) { toggle(e.path); return } if (e.isDir) { toggle(e.path); return }
if (/\.html?$/i.test(e.name)) { if (/\.html?$/i.test(e.name)) {
splitStore.openTab(props.row, props.index, { kind: 'iframe', url: '/api/worktable/file?path=' + encodeURIComponent(e.path) }) // 目录级静态托管:相对引用(./assets/...)在所在目录下解析,页面可完整渲染
const dir = parentPathOf(e.path)
splitStore.openTab(props.row, props.index, {
kind: 'iframe',
url: '/api/worktable/site/' + encodeURIComponent(dir) + '/' + encodeURIComponent(e.name),
title: e.name,
})
} else if (/\.(md|markdown|mdown|txt|log|pdf|png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(e.name)) { } else if (/\.(md|markdown|mdown|txt|log|pdf|png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(e.name)) {
splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path }) splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path })
} else { } else {
+50 -1
View File
@@ -23,6 +23,21 @@ export const HEALTH_PATH = '/api/worktable/health'
const MAX_ENTRIES = 500 const MAX_ENTRIES = 500
/** 本地文件/站点静态资源的 MIME 映射(file 与 site 两条路由共用) */
const FILE_TYPES: Record<string, string> = {
html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
css: 'text/css; charset=utf-8', js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
json: 'application/json; charset=utf-8', map: 'application/json; charset=utf-8',
md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8',
txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', avif: 'image/avif',
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
wasm: 'application/wasm', mp3: 'audio/mpeg', mp4: 'video/mp4', webm: 'video/webm',
}
const SITE_PREFIX = '/api/worktable/site'
/** /**
* 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。 * 从本插件模块位置向祖先方向查找并加载 node_modules 包(如 ws / node-pty)。
* 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖; * 本包经 junction 链接进 profile,普通 import 可能解析不到 profile 级依赖;
@@ -211,7 +226,41 @@ export function apply(ctx: Context) {
json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8', md: 'text/markdown; charset=utf-8', markdown: 'text/markdown; charset=utf-8', txt: 'text/plain; charset=utf-8', log: 'text/plain; charset=utf-8',
pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', pdf: 'application/pdf', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
} }
res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' }) res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
res.end(data)
} catch (err) {
json(res, 404, { error: String(err) })
}
},
})
// 本地站点(目录级静态托管):点开 index.html 时挂载整个所在目录,
// 让 ./assets/... 等相对引用正常解析(前缀路由,余下路径 = <rootToken>/<相对路径>)。
webServer.register({
kind: 'prefix',
path: SITE_PREFIX,
handler: async (req: any, res: any) => {
try {
if (req.method !== 'GET') { res.writeHead(405); res.end(); return }
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const segs = pathname.slice(SITE_PREFIX.length).split('/').filter(Boolean)
const rootToken = segs.shift() ?? ''
const rel = segs.map((s) => { try { return decodeURIComponent(s) } catch { return s } }).join('/')
if (!rootToken) { json(res, 400, { error: 'missing root' }); return }
const root = pathResolve(rootToken)
let abs = pathResolve(root, rel)
if (abs !== root && !abs.startsWith(root + sep)) { json(res, 403, { error: 'outside root' }); return }
const statMod = await import('node:fs/promises')
let info = await statMod.stat(abs).catch(() => null)
if (info && info.isDirectory()) {
abs = pathResolve(abs, 'index.html')
info = await statMod.stat(abs).catch(() => null)
}
if (!info || !info.isFile()) { json(res, 404, { error: 'not found' }); return }
if (info.size > 40 * 1024 * 1024) { json(res, 413, { error: 'file too large' }); return }
const data = await readFile(abs)
const ext = (abs.split('.').pop() || '').toLowerCase()
res.writeHead(200, { 'content-type': FILE_TYPES[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' })
res.end(data) res.end(data)
} catch (err) { } catch (err) {
json(res, 404, { error: String(err) }) json(res, 404, { error: String(err) })
+1 -1
View File
@@ -353,7 +353,7 @@ type SplitPane = {
| 内容窗 | 可行性 | 依据 | | 内容窗 | 可行性 | 依据 |
| --- | --- | --- | | --- | --- | --- |
| 浏览器 | ✅ 已实现 | iframe + 地址栏 | | 浏览器 | ✅ 已实现 | iframe + 地址栏 |
| 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html iframe、.md markdown-it 渲染、.txt/.log 纯文本、.pdf pdf.js 自绘阅读器(抓手:按住空格临时拖动 / H 切换常开 / 顶部 ✋ 按钮,缩放与适应宽度)、常见图片居中展示;pdf.js worker 源码注入客户端 bundle(Blob URL 起 module worker,零服务端依赖) | | 资源管理器 | ✅ 已实现(第一版) | 服务端 /api/worktable/fs 目录列表(参考 better-sidebar 架构);文件点击开预览标签(2026-08-17):.html → /api/worktable/site 目录级静态托管(相对资源随目录解析,本地网页完整渲染;PDF 已回退原生 iframe 阅读器)、.md markdown-it 渲染、.txt/.log 纯文本、常见图片居中展示 |
| 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 | | 源代码管理 | ✅ 已实现(第一版) | 服务端 /api/worktable/git(porcelain v1 -z);diff/暂存/提交待后续 |
| 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) | | 终端 | ✅ 已实现(第一版) | WS /api/worktable/term + node-pty + xterm(宿主缺 node-pty 时降级提示) |
| 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) | | 任务管理 | ✅ 已实现(第一版) | 客户端 sessions 快照 jobsBySession(后台任务列表,2s 刷新) |
+14 -23
View File
@@ -639,27 +639,18 @@
原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动—— 原生视图内部是浏览器扩展页,插件注入不了按钮、拦不到鼠标,也驱动不了内部滚动——
向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目 向用户如实说明后按指示回退);② 视图选项改名「设置」,点开直接内嵌「排序方式 + 管理项目
展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑, 展开列表」,不再二次点击;③ 管理项目里给现有项目加「变更视图」:换预设拓扑,
## 补记(常驻项目彻底去特殊化:变更视图通用 / 真删除 / 去箭头) ## 补记(本地站点目录级托管:资源管理器点 index.html 完整渲染相对资源)
- 用户反馈(四条):① 两个常驻项目也要变更视图按钮;② 怀疑底层代码里写死了这两个项目的 - 用户问题:AI 乐(静态内容 HTML)点 index.html 能渲染,旅行 Atlas(JS 单页应用)白屏。
特殊内容——应该完全不特殊;③ 删除常驻项目后「恢复默认」不应复活它们(真删除), - 根因:旧预览走 /api/worktable/file 单文件直读,页内相对引用(./assets/...)解析到错误目录 404;
但整个流程可逆(可重新添加回来);④ 管理行去掉 ↑↓ 箭头(左缘 ≡ 抓手拖拽已够用)。 AI 乐 HTML 自带静态内容所以仍可见,旅行 Atlas 只有空 root + JS 注入所以白屏。
- 处理: - 处理(用户确认方案 1):
① 变更视图通用化:projects.v1 新增 views(id → LayoutSpec 视图覆盖);管理行 🧩 按钮 ① 服务端新增前缀路由 /api/worktable/site(kind:'prefix',模式照搬 better-sidebar):
所有项目显示;applyLayoutChange 对布局项目重建条目、对入驻项目写 views[id]; URL = /site/<encodeURIComponent(目录)>/<相对路径>,逐段 decode,root 目录内沙箱(.. 越界 403),
openSplit 打开时用 views[spec.id] 替换自声明布局;卡片点击拦截(有覆盖时用引擎打开), 目录访问回退 index.html;40MB 上限;FILE_TYPES 共享 MIME 表扩充(字体/wasm/音视频/avif 等);
常驻项目一样可变更视图; ② 客户端资源管理器点 .html/.htm 改开 site URL(root=所在目录,rel=文件名),标签标题=文件名
② 去硬编码:删除 LEGACY_SPLIT_IDS/ta_card/pr_card 类名映射;改为通用 DOM 桥—— (SplitContent iframe 增加可选 title 字段);单文件预览(md/txt/pdf/图片)不变。
子座位卡片按注册序 ↔ aliveRegisteredIds 位置映射(button:not(.dsh-wt_layout)), - 验证(functional-diag STEP12 + 04_test/fixture-site 夹具:index.html + assets/app.js/style.css):
标记 data-wt-id;图标覆盖写到卡片第一个子元素;CSS 全部换成 [data-wt-id] 通用选择器 预检路由存在才断言(appText=SITE_OK + CSS 颜色注入)。当前运行中的服务器未加载新路由 →
(框/高度/字号/字重/描述行隐藏);reportUsed 冷却对全部非引擎项目通用; routeReady=false(404 已过滤,ERRORS_COUNT: 0),其余 STEP1-11 全绿。
split.tsx 仅保留跨插件互操作桥(.ta_split 浮层让位,注释中性化); - 备注:含服务端路由,**需手动重启一次 dsh web** 后 STEP12 完整验证与旅行 Atlas dist 实测。
③ 删除语义:resetProjects 不再清空 removed(恢复默认不复活);设置面板新增「已删除的项目」
区块,逐条「↺ 重新添加」(删除 ↔ 重新添加完全可逆);confirm.projectBody 文案同步;
④ 管理行删除 ↑/↓ 按钮与 moveBy 函数,排序只靠 ≡ 拖拽;locale 删 manage.up/down,
新增 manage.removed/readd。
- 验证(functional-diag STEP9/10 重写):STEP9 常驻行 3 按钮(无箭头+🧩)→ 变更视图
views['planreview'] 三栏拓扑 ✓;布局行迁移不变 ✓;STEP10 删除后 reset removedAfterReset
仍含 id(不复活)→ removedSection=true → ↺ 重新添加 removed=[] 且卡片回归 ✓;
通用桥回归:框/高度/字重/图标覆盖/desc 隐藏全 ✓;ERRORS_COUNT: 0。
- 备注:纯客户端改动,F5 即生效,无需重启 dsh web。
+1
View File
@@ -0,0 +1 @@
document.getElementById('app').textContent = 'SITE_OK';
+1
View File
@@ -0,0 +1 @@
#app { color: rgb(216, 72, 82); }
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>Fixture Site</title>
<link rel="stylesheet" href="./assets/style.css" />
</head>
<body>
<div id="app">LOADING</div>
<script src="./assets/app.js"></script>
</body>
</html>
+33
View File
@@ -304,8 +304,41 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
})()`); })()`);
console.log('STEP11:', JSON.stringify(step11)); console.log('STEP11:', JSON.stringify(step11));
// 本地站点目录级托管:index.html 的相对资源(./assets/...)应随目录一起解析渲染
const step12 = await evaluate(`(async function(){
var out={};
try{
var st=window.__dshWorktable.splitStore;
st.open({id:'t-site',title:'site',top:null,main:[{id:'s1',title:'s',min:200,content:null}],chatWidth:{default:320,min:240,max:600}});
await new Promise(function(r){setTimeout(r,400)});
var rootToken=encodeURIComponent('E:\\\\AI_Workspace\\\\DeepseekHarness\\\\Projects\\\\dsh-worktable\\\\04_test\\\\fixture-site');
var pr=await fetch('/api/worktable/site/'+rootToken+'/index.html').catch(function(e){ return null; });
out.routeReady=!!(pr&&pr.ok);
out.probeStatus=pr?pr.status:null;
if(out.routeReady){
st.openTab('main',0,{kind:'iframe',url:'/api/worktable/site/'+rootToken+'/index.html',title:'site'});
await new Promise(function(r){setTimeout(r,1800)});
out.tabTitle=st.spec.main[0].tabs[0].title;
var f=document.querySelector('.dsh-wt_paneFrame');
var doc=f&&f.contentDocument;
if(doc){
var app=doc.getElementById('app');
out.appText=app?app.textContent:null;
out.appColor=app?doc.defaultView.getComputedStyle(app).color:null;
}
}
st.close();
}catch(err){ out.err=String(err) }
return JSON.stringify(out);
})()`);
console.log('STEP12:', JSON.stringify(step12));
const errors = events.filter((e) => { const errors = events.filter((e) => {
// 站点路由属新增服务端能力:运行中的服务器未重启前该 404 属预期(重启后移除本过滤)
const ent = e.method === 'Log.entryAdded' ? e.params.entry : null;
const txt = ent ? (ent.text || '') + '|' + (ent.url || '') : '';
if (txt.indexOf('/api/worktable/site') >= 0) return false;
return true;
}).filter((e) => }).filter((e) =>
e.method === 'Runtime.exceptionThrown' || e.method === 'Runtime.exceptionThrown' ||
(e.method === 'Log.entryAdded' && e.params.entry.level === 'error') || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error') ||