feat: 本地站点目录级托管(/api/worktable/site前缀路由) 资源管理器点html完整渲染相对资源
This commit is contained in:
@@ -16,7 +16,7 @@ import MarkdownIt from 'markdown-it'
|
||||
export type BuiltinType = 'browser' | 'explorer' | 'scm' | 'tasks' | 'terminal'
|
||||
|
||||
export type SplitContent =
|
||||
| { kind: 'iframe'; url: string }
|
||||
| { kind: 'iframe'; url: string; title?: string }
|
||||
| { kind: 'builtin'; type: BuiltinType }
|
||||
| { kind: 'file'; path: string }
|
||||
|
||||
@@ -128,6 +128,7 @@ const BUILTIN_LABEL_KEYS: Record<BuiltinType, string> = {
|
||||
function tabTitleOf(content: SplitContent): string {
|
||||
if (content.kind === 'builtin') return T(BUILTIN_LABEL_KEYS[content.type])
|
||||
if (content.kind === 'file') return basenameOf(content.path)
|
||||
if (content.kind === 'iframe' && content.title) return content.title
|
||||
try {
|
||||
const u = new URL(content.url)
|
||||
return u.hostname || content.url
|
||||
@@ -929,7 +930,13 @@ function ExplorerPane(props: { row: PaneRow; index: number }) {
|
||||
onClick={() => {
|
||||
if (e.isDir) { toggle(e.path); return }
|
||||
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)) {
|
||||
splitStore.openTab(props.row, props.index, { kind: 'file', path: e.path })
|
||||
} else {
|
||||
|
||||
+50
-1
@@ -23,6 +23,21 @@ export const HEALTH_PATH = '/api/worktable/health'
|
||||
|
||||
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)。
|
||||
* 本包经 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',
|
||||
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)
|
||||
} catch (err) {
|
||||
json(res, 404, { error: String(err) })
|
||||
|
||||
Reference in New Issue
Block a user