# 内容上传操作手册

> 这份手册讲的是：**怎么往站点里加东西**。
> 覆盖五种内容（项目 / 模块 / 蓝图 / 图集 / 文档）、图片处理、大文件规则、发布流程与排错。
>
> 站点是纯静态的，没有**在线**后台 —— 但有一个只在你本机跑的**内容编写器**
> （`admin.bat`，监听 `127.0.0.1:4322`）。图片可以直接在编写器里多选上传，
> 文件落到 `public/media/` 下，它同时会替你量好宽高。
> 除此之外的内容仍然是磁盘上的文件：「上传」= 把文件放到 `content/` 里，然后构建。

---

## 目录

1. [先开服务](#1-先开服务)
2. [通用规则（先读这几条）](#2-通用规则先读这几条)
3. [内容都放在哪](#3-内容都放在哪)
4. [项目](#4-项目)
5. [功能模块](#5-功能模块)
6. [蓝图（含 UE 里怎么复制）](#6-蓝图含-ue-里怎么复制)
7. [图集](#7-图集)
8. [文档](#8-文档)
9. [3D 模型](#9-3d-模型)
10. [图片处理流水线](#10-图片处理流水线)
11. [大文件：什么时候必须走对象存储](#11-大文件什么时候必须走对象存储)
12. [发布](#12-发布)
13. [排错对照表](#13-排错对照表)
14. [上传前检查清单](#14-上传前检查清单)

---

## 1. 先开服务

双击根目录的批处理文件即可，不用敲命令：

| 双击这个 | 干什么 | 地址 |
| --- | --- | --- |
| `admin.bat` | **写内容**（本文的主角） | <http://127.0.0.1:4322> |
| `dev.bat` | **看成品**（自动重建 + 起服务） | <http://127.0.0.1:4321> |
| `preview.bat` | 只构建一次再起服务（不自动重建） | <http://127.0.0.1:4321> |

三点注意：

- 两个窗口**都要开着**，关掉窗口服务就停了。
- `dev` / `preview` 占同一个端口（4321）**不能同时开**；`admin` 是 4322，可以和任一个并存。
- **`dev.bat` 会自动重建**：你在编辑器里保存，它 1~2 秒后重新构建一次，
  刷新浏览器就能看到新内容（窗口里会打一行 `✓ 重建完成`）。
  搜索索引也是每次重建都更新的，所以**看搜索效果也用 `dev.bat` 就行**。

> 写作时推荐组合：**`dev.bat`（4321 窗口）+ `admin.bat`（4322 窗口）**。
>
> ⚠️ **别用 `pnpm dev:hmr`**（那是 Astro 自带的开发服务器）。它对 `content/` 的改动
> **不可靠** —— 改完正文刷新，页面可能还是旧的，而且不报任何错。
> `dev.bat` 走的是"改了就重新构建"，慢 1 秒但**看到的一定是当前内容**。

---

## 2. 通用规则（先读这几条）

### 2.1 slug 只用英文小写 + 连字符

slug 是**网址的一部分**，`content/projects/tomato/` 就对应 `/projects/tomato/`。

```
✅ tomato   marathon-volt   dev-doc   bp-farmer
❌ 番茄     Marathon_Volt   文档1     中文名
```

一旦发布**不要改 slug** —— 它就是这篇内容的身份，改了等于换了个人，外链和搜索记录全断。

例外：蓝图图表的**文件名**可以是 UE 那种大小写下划线风格
（`BP_Farmer_Look`、`Blueprint_WallSconce_UserConstructionScript`），因为它不进网址。

### 2.2 `order` 决定排序

数字小的排前面，同层级内比较。习惯上按 10、20、30 留间隔，方便以后往中间插。

### 2.3 `draft: true` = 存草稿

写一半的东西勾上草稿，构建时**整篇不上线**，不会在列表里出现，也不会被搜索索引。
写完了把勾去掉再构建。

### 2.4 图片必须写 `w` / `h`

`<img>` 不写宽高，图片加载完会把下面的内容顶下去（页面抖动，也就是 CLS 掉分）。
用第 10 节的脚本会自动量好给你。

### 2.5 单个文件不超过 25 MiB

这是托管平台的硬上限。超了必须走对象存储，见第 11 节。

### 2.6 编辑器里 `Ctrl/Cmd + S` 就是保存

有未保存改动时切走其它条目会拦你一下。覆盖已有文件前会自动备份到 `tmp/admin-backup/`。

---

## 3. 内容都放在哪

```
content/
  projects/<项目slug>/
    project.md                       项目概述
    modules/<模块slug>.md            功能模块
    blueprints/<图表名>.t3d          蓝图原文（UE 里复制的）
    blueprints/<图表名>.json         蓝图元数据（可选）
    code/<模块slug>/<文件名>         该模块的源码（会显示在模块页「源码」区块）
    mindmaps/<名称>.md               思维导图源（规划中，现在还不会显示）
  gallery/<图集slug>/
    album.md                         图集元数据 + 图片清单
  docs/<slug>.md                     文档
  models/<slug>.md                   3D 模型元数据
public/
  media/<图集slug>/*.webp            图片（加工后的）
  media/models/*.glb                 3D 模型本体
  files/*                            允许下载的文档副本
```

编辑器左侧就是按这六类分组的：**项目 / 模块 / 蓝图 / 图集 / 文档 / 模型**。
点右上角的「新建 XXX」建新的，点列表里的条目改已有的。

---

## 4. 项目

一个项目 = 一个目录，`project.md` 是它的门面。

### 用编辑器

1. 左侧「项目」组右上角点 **「＋ 新建 项目」**
2. slug 填英文（如 `tomato`），确定
3. 右侧表单填字段 → `Ctrl/Cmd + S` 保存

### 字段

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `title` | ✅ | 中文标题，如 `钛心番茄` |
| `summary` | | 一句话摘要，出现在卡片和搜索结果里 |
| `order` | | 排序 |
| `cover` | | 封面图路径，如 `/media/tomato/cover.webp`（**每件作品都要有封面**） |
| `workspace` | | UE 里的工作区名，如 `U_Tomato` |
| `engine` | | 默认 `Unreal Engine 5` |
| `tech` | | 技术形态，一行一个：`C++` / `蓝图` / `数据表` |
| `tags` | | 标签，复用为主，全站控制在 30 个以内 |
| `status` | | `设计中` / `进行中` / `已完成` / `搁置` |
| `updated` | | 更新日期 |
| `draft` | | 草稿 |

正文部分（`---` 下面）就是普通的 Markdown：标题、表格、代码块、引用都能用。

`kind: project` 这个字段编辑器会自动填，不用管。

---

## 5. 功能模块

模块挂在某个项目下面，对应 `/projects/<项目>/modules/<模块>/`。

### 用编辑器

1. 「模块」组点 **「＋ 新建 模块」**
2. 先**下拉选一个项目**，再填模块 slug（如 `farming`）
3. 填字段 → 保存

### 字段

和项目基本一样（`title` / `summary` / `order` / `cover` / `workspace` / `engine` / `tech` /
`tags` / `status` / `updated` / `draft`），区别是：

- **必须选所属项目**（编辑器会写进 `project:` 字段）
- 不需要 `kind`，编辑器自动填成 `module`

> 模块页会自动出现在所属项目的左侧导航里，不用手工维护目录。

### 附：把 C++ 源码放上去

模块页底部有一个「源码」区块，读的是这个目录：

```
content/projects/<项目>/code/<模块slug>/
```

例：

```
content/projects/tomato/code/farming/
  FarmingComponent.h
  FarmingComponent.cpp
  PlantData.h
```

目录里**有什么就显示什么**，全部按 C++ 高亮，不用在正文里写代码块。

几条规矩：

- **目录名必须和模块 slug 一致** —— `modules/farming.md` 对应 `code/farming/`，对不上就看不见。
- 跳过 `_` 开头的文件 —— 不想让某个文件出现就给它加个下划线前缀。
- 目录不存在**不会报错**，只是模块页不显示「源码」区块。

> 把代码直接贴在模块正文里（用围栏代码块）也行，两种方式并存。
> `code/` 目录的好处是源码能在版本管理里单独跟踪，改代码不用动 md。

### 思维导图（暂未上线）

`mindmaps/<名称>.md` 这个位置在目录结构里留好了，计划用 markmap 直接渲染 Markdown 源文件。
但**页面还没接**（属于后续里程碑）—— 现在放进去不会显示，先别用它。

---

## 6. 蓝图（含 UE 里怎么复制）

蓝图是**把 UE 里的节点图搬到网页上**，能缩放平移、还能复制回 UE 里继续用。
这是这个站最特别的一环，步骤稍微多一点。

### 6.1 先在 UE 里复制

1. 打开要存档的蓝图，进入目标图表（Event Graph / 构造脚本 / 函数 / 材质图……）
2. **框选**要存档的节点（想全要就 `Ctrl + A`）
3. `Ctrl + C` —— 这时候剪贴板里已经是 **T3D 文本**了

> 复制出来的不是图片，是一段带节点坐标和连线信息的文本。
> 想确认的话，先粘到记事本里看一眼，应该能看到 `Begin Object`、`NodePosX=`、`LinkedTo=(...)` 这类内容。

### 6.2 用编辑器粘贴

1. 「蓝图」组点 **「＋ 新建 蓝图」**
2. 下拉**选所属项目**
3. 填**图表名**（文件名，可以是 `BP_Farmer_Look` 这种大小写下划线风格）
4. 在弹出的文本框里 **`Ctrl + V`** 粘贴 T3D 原文
5. 填字段 → 保存

编辑器会生成两个文件：`<图表名>.t3d`（原文）和 `<图表名>.json`（元数据）。

### 6.3 字段

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `title` | ✅ | 显示用的中文标题 |
| `graph` | | 图表全名，如 `BP_Farmer:Look`，显示在查看器标题栏 |
| `module` | | 归属到哪个功能模块（对应 `modules/<文件名>`，可留空） |
| `graphType` | | `BLUEPRINT` / `MATERIAL` / `MATERIAL FUNCTION` / `ANIM GRAPH` —— **影响节点配色**，材质图别选成 BLUEPRINT |
| `summary` | | 这张图做了什么（很有用，别空着） |
| `order` | | 排序 |
| `height` | | 画布高度 px，默认 640 |
| `zoom` | | 初始缩放，**负值 = 放大**。留空交给库自己算 |
| `tags` / `draft` | | 标签 / 草稿 |

`source`（T3D 原文）是构建时自动注入的，**不要手写**。

### 6.4 手写格式（不用编辑器时）

```
content/projects/tomato/blueprints/BP_Farmer_Look.t3d    ← T3D 原文，直接整段贴进去
content/projects/tomato/blueprints/BP_Farmer_Look.json   ← 元数据，可选
```

`.json` 长这样：

```json
{
  "title": "农民 · 视线检测",
  "project": "tomato",
  "graph": "BP_Farmer:Look",
  "graphType": "BLUEPRINT",
  "summary": "每帧从摄像机往前打一条射线，命中可交互物就更新准星。",
  "order": 10,
  "height": 720,
  "tags": ["交互", "射线检测"]
}
```

**没有 `.json` 也能跑** —— 标题会从文件名推断（下划线换成空格）。但建议写，摘要那一栏很有价值。

---

## 7. 图集

一个图集 = 一个目录 + 一份 `album.md`，页面上是「左边图片流、右边说明面板」。

### 7.1 图片怎么进去（编写器里多选上传）

打开 <http://127.0.0.1:4322> → 左边选「图片」→ 选一个图集（或先新建）→
在「图片清单」那一栏点 **「⤒ 上传图片（可多选）」**，一次把要用的图全选上。

上传时编写器自动做四件事：

| 自动做的 | 说明 |
| --- | --- |
| 落盘 | 写进 `public/media/<图集slug>/`，**一次一张排队上传**，哪张失败只报哪张 |
| 量宽高 | 直接读文件头（PNG / JPEG / WebP / GIF / AVIF），不用你右键看属性 |
| 改名 | 中文名 / 空格会被清成 ASCII 安全名；全中文名退回 `img-<时间戳>`；撞名自动加 `-2` |
| 建条目 | 往 `images:` 里追加一条，默认「整栏、居中」 |

> 想用原始做法（先把图片批量加工成 1600px 的 webp 再入库）也行，见 7.4。

### 7.2 排版：顺序 + 占宽 + 对齐

编辑器里图片清单是**三栏**，从左到右各管一件事：

```
[① 清单（拖动排序）]  [② 页面排布预览]  [③ 这张图的表单（改文字/占宽）]
```

1. **顺序** —— 拖动清单里的 **⠿ 手柄**（或点 ▲ ▼）改行的先后。
   **清单里的行序就是页面上的顺序** —— 想让它出现在上面就往上拖。
2. **占宽** —— 点任意一张，右栏选 `layout`：

   | 值 | 效果 |
   | --- | --- |
   | `full` | 整栏，独占一行（默认） |
   | `two-thirds` | 占三分之二栏 |
   | `half` | 半栏 —— **连着 2 张**半栏图并排 |
   | `third` | 三分之一 —— **连着 3 张**并排 |
   | `quarter` | 四分之一 —— **连着 4 张**并排 |

3. **对齐** —— 比整栏窄的图，选 `align`：`center` / `left` / `right`。
   只在"一行没排满"时有区别（铺满时靠左靠右长得一样）。

**"哪几张凑一块"由"顺序 + 占宽"决定，不需要拖坐标**：

- 连着几张**同档位**的图自动排成一行，档位一变就另起一行；
- **分组小标题会打断一行** —— 中间插了一条分组标题，两侧的图不会并排
  （所以想让两张图并排，它们得在同一个分组里、并且挨着）；
- 中栏那个「页面排布预览」实时按这套规则画出来，**它长什么样，页面上就是什么样**。

手机（窄于 860px）上一律退回整栏 —— 半宽的图在手机上没法看。

### 7.3 一次上传多张之后，还要做什么

**不用**给每张图补文字。`alt` 与 `caption` 都留空时，页面会退回用图集标题当替代文字，
`figcaption` 直接不渲染。只有你**想**给某张图加一句小注时才点开它填。

批量工具（都在「图片清单」那一栏）：

| 按钮 | 干什么 |
| --- | --- |
| ＋ 手动加一条 | 手工填一条（图已经在 `public/` 里、只是没进清单时用） |
| 扫描 public/media/… | 把目录里已有的图列成条目（手建目录、或从别处拷图进来时用） |
| ⇅ 按文件名重排 | 按路径字母序重排（文件名是 `01/02/…` 时最有用） |
| ⟲ 自动补宽高 | 把清单里所有缺 `w`/`h` 的条目挨个从文件量一次 |

### 7.4 批量加工（把原图压成 1600px 的 webp）

要控制输出体积时用脚本（会生成 webp 并量好宽高、打印可直接粘贴的清单）：

```bash
node scripts/ingest-gallery.mjs <源目录> <图集slug> [文件名前缀] [最大边]
```

例：

```bash
node scripts/ingest-gallery.mjs "C:/Users/KFC50/Desktop/Marathon" marathon-volt alex-tang-volt 1600
```

- **源目录只读**，脚本绝不改动你的原图
- 输出到 `public/media/<图集slug>/`
- 跑完打印一段可直接粘贴的 `images` 清单

### 7.5 PNG 会被自动减重（构建期）

**PNG 可以直接传**（上传、页面渲染、下载都没问题）。构建时会多跑一步
`scripts/optimize-png.mjs`：把 `public/media/` 下**超过 200 KB** 的 PNG
转出一份**同目录同名 `.webp`**（带透明的走无损，不透明走有损 90），并打印一张对照表。

三点要知道：

- 它**不删原 PNG、也不改你的 `content/`**。转换是可逆的。
- 想用上小体积那份，就把该条目的 `src` 从 `.png` 改成 `.webp`（编辑器里改一下即可）。
  构建日志会把"哪些图有更小的 webp 版本"列出来。
- 转换是**幂等**的：源图没变就不会重做，所以每次构建都跑也不会变慢。

### 7.6 字段

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `title` | ✅ | 图集标题 |
| `titleEn` | | 英文名，标题下方那行小字 |
| `summary` | | 简介，显示在右侧面板顶部 |
| `cover` | | 列表卡片的封面图路径 |
| `order` | | 排序 |
| `credit` | | 来源 / 版权说明 |
| `tags` | | 标签 |
| `images` | | 图片清单（见下） |
| `updated` / `draft` | | 更新日期 / 草稿 |

> `panel`（说明面板配色）**已废弃**：右侧面板现在直接吃站点的设计 token，
> 跟其它页面一套观感，页面上也不再提供配色调节。老的 `album.md` 里如果还留着
> `panel:` 字段，构建不会报错、页面也不会读它，编辑器不再显示这个字段。

**`images`（图片清单）**：

```yaml
images:
  - src: /media/marathon-volt/xxx.webp    # 路径（相对 public/）
    w: 1600                                # 宽（上传/扫描时自动量好）
    h: 900                                 # 高
    alt: 图片内容的文字描述                 # 可留空 —— 留空则用图集标题
    caption: 图下小注                       # 可留空 —— 留空则不渲染 figcaption
    group: 携行箱                           # 分组名，同组会插一个分组小标题
    layout: half                           # full / two-thirds / half / third
    align: center                          # center / left / right
```

数组顺序 = 页面上从上到下的顺序。`w`/`h` 仍然建议填全：不填的话图一加载完会把
下面的内容顶下去（首屏抖动），这也是上传时自动量高宽的原因。

---

## 8. 文档

一篇文档 = `content/docs/<slug>.md`，slug 即网址（`foo.md` → `/docs/foo/`）。

### 用编辑器

1. 「文档」组点 **「＋ 新建 文档」**
2. 填 slug、填字段、写正文 → 保存

### 字段

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `title` | ✅ | 标题 |
| `summary` | | 摘要，列表卡片和页面描述都用它 |
| `order` | | 排序 |
| `format` | | `md` / `pdf` / `html` / `text` —— 决定用哪个查看器（默认 `pdf`） |
| `docMode` | | 只读强度，见下 |
| `file` | | 下载链接，相对 `public/`，如 `/files/foo.md` |
| `tags` / `updated` / `draft` | | 标签 / 更新日期 / 草稿 |

**`docMode` 三档**：

| 值 | 含义 |
| --- | --- |
| `public` | 在线阅读 **+ 下载按钮** |
| `readonly` | 能看**不能拿**（页面不提供任何下载入口）—— 默认 |
| `strict` | 同上，另外禁右键、禁选中 |

> 下载按钮的出现条件是 **`docMode: public` 且 `file` 填了**，两个条件缺一不可。
> 想给下载就把文件放到 `public/files/` 下（**用英文文件名**），再填 `file: /files/xxx.md`。

### 侧栏目录是自动的

页面左边的目录从**渲染结果**算出来，不用手写。
手写一份目录也行（像自带那篇开发文档那样），但不必 —— 手写锚点容易和实际标题 id 对不上。

---

## 9. 3D 模型

一个模型 = **一个内容文件** + **一个 glb 文件**：

```
content/models/<slug>.md              ← 条目：标题、摘要、封面、指向 glb
public/media/models/<文件名>.glb      ← 模型本体
```

两处的名字**互相独立**，靠 `.md` 里的 `src:` 连起来。详情页里模型能**拖拽旋转、
滚轮缩放**；右侧那些规格数字（面数、顶点数、包围盒）是**构建时直接读 glb 算出来的**，
不用你手填。

### 9.0 想改名字 / 加新模型，改哪几个地方

这是最容易问的一个问题，一次说清。**`<slug>` 决定网址**，所以改名 = 换网址。

| 你想改的 | 要动的地方 | 会不会换网址 |
| --- | --- | --- |
| **显示标题**（页面上那行大字） | `.md` 里的 `title:` | 不会 ✅ 最常改的就是这个 |
| **网址 / 文件名** | `.md` 的**文件名**（`crate-01.md`）→ 网址 `/models/crate-01/` | 会 |
| **glb 的文件名** | `public/media/models/` 里的文件 + `.md` 里的 `src:` / `cover:` | 不会 |
| **封面图** | `.md` 里的 `cover:` | 不会 |

> **只改标题不用改名**：`.md` 里写 `title: 我的新模型` 就够了，
> 文件名和网址可以一直叫 `sample-table` 之类的旧名字 —— 只是看起来有点怪。
> **改文件名（slug）才会换网址**，旧网址会 404（站点没有重定向机制）。

要改 slug 时，**四步一起做**（漏一步就出现"模型打不开"或"封面破图"）：

1. 把 `content/models/旧名.md` **复制**成 `content/models/新名.md`，再删掉旧的
   （新建文件、别原地改名 —— 项目没有版本控制，出事了只有 `tmp/admin-backup/` 能救）；
2. 把 `public/media/models/旧文件.glb` 复制成新名字（glb 的名字跟 slug 一致最省心）；
3. 打开新的 `.md`，把 `src:` 与 `cover:` 改成新路径；
4. 重新构建。首页第二屏的数据**会自动跟着变**（见下面 9.7），
   不用再手改任何"详情页文案"。

**加一个全新模型**（`crate-01` 为例）：

```bash
# 1) 把模型本体拷进去（英文名、连字符）
copy 我的模型.glb  public/media/models/crate-01.glb
# 2) 编辑器里新建条目
#    「3D 模型」组 → 「＋ 新建 3D 模型」→ slug 填 crate-01
#    然后「渲染用模型」那栏从下拉里选 /media/models/crate-01.glb
# 3) 填标题 / 摘要 / 封面 → 保存 → 构建
pnpm build
```

**加完不用再改别的地方** —— 首页卡片、列表页、档案阵列都是从 `content/models/`
一份数据长出来的（9.7 讲的就是这条）。

### 9.1 先准备文件

模型本体放到：

```
public/media/models/<英文文件名>.glb
```

- 文件名用**英文 + 连字符**（`crate-01.glb`），理由和图片一样（见 2.1）。
- 单个文件超过 **25 MiB 就必须放 R2**（见第 11 节），这时 `src` 直接写完整 URL。
- 格式优先用 **`.glb`**（二进制 glTF，模型 + 贴图打包成一个文件）。
  `.gltf` + 外挂贴图那种也能用，但要把贴图一起拷进去、路径还得对得上，容易漏 ——
  导出时选「glb」最省事。

### 9.2 用编辑器

1. 「模型」组点 **「＋ 新建 3D 模型」**
2. 填 slug（如 `crate-01`），确定
3. 右侧表单里「**渲染用模型**」那栏，从下拉里选刚放进去的 glb
   （编辑器会自动扫描 `public/media/models/`，所以**先放文件再填这一栏**）
4. 填字段 → `Ctrl/Cmd + S` 保存

> 顺序反过来也行（先建条目后放文件），但 `src` 是必填 —— 空着保存不会立刻报错，
> **重新构建时**才会指出来。

### 9.3 字段

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `title` | ✅ | 中文标题 |
| `titleEn` | | 英文名，卡片标题下面那行小字 |
| `src` | ✅ | **渲染用的模型文件**：`/media/models/xxx.glb`，或 R2 的完整 URL |
| `srcNote` | | 这个文件做了什么处理，如「面数减半、贴图 2K WebP」 |
| `original` | | **原始版文件**（可选）。填了下载区会多出一行「原始版」 |
| `originalNote` | | 原始版说明 |
| `summary` | | 一句话摘要，卡片和搜索结果用它 |
| `cover` | | 封面图。**留空也能用** —— 列表卡片会用内置占位（深色渐变 + 线框立方体 + 面数） |
| `viewer` | | 外观参数，见 9.4 |
| `order` / `tags` / `updated` / `draft` | | 排序 / 标签 / 更新日期 / 草稿 |

**为什么没有 `stats`（规格）字段**：面数、顶点数、材质数、贴图数、包围盒、文件体积
全部是**构建时读 glb 现算的**，和文件永远一致，手填一份反而会写错、会过期。
（schema 里留了个 `stats` 覆盖位，只在模型放 R2、构建期读不到本地文件时才用得上，日常别碰。）

### 9.4 `viewer`（外观参数）

**全部留空就用默认值，不影响渲染。** 想微调时：

| 子字段 | 默认 | 说明 |
| --- | --- | --- |
| `autoRotate` | `false` | 自动旋转。一页里几个模型同时转会互相抢注意力 |
| `orbit` | `sphere` | `sphere` = 上下左右都能转；`turntable` = 只能绕圈，不会翻到脚底 |
| `zoom` | `true` | 允许滚轮缩放。关掉可以避免「鼠标停在模型上、页面滚不动」 |
| `theta` | `0` | 初始水平角（度）。`0` = 正对相机 |
| `phi` | | 初始俯仰角（度）。`90` = 平视；小一点 = 从上往下看 |
| `radius` | | 初始距离。一般留空，由库按模型尺寸自动取 |
| `exposure` | `1` | 整体偏暗 / 偏亮时微调 |
| `shadow` | | 阴影强度。`0` = 关掉地面阴影 |
| `environment` | | 环境贴图路径（HDR / EXR 的 public 路径）。留空用内置的中性棚拍光 |
| `transparent` | `true` | 透明背景，模型直接坐在站点底色上 |

### 9.5 页面上是什么样

- **列表页 `/models`** —— 封面卡网格。**这一页不加载 3D 库**，所以模型再多也不会拖慢列表。
- **详情页 `/models/<slug>/`** —— 模型区**滚进视口才开始加载** 3D 库，加载时先显示一个转圈占位。
  左下角工具条有三个按钮：**重置视角 / 自动旋转 / 全屏**。
- **规格表** —— 面数、顶点数、材质、贴图、包围盒、文件体积；空的行不显示。
- **下载区** —— 渲染版必有一行；`original` 填了才会多出「原始版」那一行。

手机上用手指转模型时，**页面纵向滚动优先**，不会出现「想滚页面结果在转模型」的尴尬。

> 3D 库是**自托管**的（放在 `public/vendor/three-wire/`），不走任何 CDN。
> 首次打开详情页会有约 0.9 MB 的下载，之后按 7 天缓存。这是它唯一"重"的地方。

### 9.5.1 视口画的是「形体 + 布线」

详情页的视口画**两层**：中性黏土的面 + glb 里**真实的三角网特征边**。
页面上**没有**「线框 / 实体」切换按钮 —— 两层同时画着，没有可切的。

为什么不做切换：DCC 里的"线框视图"是显示模式，**不会导出进 glb**，文件里只有三角面；
而作者要在这里展示的是"布线怎么撑起形体"，面与线缺一不可。
顺带三个好处：文件小、放大不糊、低模高模都能看清结构。

**但线不能单独存在**：只画线不画面，模型就只剩一团线条、完全看不出形状
（用户 2026-09-20 原话："线框之间的面还是需要保留的，我只是想展示布线，
不是为了展示线框，这样完全无法看清模型的形状了"）。所以视口画的是**两层**：

| 层 | 是什么 | 怎么来的 |
| --- | --- | --- |
| 面 | 中性黏土材质，只吃光照 | 直接复用 glb 的几何，**不加载**原始材质/贴图 |
| 线 | glb 里**真实的三角网边** | `EdgesGeometry`，只取夹角超过 22° 的**特征边** |

> 为什么不把三角剖分线全画出来：高模上那是几万条线，密到糊成一块剪影，
> 反而看不见形体。特征边阈值只保留"结构上的折线"，这才是布线该有的读法。
> 想看更密或更疏：改 `public/vendor/three-wire/wireframe-runner.js` 里的
> `edgeThreshold`（默认 22 度，调小 = 线更多）。

### 9.6 手写格式（不用编辑器时）

```
content/models/crate-01.md
public/media/models/crate-01.glb
```

`crate-01.md` 长这样 —— 现成可读的例子是 `content/models/chibi-model.md`：

```yaml
---
title: 货箱
titleEn: Supply Crate
summary: 一个低面数货箱，用来测贴图和金属度的表现。
order: 10
cover: /media/models/crate-01.webp
src: /media/models/crate-01.glb
srcNote: Draco 压缩后 1.2 MB，贴图 2K WebP
tags: [道具, 硬表面]
viewer:
  autoRotate: true
  orbit: turntable
  phi: 75
updated: 2026-09-16
---

正文就是普通 Markdown —— 建模过程、参考图、遇到的坑，都能写。
```

### 9.7 首页那一屏会自动跟着变（不用手改）

首页第二屏（滚轴选页 + 三维档案阵列）里的卡片、右侧说明面板、点进去的详情，
**全部来自 `content/`** —— 你改完内容重新构建，它就跟着变了。

具体是这样接起来的：

| 环节 | 谁产出 | 什么时候 |
| --- | --- | --- |
| `content/` | **你写的**（唯一真源） | 随时 |
| 档案数据 | `spike/three-archive/gen-archives.mjs` 从 `content/` 收一遍 | **每次构建时自动跑** |
| 首页右栏 / 卡片 / 无 WebGL 回退 | 读那份档案数据 | 页面加载时 |
| 三维层（1.2 MB 的包，数据是烘进包里的） | 上一步数据变了才重出 | **同上，自动** |

所以：**加了模型 / 删了图集 / 改了标题 → 跑一次 `pnpm build` 就够**，
不需要去别的地方"同步写一遍"。构建日志里会看到这一行：

```
档案同步：首页数据已是最新（6 条），无需重出
```

如果它说的是「内容变了，重出首页三维包」，就说明这次真的重新生成了一遍
（数据一样时不会重出，所以日常构建不会因此变慢）。

> 以前这一步是**手动**的，而且漏了会很难查：内容删了首页还在讲旧的，
> 或者两份数据下标错位 —— 点卡片显示别的项目、详情跳错页。
> 现在它是构建流水线里的一步，并且会自检"两份数据是不是同一代"。

---

## 10. 图片处理流水线

图片是唯一需要"加工"一步的内容，单独说清楚。**能自动的都自动了**，
下面从"最省事"到"最可控"排三种做法。

### 最省事：在编写器里多选上传（推荐）

`admin.bat` → 「图片」→ 选中图集 → 「⤒ 上传图片（可多选）」。
落盘、量宽高、清洗文件名、追加条目全部自动，见 7.1。

- **超过 25 MiB 的图会被拒**（Cloudflare Pages 单文件上限），报错里会写清是哪一张。
- 上传的是原图尺寸。想要小体积，见下面的减重与批量两条。

### 自动减重：构建期给大 PNG 补一份 webp

构建流水线里的一步，**不用你手动跑**（也可以单独跑 `pnpm images:optimize`）：

| 命令 | 干什么 |
| --- | --- |
| `pnpm images:optimize` | 超过 200 KB 的 PNG → 同目录同名 `.webp`（幂等，源图没变就跳过） |
| `pnpm images:optimize --force` | 不管有没有，全部重转 |
| `pnpm images:optimize --min=120` | 阈值改成 120 KB |
| `pnpm images:optimize --dry` | 只报告，不写文件 |

它**不改 `content/`**：转换结果只是多出一个同名 `.webp` 文件，想用就自己把
`src` 改成 `.webp`（PNG 原图可以留着供下载）。构建日志会列出所有"已经有更小
webp 版本"的图。

### 批量（图集用这个）

```bash
node scripts/ingest-gallery.mjs <源目录> <图集slug> [文件名前缀] [最大边]
```

| 参数 | 说明 |
| --- | --- |
| 源目录 | 原图所在目录，**只读，不会被改动** |
| 图集slug | 输出到 `public/media/<slug>/`，和 `content/gallery/<slug>/` 对应 |
| 文件名前缀 | 只挑文件名以它开头的图（留空 = 全部） |
| 最大边 | 长边缩到多少 px，默认 1600 |

跑完会打印 JSON 片段，直接贴进 `album.md` 的 `images:` 里。

### 单张（封面图之类）

手工放到 `public/media/<图集>/` 也行，但**必须自己确认宽高并写进引用它的地方**。
编辑器里「封面图」那栏填的是路径（如 `/media/tomato/cover.webp`），
项目卡片的封面裁切由页面处理，不用你裁。

### 三条规矩

1. **`w` / `h` 一定要写** —— 不写就让页面抖（CLS）。
   上传与扫描会自动量；清单里剩下的空值可以用「⟲ 自动补宽高」一键补。
2. **文件名用英文 + 连字符** —— 中文名进 URL 会变成一长串 `%E4%B8%AD%E6%96%87`，难看且容易出错。
   （上传时会自动清成 ASCII 安全名，但自己命名时也照这条来。）
3. **原图留在源目录，别往仓库里塞** —— 加工后的 webp 才进 `public/`。

---

## 11. 大文件：什么时候必须走对象存储

**单个文件超过 25 MiB 就必须放对象存储（R2），不能进前端仓库** —— 这是托管平台的硬上限，
也是整套架构的分界线。

现实情况（**重要**）：

- 目前**对象存储的流水线还没接**（属于 M4）。所以在接上之前，**大于 25 MiB 的文件暂时放不进来**。
- 现在能放的：图片（加工后）、中小体积的 PDF、文本类文档、T3D 蓝图文本（通常几十 KB～几 MB）。
- 视频、大模型文件、大音频要等 M4 完成，或者你先把文件放别处、页面里写外链。

遇到大文件时**别硬塞** —— 构建会失败，而且就算塞进去了，访客下载也会烧掉带宽。

---

## 12. 发布

内容写完，看一遍，再发布。

### 本地看

双击 `preview.bat`（它会先构建再起服务），打开 <http://127.0.0.1:4321>。
写内容的过程中用 `dev.bat` 更顺手。

### 用编辑器改完，预览里为什么没变？

这是踩得最多的一脚，先说清楚：

**编辑器保存的只是磁盘上的源文件；预览服务读的是 `dist/`（构建产物）。**
两者之间隔着一道「构建」。所以改完保存后：

- 预览页还是旧的 —— 正常，不是保存失败；
- **刷新也没用** —— 因为 `dist/` 里那份文件从头到尾没被改过，刷新多少次读到的都是同一份旧产物。

正确的看法是这三步：

1. 编辑器右上角会出现黄色提示 **「有改动未构建」**（看到它 = 保存成功了，但站点还没更新）；
2. 点它旁边的 **「重新构建」**，等 8~15 秒（按钮转圈时别重复点，服务端同一时刻只跑一个构建）；
3. 提示消失后，再去点「预览这一页」，这时才是你改完的版本。

> 想让保存即时生效（不用点构建），就用 `dev.bat` 起开发服务器——它边写边热更新。
> 代价是 **dev 模式下搜索是哑的**（Pagefind 索引只在构建期生成），
> 而且要另开一个端口。日常改排版用 dev，验收成品用 preview。

### 上线的流程

```bash
pnpm build      # 构建到 dist/（含 Pagefind 搜索索引）
pnpm run deploy # 推到 Cloudflare Pages（⚠️ 必须带 run，裸的 pnpm deploy 是 pnpm 内置命令）
```

或者双击 `deploy.bat`（两步连做）。

> **首次部署需要先做一次授权**：`npx wrangler login`（会弹浏览器）。
> 这个只做一次，之后 `deploy.bat` 就能直接用。

### 构建失败怎么办

构建是**校验关卡**，字段写错会直接失败并指出是哪个文件哪一行 —— 这是好事，
总比上线后页面空白强。根因几乎都是下面几条。

---

## 13. 排错对照表

| 现象 | 原因 | 怎么修 |
| --- | --- | --- |
| 构建报 `title` 相关错误 | 必填字段没填 | 补上 `title` |
| 构建报枚举值不匹配 | `status` / `graphType` / `docMode` / `format` 写成了别的值 | 对照第 4、6、8 节的取值表改 |
| 新建条目被拒 | slug 不是英文小写+连字符 | 改名，或用编辑器建（它会校验） |
| 内容保存了但页面没变 | `preview` 是静态服务，读的是 `dist/`；源文件改了但没重新构建 | 编辑器点「重新构建」，或重新 `pnpm build`；想边写边看用 `dev.bat` |
| 改完保存、刷新也还是旧版 | 同上 —— 刷新读的还是那份没变的 `dist/`，不是缓存问题 | 别刷了，去点「重新构建」 |
| 搜索搜不到新内容 | 同上，索引是构建期产物 | 重新构建后才有 |
| 图片裂开 | 路径写错，或图不在 `public/` 下 | 路径要以 `/media/...` 开头，且文件真实存在 |
| 页面加载时跳动 | 图片没写 `w` / `h` | 用第 10 节的脚本重新生成清单 |
| 蓝图页空白 / 提示没有蓝图 | `.t3d` 没放对位置 | 必须在 `content/projects/<项目>/blueprints/` 下 |
| 蓝图节点位置乱 | 粘贴时 T3D 被截断 | 重新完整复制粘贴 |
| 模型页一直转圈 / 显示不出来 | `src` 指向的 glb 不存在，或路径没以 `/media/...` 开头 | 确认文件真在 `public/media/models/` 下、文件名大小写一致 |
| 构建报「找不到模型文件」 | `src` 填了本地路径但文件不在 | 要么把文件放上去，要么把 `src` 改成 R2 完整 URL |
| 手机上看模型翻不了 / 页面滚不动 | 手指都用在转模型了 | 这是刻意设计（纵向滚动优先）；想翻到脚底把 `viewer.orbit` 设为 `sphere` |
| 模型太暗 / 发白 | 环境光与模型材质不搭 | 调 `viewer.exposure`（默认 1，往大调更亮） |
| 详情页第一次打开有点慢 | 3D 库约 1 MB，只在这一页加载 | 正常，之后按 7 天缓存；列表页不受影响 |
| 构建报找不到模块（`Cannot find module`） | 依赖装了一半 | 跑 `pnpm check:deps` 看，再 `pnpm install --force` |
| 未知栏目 / 404 | slug 和目录名对不上 | 检查 `content/` 下的目录名 |

### 一个万能自检

```bash
pnpm check:deps     # 依赖是否完整
pnpm build          # 内容是否合法（会逐个文件报错）
```

---

## 14. 上传前检查清单

每加一件东西，过一遍这个：

- [ ] slug 是**英文小写 + 连字符**的，且**没有**改过已发布内容的 slug
- [ ] `title` 填了
- [ ] `summary` 填了（它会进搜索结果，别浪费）
- [ ] `order` 给了个合适的值
- [ ] 有封面（项目 / 模块 / 图集；**模型篇可以没有**，会自动用内置占位）
- [ ] 图片写了 `w` / `h`，文件名是英文
- [ ] 模型篇：`src` 指向的 glb **真的在** `public/media/models/` 下、文件名大小写也对
- [ ] 单文件没超过 **25 MiB**（模型同理）
- [ ] 标签是**复用**已有的，没在造第 31 个
- [ ] 写完了就把 `draft` 取消掉
- [ ] `pnpm build` 过了
- [ ] 在 `preview` 里**实际点开看过**（别只看构建成功）

---

## 附：速查

```bash
# 服务
admin.bat                    # 写内容  → 127.0.0.1:4322
dev.bat                      # 边写边看 → 127.0.0.1:4321（无搜索）
preview.bat                  # 看成品  → 127.0.0.1:4321（有搜索）

# 命令
node scripts/ingest-gallery.mjs <源目录> <图集slug> [前缀] [最大边]   # 图片批处理
pnpm docs:sync               # 开发文档 → content/docs/
pnpm check:deps              # 依赖体检
pnpm build                   # 构建（含内容校验）
pnpm run deploy              # 部署（⚠️ 必须带 run）
```
