场景:前端页面让用户选择一个本地目录,做预览(纯前端读文件渲染)或导入(落盘到服务器)。 适用栈:本仓库 React + esbuild 的前端(
src/*/public、services/*),后端 Nodefs。
1. 结论:默认不能,只有两种「用户明确授权」的例外
浏览器处于安全沙箱,脚本无文件系统权限,直接 fetch("file:///...") 会被同源策略/CORS 拦截。
仅以下两种需在用户手势触发下可用:
| File System Access API(FSA) | <input type="file" webkitdirectory> | |
|---|---|---|
| 浏览器 | Chrome / Edge(Chromium 系) | 全部(含 Firefox / Safari) |
| 读目录树 | ✅ 真递归 dir.entries() | ⚠️ 间接:靠 webkitRelativePath 反推 |
| 写回 | ✅ createWritable() | ❌ |
| 真实路径 | ✅(相对根) | ❌ 拿不到 |
| 安全上下文 | 必须 localhost / https | 同 |
选型:用 FSA 做主路径,检测到不支持时回退 webkitdirectory。两者都只在按钮点击时触发。
2. 读取目录树(FSA 主路径)
type TreeNode = {
name: string;
path: string; // 相对所选根
kind: "dir" | "file";
handle?: FileSystemFileHandle;
children?: TreeNode[];
};
async function readDirectoryTree(
dir: FileSystemDirectoryHandle,
base = "",
): Promise<TreeNode> {
const node: TreeNode = { name: dir.name, path: base, kind: "dir", children: [] };
for await (const [name, entry] of (dir as any).entries()) {
const childPath = base ? `${base}/${name}` : name;
if (entry.kind === "file") {
node.children!.push({ name, path: childPath, kind: "file", handle: entry });
} else {
node.children!.push(await readDirectoryTree(entry, childPath));
}
}
return node;
}
async function collectFiles(node: TreeNode, out: Map<string, File>): Promise<void> {
if (node.kind === "file" && node.handle) {
out.set(node.path, await (node.handle as FileSystemFileHandle).getFile());
return;
}
for (const c of node.children ?? []) await collectFiles(c, out);
}
async function pickLocalDirectory(): Promise<{ tree: TreeNode; files: Map<string, File> }> {
const dir = await (window as any).showDirectoryPicker(); // 用户手势触发
const tree = await readDirectoryTree(dir);
const files = new Map<string, File>();
await collectFiles(tree, files);
return { tree, files };
}
读内容:const file = await handle.getFile(); const text = await file.text();
回退路径(非 Chromium)
<input type="file" webkitdirectory multiple
onChange={(e) => { /* 遍历 e.target.files,按 file.webkitRelativePath 反推树 */ }} />
回退路径拿不到真实绝对路径、不能写回,仅用于预览/导入,无法递归列目录(需自行按 webkitRelativePath 拼树)。
3. 两种落地形态
- 预览(纯前端):把
tree喂给现有的文件树组件(theme-workbench 已有FileNode/ 树 UI),选中文件用file.text()渲染,不经过后端。 - 导入(落盘):把
files打包成FormData/JSONPOST 到后端某/api/...,由 Nodefs写入目标目录 (主题即写入src/store/public/themes/<theme>/)。浏览器只能选 + 读,落盘必须由后端做—— 与现有 theme-workbench「服务端fs读 themesDir」架构一致。
4. 部署约束
- FSA 要求 secure context:dev 跑在
localhost(如localhost:8003)✅;线上必须用 https,否则showDirectoryPicker为undefined。 - 权限会话级,刷新后需重新
showDirectoryPicker(可用queryPermission续期)。
5. 在本仓库的建议落点
- 通用能力:新增
src/common/client/useLocalDirectory.ts(hook)+<DirectoryPicker>组件, 与现有services/*平级,前端统一复用;内部做showDirectoryPicker是否存在的 feature detection, 缺失时渲染回退的webkitdirectoryinput。 - 具体页面(优先):theme-workbench 增加「导入本地主题目录」按钮 —— 补齐它当前只有
「服务端读
themesDir」的短板,支持本地草稿预览 / 导入。 - 备选:store 设计器(store-design)选本地资源目录预览;或全新的导入弹窗。
6. 待确认
- 具体落到哪个页面 / 场景(theme-workbench 导入主题目录 / store-design 资源预览 / 新导入弹窗)?
- 是只要「预览」还是也要「导入落盘」(导入需配套后端接口,见第 3 节)。