feat(polaris): 实现静态分类页

新增七个预生成分类路由、工具卡片网格与空状态。启用侧栏分类导航和当前分类选中态,修复 Windows 静态导出 RSC 路径并补充产物验证,保持静态导出与安全外链约束。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 19:24:57 +08:00
co-authored by Claude Opus 5
parent e9f3bf6934
commit 4a032ea742
24 changed files with 2332 additions and 46 deletions
+29
View File
@@ -0,0 +1,29 @@
# Next.js Windows 静态导出补丁
## 原因与范围
`next@16.3.3` 的静态导出器将 `path.relative()` 产生的文件系统路径直接用于 RSC segment 文件名编码。Windows 的反斜杠不会被编码器转换为点号,导致浏览器请求扁平文件名、实际文件却落在嵌套目录。
- 上游问题:<https://github.com/vercel/next.js/issues/85374>
- Windows 根因复现:<https://github.com/vercel/next.js/issues/92339>
- 2026-09-13 核对:`16.3.5` 的相应导出逻辑仍未包含路径标准化。
补丁只修改导出器的 CommonJS / ESM 两份入口,在文件名编码前用系统路径分隔符拆分并以 `/` 连接。读取源文件仍使用原始文件系统路径。Linux/macOS 的 `/` 路径保持不变,浏览器运行时代码不变。
## 安装与验证
补丁通过 `pnpm-workspace.yaml` 的精确版本 `patchedDependencies` 和锁文件登记,正常安装会自动应用,无需手动编辑 `node_modules`。
```bash
corepack pnpm install --frozen-lockfile
corepack pnpm build
corepack pnpm verify:export
```
`verify:export` 只读取产物:检查首页、全部分类页和 404,并将每个源 RSC segment 与浏览器所需扁平路径下的导出文件逐字节比较。它不会复制、移动或重命名 `out/` 文件。部署前还需在真实静态服务器上验证预取和客户端导航。
## 后续升级
补丁仅绑定 `next@16.3.3`,不会自动迁移到其他版本。升级 Next.js 时必须重新检查上游导出实现、运行构建与 `verify:export`,再进行静态浏览器验收;不能仅以 `next build` 成功判断问题已修复。
上游修复并验收通过后,使用 pnpm 的补丁移除命令清理对应登记和补丁文件,更新锁文件,保留产物回归检查。
@@ -0,0 +1,28 @@
diff --git a/dist/esm/export/index.js b/dist/esm/export/index.js
index 6c35f4f47f704caea869a4f89721e0fc71f7f458..4c4e25fec3d1aecb439cea4994f8747ea68df0fe 100644
--- a/dist/esm/export/index.js
+++ b/dist/esm/export/index.js
@@ -717,7 +717,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = join(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -RSC_SEGMENT_SUFFIX.length);
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
+ const segmentPath = '/' + segmentFileSrc.split(sep).join('/').slice(0, -RSC_SEGMENT_SUFFIX.length);
const segmentFilename = convertSegmentPathToStaticExportFilename(segmentPath);
const segmentFileDest = join(segmentsDirDest, segmentFilename);
await fs.mkdir(dirname(segmentFileDest), {
diff --git a/dist/export/index.js b/dist/export/index.js
index a19941bec99b5c5673834872eaf8a95deb584a34..b7492b7ba76823380b0013bd1c08527388c82ed7 100644
--- a/dist/export/index.js
+++ b/dist/export/index.js
@@ -785,7 +785,8 @@ async function exportAppImpl(dir, options, span, staticWorker) {
const segmentsDirDest = (0, _path.join)(outDir, unnormalizedRoute);
const segmentPaths = await collectSegmentPaths(segmentsDir);
await Promise.all(segmentPaths.map(async (segmentFileSrc)=>{
- const segmentPath = '/' + segmentFileSrc.slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
+ // Windows 文件系统路径先转为 URL 路径,避免 RSC 文件被导出为嵌套目录。
+ const segmentPath = '/' + segmentFileSrc.split(_path.sep).join('/').slice(0, -_constants.RSC_SEGMENT_SUFFIX.length);
const segmentFilename = (0, _segmentvalueencoding.convertSegmentPathToStaticExportFilename)(segmentPath);
const segmentFileDest = (0, _path.join)(segmentsDirDest, segmentFilename);
await _fs.promises.mkdir((0, _path.dirname)(segmentFileDest), {