Ver Fonte

feat(deploy): 环境切换机制重构 + 生产部署链路 + 发布手册

一、环境切换(后端地址收口到单一真源)
- 新增 environment.base.ts:parseServer / functionsBase / parseAppId /
  matrixHs / matrixServerName 唯一真源,环境文件继承覆盖
- parse-api.ts 3 处、matrix-client.service.ts 2 处硬编码改读 environment
- environment.production.ts 的 useMockData 由 true 改为 false
  (线上此前实际跑的是演示数据)
- 新增 environment.mock.ts(冒烟/UI 走查)与 environment.local.example.ts
  (本地联调,.local.ts 不入库)

二、构建配置
- production 补 optimization: true / sourceMap: false(此前缺失,产出未优化)
- 新增 mock / mock-production / local 三套构建与对应 serve 配置
- package.json 增 start:local / start:mock / build:mock / deploy

三、部署脚本 scripts/deploy.mjs(替代已删的 deploy.ps1)
- 项目名与产物路径修正为 fmode-app-v2(原为 nova-aigc)
- 产物自检:拦截演示包、空产物、缺 index.html
- 凭据改由 obsutil / hcloud 自身配置提供,脚本内无明文

四、顺带修复
- ui-button 加载态内容被整块替换后无可访问名 → 无 ariaLabel 时兜底「加载中」
- smoke-imhub 左栏断言改查外壳 app-session-tree(10-30 左栏已提升)
- smoke-imhub 等待加载态退场;可访问名判定补 aria-labelledby / title

五、文档
- 新增 docs/05-operations/部署发布手册.md(安装→发布→排障→踩坑)
- README 重写为项目实际内容;docs 索引与操作追踪同步

验证:tsc 0 错误;单测 1089/1089;冒烟 28/28;verify:ui 12/12;
verify:docs 80 文件通过;四套构建配置均通过;已实际发布至 ai.fmode.cn

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct há 7 horas atrás
pai
commit
9352ae131a

+ 1 - 0
.gitignore

@@ -57,6 +57,7 @@ AGENTS.md
 
 # 本地环境与凭据(黄金规则 9:任何密钥/令牌不入库)
 src/environments/*.local.ts
+!.example.ts
 .env
 .env.*
 !.env.example

+ 58 - 32
README.md

@@ -1,59 +1,85 @@
-# FmodeAppV2
+# Fmode App v2 · 数字生命客户端
 
-This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.36.
+Fmode 双端 IM 中枢:桌面横版工作台(`/desktop/**`)+ 手机竖版(`/mobile/**`)。
+Angular 20 standalone SPA,无自建后端 —— 数据全部经 fmode 平台云函数 + Matrix。
 
-## Development server
+## 环境与命令
 
-To start a local development server, run:
+| 命令 | 构建配置 | 数据源 | 用途 |
+|---|---|---|---|
+| `npm start` | development | 真实后端 | 日常开发(http://localhost:4200) |
+| `npm run start:local` | local | 真实后端(`environment.local.ts`) | 指向测试/预发环境联调 |
+| `npm run start:mock` | mock | 演示数据 | 无后端时走 UI / 看设计 |
+| `npm run build` | production | 真实后端 | **部署用** |
+| `npm run build:mock` | mock-production | 演示数据 | 冒烟测试、演示包 |
+| `npm test` | — | — | 单测(Karma + Jasmine) |
+| `npm run smoke` | — | — | 双端全路由冒烟(需先 `npm run build:mock`) |
+| `npm run verify:ui` | — | — | UI 规范断言 |
+| `npm run deploy` | production | 真实后端 | 构建 + 上传 OBS + 刷新 CDN |
 
-```bash
-ng serve
-```
+### 环境切换
 
-Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
+后端地址**只有一个真源**:`src/environments/environment.base.ts`。
+各环境文件继承它,只覆盖自己需要的字段。
 
-## Code scaffolding
+- **本地指向测试环境**:复制 `environment.local.example.ts` → `environment.local.ts` 改地址,跑 `npm run start:local`。
+  该文件不入库(`.gitignore`)。
+- **不要**为了切环境去改 `environment.base.ts`,否则所有人一起变。
 
-Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
+数据源开关 `useMockData` 是唯一的数据层分支:`true` = MockApi,`false` = ParseApi。
+`npm start` / `npm run build` 恒为 `false`;演示态只能通过 `mock` 系列配置进入。
 
-```bash
-ng generate component component-name
-```
+## 部署
 
-For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
+前置条件(只需一次):
 
 ```bash
-ng generate --help
+# obsutil:https://support.huaweicloud.com/utiltg-obs/obs_11_0003.html
+# hcloud :https://support.huaweicloud.com/utiltg-hcloudcli/hcloudcli_01_0002.html
+obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com
+hcloud configure set --cli-profile=default --cli-access-key=<AK> --cli-secret-key=<SK>
 ```
 
-## Building
-
-To build the project run:
+凭据只存本机 CLI 配置,**不进仓库、不进脚本**。
 
 ```bash
-ng build
+npm run deploy                 # 完整流程
+node scripts/deploy.mjs --skip-build   # 复用已有 dist
+node scripts/deploy.mjs --dry-run      # 只打印动作
 ```
 
-This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
+流程:构建 → 产物自检(拦截演示包/空产物)→ sync 到 OBS → 设 public-read → 刷新 CDN → 探活。
 
-## Running unit tests
+> ⚠️ OBS 静态托管的 SPA 深链(直接访问 `/desktop/chat`)需要在桶的**静态网站配置**
+> 里把错误文档设为 `index.html`;否则刷新深链会 404。这一项在华为云控制台配置,代码侧无法替代。
 
-To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
+## 目录
 
-```bash
-ng test
+```
+src/app/
+  core/         共享服务(数据访问、Matrix、会话、路由守卫…)
+  components/   业务外壳组件(会话树、消息气泡、预览…)
+  ui/           ui-kit 官方组件底座(整目录接入,禁改业务无关代码)
+  desktop/      桌面端 shell + pages
+  mobile/       移动端 shell + pages
+  pages/        双端共用页面(入口、登录)
+src/environments/   环境配置(base 为地址真源)
+scripts/            构建/校验/冒烟/部署脚本
 ```
 
-## Running end-to-end tests
+## 已知限制
 
-For end-to-end (e2e) testing, run:
+- 主包 gzip 约 146 kB,超出 `production` 的 500 kB(原始体积)警告线,属已知、不阻塞。
+- `fetchTasks` / `fetchTaskDetail` 云函数接收端未实现,前端显式抛错。
+- `tasks[]` 多任务字段在存量系统中从未落库。
 
-```bash
-ng e2e
-```
+### 本机 CLI 安装状态(2026-11-02)
 
-Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
+已安装并配置(仅本机,不入库):
 
-## Additional Resources
+| 工具 | 路径 | 配置 |
+|---|---|---|
+| obsutil 5.8.9 | `%USERPROFILE%\tools\obsutil\obsutil_windows_amd64_5.8.9` | `%USERPROFILE%\.obsutilconfig` |
+| hcloud (KooCLI) | `%USERPROFILE%\tools\hcloud` | profile `default`,含 `cli-domain-id` |
 
-For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+两者均已加入用户 `PATH`,新开终端直接可用。

+ 56 - 0
angular.json

@@ -55,7 +55,9 @@
                   "maximumError": "24kB"
                 }
               ],
+              "optimization": true,
               "outputHashing": "all",
+              "sourceMap": false,
               "fileReplacements": [
                 {
                   "replace": "src/environments/environment.ts",
@@ -63,6 +65,51 @@
                 }
               ]
             },
+            "mock": {
+              "optimization": false,
+              "extractLicenses": false,
+              "sourceMap": true,
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.mock.ts"
+                }
+              ]
+            },
+            "mock-production": {
+              "budgets": [
+                {
+                  "type": "initial",
+                  "maximumWarning": "2MB",
+                  "maximumError": "4MB"
+                },
+                {
+                  "type": "anyComponentStyle",
+                  "maximumWarning": "8kB",
+                  "maximumError": "24kB"
+                }
+              ],
+              "optimization": true,
+              "outputHashing": "all",
+              "sourceMap": false,
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.mock.ts"
+                }
+              ]
+            },
+            "local": {
+              "optimization": false,
+              "extractLicenses": false,
+              "sourceMap": true,
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.local.ts"
+                }
+              ]
+            },
             "development": {
               "optimization": false,
               "extractLicenses": false,
@@ -79,6 +126,15 @@
             },
             "development": {
               "buildTarget": "fmode-app-v2:build:development"
+            },
+            "mock": {
+              "buildTarget": "fmode-app-v2:build:mock"
+            },
+            "local": {
+              "buildTarget": "fmode-app-v2:build:local"
+            },
+            "mock": {
+              "buildTarget": "fmode-app-v2:build:mock"
             }
           },
           "defaultConfiguration": "development"

+ 57 - 0
docs/00-tracking/操作追踪.md

@@ -3831,10 +3831,12 @@ CSS 模板字符串的注释里用反引号包标识符(如 `` `opacity: 0` ``
 
 ### 一、开关配色改为「随主题反转」
 用户明确规则:
+
 | 主题 | 轨道 | 圆点 |
 |---|---|---|
 | 浅色 | **深色** #3a4049 | **亮色** #fff |
 | 深色 | **亮色** #9aa3ad | **深色** #1e2128 |
+
 开启态统一为品牌蓝 + 白点。
 
 **之前几版都没生效的真原因**:
@@ -3868,3 +3870,58 @@ CSS 模板字符串的注释里用反引号包标识符(如 `` `opacity: 0` ``
 - 关闭态圆点**永远白色**(否则浅灰轨道 + 深灰圆点会和开启态太像)
 
 已用 Chrome 渲染核对「铂金深色 / 其它配色深色 / 浅色」三组共 6 种组合。
+
+---
+
+## 2026-11-02
+
+### 环境切换机制重构 + 生产部署链路
+
+**原因**:`npm run build` 走 `production` 配置但缺 `optimization`;`environment.production.ts` 的
+`useMockData` 竟为 `true`(线上跑演示数据);后端地址 4 处硬编码在 `parse-api.ts` /
+`matrix-client.service.ts` 中,无法切测试环境;`deploy.ps1` 的项目名(`nova-aigc`)、
+产物路径均为另一项目的值,且内联明文 OBS/HCloud 凭据。
+
+**改动**
+| 文件 | 内容 |
+|---|---|
+| `src/environments/environment.base.ts` | **新增**。后端端点唯一真源(parseServer / functionsBase / parseAppId / matrixHs / matrixServerName) |
+| `src/environments/environment.ts` | 继承 base,`useMockData: false` |
+| `src/environments/environment.production.ts` | **`useMockData` 由 true 改为 false**;继承 base |
+| `src/environments/environment.mock.ts` | **新增**。演示数据配置(冒烟/UI 走查用) |
+| `src/environments/environment.local.example.ts` | **新增**。本地联调模板(`.local.ts` 不入库) |
+| `src/app/core/api/parse-api.ts` | `FUNCTION_API` / `PARSE_BASE` / `PARSE_APP_ID` 改读 `environment.endpoints` |
+| `src/app/core/matrix-client.service.ts` | `MATRIX_HS` / `MATRIX_SERVER_NAME` 改读 `environment.endpoints` |
+| `angular.json` | production 补 `optimization: true` / `sourceMap: false`;新增 `mock`、`mock-production`、`local` 三套构建配置与对应 serve 配置 |
+| `package.json` | 新增 `start:local` / `start:mock` / `build:mock` / `deploy`;`build` 显式 `--configuration production` |
+| `scripts/deploy.mjs` | **新增**,替代 `deploy.ps1`(已删)。项目名修正为 `fmode-app-v2`,产物自检拦截演示包,凭据改由 CLI 自身配置提供 |
+| `tsconfig.app.json` | 排除 `.example.ts` |
+| `README.md` | 重写:环境矩阵、切换方法、部署前置与流程 |
+
+**顺带修复(冒烟脚本与底座)**
+- `scripts/smoke-imhub.mjs`:左栏断言从页面 `.list-col` 改为外壳 `app-session-tree`(10-30 左栏已提升到 Shell);`go()` 增加「等加载态退场」;可访问名判定补 `aria-labelledby` / `title`
+- `src/app/ui/components/button/button.html`:加载态内容被整块替换后按钮无可访问名 → 无 `ariaLabel` 时兜底 `加载中`
+
+**验证**:tsc app/spec 均 0 错误;单测 1089/1089;`build` / `build:mock` / `local` / `mock-production` 四配置均构建通过;冒烟 28/28;verify:ui 12/12;`deploy --dry-run` 自检通过,演示包与空产物均被拦截(退出码 1)。
+
+**未完成**:本机未安装 `obsutil` / `hcloud`,**实际发布未执行**。
+
+### 首次线上发布(2026-11-02)
+
+**执行**:`node scripts/deploy.mjs --skip-build`
+- 备份原 `index.html` → `obs://www-ai.fmode.cn/_backup/nova-aigc-20261102-102102/index.html`
+- 上传 82 文件(6.85MB),0 失败;桶内 3273 对象设 public-read
+- CDN 刷新任务 `8049054938`
+- 探活:`/` 200、`/index.html` 200、标题匹配、深链回落 index.html
+
+**发布过程中修掉的脚本缺陷**
+1. `obsutil sync` 无 `-u` 参数 → 去掉
+2. KooCLI 首次运行交互式询问隐私声明 → 传 stdin `y`
+3. Windows 上 `curl -o /dev/null` 会被当文件写盘 → 探活改用 Node 原生 `fetch`
+4. KooCLI 用 AK/SK 调全局服务需 `cli-domain-id` → 从桶 ACL owner 取到并配置
+5. SPA 深链探活误判 → OBS ErrorDocument 回落时状态码仍为 404,改为判断响应体是否含本次 `<title>`
+
+**环境配置**:OBS 桶 `www-ai.fmode.cn` 静态网站托管设置为 IndexDocument/ErrorDocument = `index.html`
+(此前未配置,深链直接访问 404)。
+
+**未处理**:桶内 1000+ 个 nova-aigc 旧产物(2025-08)保留未删,仅 index.html 被覆盖。

+ 229 - 0
docs/05-operations/部署发布手册.md

@@ -0,0 +1,229 @@
+# 部署与发布手册
+
+| 字段 | 值 |
+|---|---|
+| 版本 | v1.0 |
+| 创建日期 | 2026-11-02 |
+| 适用对象 | 需要把 fmode-app-v2 发布到 `https://ai.fmode.cn/` 的开发者 |
+| 对应脚本 | `scripts/deploy.mjs`(`npm run deploy`) |
+
+> 本文档面向**首次操作者**:从零安装工具 → 首次发布 → 出问题怎么查,按顺序执行即可。
+> 已熟手的读者可直接跳到「三、日常发布」。
+
+---
+
+## 一、发布链路一览
+
+```
+本地源码
+  │  npm run build            ← production 配置,useMockData=false
+  ▼
+dist/fmode-app-v2/browser/    ← 静态产物(82 文件,约 6.9 MB)
+  │  obsutil sync            ← 上传覆盖
+  ▼
+obs://www-ai.fmode.cn         ← 华为云 OBS 桶(public-read)
+  │  CDN 回源
+  ▼
+https://ai.fmode.cn/          ← 对外域名(华为云 CDN,需刷新缓存)
+```
+
+**四个必须理解的点**:
+
+1. **构建配置决定数据源**。`npm run build` 是 production(真实云函数),`npm run build:mock` 是演示数据。
+   上线只能发 production 包 —— 部署脚本会自检并拒收演示包。
+2. **OBS 桶里有别人的东西**。桶内除本项目产物外,还有 2025-08 遗留的 `nova-aigc` 旧构建产物和
+   `assets/` 资源。所以**不要**用 `obsutil rm` 清桶,也不要在 sync 时加 `-delete`。
+3. **CDN 有缓存**。上传成功后必须刷新,否则用户看到的还是旧页面。刷新有 1-5 分钟延迟。
+4. **SPA 深链靠桶的静态网站配置**。直接访问 `/desktop/chat` 时 OBS 用错误文档回落 `index.html`,
+   **状态码仍是 404 但内容是页面**——这是 OBS/S3 的标准行为,不是故障。
+
+---
+
+## 二、首次准备(每台机器一次)
+
+### 2.1 安装 CLI
+
+需要两个华为云命令行工具:`obsutil`(对象存储)与 `hcloud`(KooCLI,云 API)。
+
+```bash
+# obsutil — 对象存储工具
+mkdir -p ~/tools/obsutil && cd ~/tools/obsutil
+curl -L -o obsutil.zip https://obs-community.obs.cn-north-1.myhuaweicloud.com/obsutil/current/obsutil_windows_amd64.zip
+unzip obsutil.zip          # 解压出 obsutil_windows_amd64_x.y.z/obsutil.exe
+
+# hcloud — 华为云 API CLI
+mkdir -p ~/tools/hcloud && cd /tmp
+curl -L -o hcloud.zip https://hwcloudcli.obs.cn-north-1.myhuaweicloud.com/cli/latest/huaweicloud-cli-windows-amd64.zip
+unzip hcloud.zip -d ~/tools/hcloud   # 解压出 hcloud.exe
+```
+
+把两个目录加入用户 PATH(PowerShell):
+
+```powershell
+$p1 = Join-Path $env:USERPROFILE 'tools\obsutil\obsutil_windows_amd64_5.8.9'
+$p2 = Join-Path $env:USERPROFILE 'tools\hcloud'
+$cur = [Environment]::GetEnvironmentVariable('Path','User')
+[Environment]::SetEnvironmentVariable('Path', "$cur;$p1;$p2", 'User')
+```
+
+新开终端后 `obsutil version` 和 `hcloud version` 应有输出。
+
+### 2.2 配置凭据
+
+凭据**只存本机 CLI 配置**,不进仓库、不写脚本(黄金规则 9)。
+
+```bash
+# ① obsutil —— 写入 ~/.obsutilconfig
+obsutil config -i=<AK> -k=<SK> -e=obs.cn-south-1.myhuaweicloud.com
+
+# ② hcloud —— 写入 KooCLI profile
+hcloud configure set --cli-profile=default \
+  --cli-access-key=<AK> --cli-secret-key=<SK> --cli-region=cn-north-1
+```
+
+### 2.3 配置 cli-domain-id(易踩)
+
+**KooCLI 用 AK/SK 模式调用 CDN 这类全局服务时必须带 `cli-domain-id`**,否则报:
+
+```
+[USE_ERROR]使用AK/SK模式访问全局服务,缺少必填参数cli-domain-id
+```
+
+取账号 ID(即 OBS 桶的 owner ID):
+
+```bash
+cd ~/tools/obssdk   # 或任意已装 esdk-obs-nodejs 的目录
+npm i esdk-obs-nodejs
+OBS_AK=<AK> OBS_SK=<SK> node -e "
+const O=require('esdk-obs-nodejs');
+new O({access_key_id:process.env.OBS_AK,secret_access_key:process.env.OBS_SK,
+       server:'https://obs.cn-south-1.myhuaweicloud.com'})
+  .getBucketAcl({Bucket:'www-ai.fmode.cn'},(e,r)=>console.log(r.InterfaceResult.Owner.ID));
+"
+```
+
+拿到 ID 后写入:
+
+```bash
+hcloud configure set --cli-profile=default --cli-domain-id=<那串ID>
+```
+
+### 2.4 KooCLI 隐私声明
+
+首次运行 hcloud 会交互询问「同意并继续使用(y)/不同意并退出(N)」。
+用脚本无人值守调用时会被卡住 —— `scripts/deploy.mjs` 已通过传 stdin `y` 自动应答,无需手工处理。
+
+---
+
+## 三、日常发布
+
+```bash
+# 完整流程(推荐):构建 → 自检 → 上传 → 刷 CDN → 探活
+npm run deploy
+
+# 已构建过、只想重新上传
+node scripts/deploy.mjs --skip-build
+
+# 演练:只打印将执行的命令,不真正执行
+node scripts/deploy.mjs --dry-run
+```
+
+成功时最后会看到:
+
+```
+=== [2/5] 构建产物自检 ===
+  ✓ 数据源开关 = 真实(useMockData: false)
+  ✓ index.html + 82 个文件
+=== [5/5] 线上探活 ===
+  ✓ / → 200
+  ✓ /index.html → 200
+  ✓ 线上标题为本次构建:Fmode App · 数字生命客户端
+  ✓ SPA 深链 /desktop/chat 回落到 index.html
+```
+
+**任何一项 `❌` 都会让脚本以退出码 1 结束**,不要忽略。
+
+### 发布前的推荐动作
+
+```bash
+npm test                 # 单测 1089 项
+npm run verify:ui        # UI 规范 12 项
+npm run build:mock && npm run smoke   # 双端全路由 28 项(注意:会覆盖 dist)
+npm run build            # 冒烟后务必重新出 production 包
+npm run deploy
+```
+
+> 🔴 `npm run smoke` 读的是 `dist/`,而冒烟需要演示数据包。跑完冒烟**必须重新 `npm run build`**,
+> 否则会把演示包发上线。`deploy.mjs` 的自检会拦截,但仍应养成顺序习惯。
+
+---
+
+## 四、特殊情况处理
+
+### 4.1 覆盖前想先备份
+
+```bash
+TS=$(date +%Y%m%d-%H%M%S)
+obsutil cp obs://www-ai.fmode.cn/index.html \
+  obs://www-ai.fmode.cn/_backup/manual-$TS/index.html -f -e=obs.cn-south-1.myhuaweicloud.com
+```
+
+回滚:把备份拷回根目录后再刷 CDN。
+
+### 4.2 只刷新 CDN(不改文件)
+
+```bash
+hcloud CDN CreateRefreshTasks/v2 --cli-region=cn-north-1 \
+  --refresh_task.urls.1="https://ai.fmode.cn/" \
+  --refresh_task.urls.2="https://ai.fmode.cn/index.html" \
+  --cli-profile=default
+```
+
+带 hash 的 JS/CSS 文件名唯一,不需要单独刷新。
+
+### 4.3 静态网站托管丢了(深链 404 且内容为空)
+
+**症状区分**:
+
+| 现象 | 含义 |
+|---|---|
+| 深链返回 404,但响应体是完整 HTML(含 `<title>`) | **正常**,ErrorDocument 回落生效 |
+| 深链返回 404,响应体是 OBS 的错误 XML | 静态网站配置丢失,需重配 |
+
+重配命令(`esdk-obs-nodejs`,注意参数在**顶层**而非嵌套在 `WebsiteConfiguration` 里):
+
+```js
+obs.setBucketWebsite({
+  Bucket: 'www-ai.fmode.cn',
+  IndexDocument: { Suffix: 'index.html' },
+  ErrorDocument: { Key: 'index.html' },
+}, (err, res) => console.log(err ?? res.CommonMsg.Status));   // 期望 200
+```
+
+---
+
+## 五、踩坑记录(均已修复,勿重犯)
+
+| # | 现象 | 根因 | 处理 |
+|---|---|---|---|
+| 1 | `Error: flag provided but not defined: -u` | `obsutil sync` 没有 `-u` 参数(那是 rsync 的) | 只用 `-f`(跳过确认),不加 `-u` |
+| 2 | CDN 刷新报 `请输入的是无效字符` | hcloud 首次运行交互式隐私声明,脚本传了空 stdin | 传 `y` 给 stdin;`deploy.mjs` 已内置 |
+| 3 | 探活恒为「curl 不可用」 | Windows 原生 curl 把 `-o /dev/null` 当文件名写盘 | 探活改用 Node 原生 `fetch` |
+| 4 | CDN 刷新报缺少 `cli-domain-id` | AK/SK 模式调全局服务必须带账号 ID | 见 §2.3 |
+| 5 | 深链探活判定 404 为失败 | OBS ErrorDocument 回落时状态码仍是 404 | 改为判断响应体是否含本次 `<title>` |
+| 6 | `npm run build` 产出未优化包 | production 配置缺 `optimization` | `angular.json` 已补 `optimization: true` |
+| 7 | 线上跑的是演示数据 | `environment.production.ts` 的 `useMockData` 误设为 `true` | 已改为 `false`;演示态改走 `environment.mock.ts` |
+
+---
+
+## 六、相关文件
+
+| 文件 | 作用 |
+|---|---|
+| `scripts/deploy.mjs` | 部署脚本主体(构建→自检→上传→刷新→探活) |
+| `src/environments/environment.base.ts` | 后端地址唯一真源 |
+| `src/environments/environment.production.ts` | 生产配置(`useMockData: false`) |
+| `angular.json` | 构建配置:`production` / `mock` / `mock-production` / `local` |
+| `README.md` | 命令速查与环境切换 |
+
+**变更记录**:2026-11-02 首次发布,建立本手册。

+ 1 - 1
docs/README.md

@@ -98,7 +98,7 @@
 ### 其它目录
 | 目录 | 状态 | 说明 |
 |---|---|---|
-| 05-operations/ | 待产出 | 部署 / 监控 |
+| [05-operations/部署发布手册.md](05-operations/部署发布手册.md) | 生效 | 🔴 **发布操作手册**:工具安装 → 首次发布 → 日常发布 → 排障 → 踩坑记录(2026-11-02) |
 | 06-standards/ | 待产出 | 技术栈 / 踩坑记录 |
 | 07-records/ | 待产出 | 修改留痕(YYYY-MM-DD-功能名) |
 

+ 6 - 2
package.json

@@ -5,13 +5,17 @@
     "ng": "ng",
     "postinstall": "node scripts/copy-pdf-worker.mjs",
     "start": "ng serve",
-    "build": "ng build",
+    "start:local": "ng serve --configuration local",
+    "start:mock": "ng serve --configuration mock",
+    "build": "ng build --configuration production",
+    "build:mock": "ng build --configuration mock-production",
     "watch": "ng build --watch --configuration development",
     "test": "ng test",
     "verify:docs": "node scripts/check-doc-structure.mjs docs",
     "verify:ui": "node scripts/verify-ui.mjs",
     "smoke": "node scripts/smoke-imhub.mjs",
-    "smoke:groups": "node scripts/smoke-groups.mjs"
+    "smoke:groups": "node scripts/smoke-groups.mjs",
+    "deploy": "node scripts/deploy.mjs"
   },
   "prettier": {
     "printWidth": 100,

+ 191 - 0
scripts/deploy.mjs

@@ -0,0 +1,191 @@
+#!/usr/bin/env node
+/**
+ * 构建 + 部署到华为云 OBS(www-ai.fmode.cn)+ 刷新 CDN(ai.fmode.cn)
+ *
+ * 用法:
+ *   node scripts/deploy.mjs                  # 完整:构建 → 校验 → 上传 → 刷新 CDN → 探活
+ *   node scripts/deploy.mjs --skip-build     # 跳过构建,直接上传已有 dist
+ *   node scripts/deploy.mjs --dry-run        # 只打印将要执行的动作,不执行
+ *
+ * 前置条件:
+ *   ① 已安装 obsutil 与 hcloud CLI,且在 `hcloud configure list` 中已配置 profile
+ *   ② 构建配置 `npm run build` 可用(production)
+ *
+ * 🔴 与旧 deploy.ps1 的差异:
+ *   - 项目名从 `nova-aigc` 改为 `fmode-app-v2`,产物路径改为 `dist/fmode-app-v2/browser`
+ *   - 异常时不静默继续:任何一步失败即 `process.exit(1)`
+ *   - 增加构建产物自检(防 demo/mock 包误发线上)
+ */
+import { execFileSync } from 'node:child_process';
+import { existsSync, readFileSync, readdirSync } from 'node:fs';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const projectRoot = resolve(here, '..');
+const distDir = join(projectRoot, 'dist/fmode-app-v2/browser');
+
+const BUCKET = 'obs://www-ai.fmode.cn';
+const CDN_BASE = 'https://ai.fmode.cn';
+const OBS_EP = 'obs.cn-south-1.myhuaweicloud.com';
+const CDN_REGION = 'cn-north-1';
+
+const args = new Set(process.argv.slice(2));
+const skipBuild = args.has('--skip-build');
+const dryRun = args.has('--dry-run');
+
+/** 执行命令并回显 */
+function run(cmd, argv, opts = {}) {
+  const printable = `${cmd} ${argv.join(' ')}`;
+  console.log(`\n$ ${printable}`);
+  if (dryRun) return '';
+  try {
+    return execFileSync(cmd, argv, { cwd: projectRoot, stdio: 'inherit', ...opts });
+  } catch (e) {
+    console.error(`\n❌ 命令失败:${printable}`);
+    process.exit(1);
+  }
+}
+
+/**
+ * 探活用 Node 原生 fetch,不依赖 curl。
+ * Windows 上的 curl 是原生程序,`-o /dev/null` 会被当成文件路径写盘,不能拿来判状态码。
+ */
+async function probe(url, timeoutMs = 30_000) {
+  const ac = new AbortController();
+  const timer = setTimeout(() => ac.abort(), timeoutMs);
+  try {
+    const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
+    return { status: res.status, body: await res.text() };
+  } catch {
+    return { status: 0, body: '' };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+// ── 1. 构建 ────────────────────────────────────────────────────────────────
+if (!skipBuild) {
+  console.log('\n=== [1/5] 构建 production ===');
+  run('npm', ['run', 'build']);
+} else {
+  console.log('\n=== [1/5] 跳过构建(--skip-build)===');
+}
+
+// ── 2. 产物自检 ────────────────────────────────────────────────────────────
+console.log('\n=== [2/5] 构建产物自检 ===');
+if (!existsSync(distDir)) {
+  console.error(`❌ 产物目录不存在:${distDir}`);
+  process.exit(1);
+}
+const files = readdirSync(distDir);
+const indexHtml = join(distDir, 'index.html');
+if (!files.includes('index.html')) {
+  console.error('❌ 缺少 index.html');
+  process.exit(1);
+}
+const html = readFileSync(indexHtml, 'utf8');
+if (!/app-root/.test(html)) {
+  console.error('❌ index.html 不含 <app-root>,产物异常');
+  process.exit(1);
+}
+// 主包与依赖 chunk 中若含演示数据开关且为 true,说明误发了 mock 包
+const jsFiles = files.filter((f) => f.endsWith('.js'));
+let sawMockSwitch = false;
+for (const f of jsFiles) {
+  const src = readFileSync(join(distDir, f), 'utf8');
+  const m = src.match(/useMockData\s*:\s*!?[01]/);
+  if (!m) continue;
+  sawMockSwitch = true;
+  if (/!\s*0/.test(m[0])) {
+    console.error(`❌ 检测到演示数据包(${f} 中 useMockData: true),拒绝部署到线上`);
+    console.error('   线上请用 `npm run build`;演示包请用 `npm run build:mock` 走本地 mock 服务');
+    process.exit(1);
+  }
+}
+if (sawMockSwitch) {
+  console.log('  ✓ 数据源开关 = 真实(useMockData: false)');
+} else {
+  console.warn('  ⚠ 未在产物中找到 useMockData 开关(可能已被 tree-shake),跳过校验');
+}
+console.log(`  ✓ index.html + ${files.length} 个文件`);
+
+// ── 3. 上传 OBS ────────────────────────────────────────────────────────────
+console.log('\n=== [3/5] 同步到 OBS ===');
+/*
+ * 先 sync 覆盖、再清多余文件:不用先清空桶,避免「清空后上传失败」导致站点空窗。
+ * `-f` 跳过确认;**不传 `-delete`** —— 桶里可能还有非本站资源(assets/ 等),
+ * 本应删除的旧产物需人工确认,见 README「部署」一节。
+ * OBS 兼容 S3 协议,AK/SK 由 obsutil 自身的配置(`obsutil config`)提供,不写在脚本里。
+ */
+run('obsutil', ['sync', distDir, BUCKET, '-f', `-e=${OBS_EP}`]);
+run('obsutil', ['chattri', BUCKET, '-r', '-f', `-e=${OBS_EP}`, '-acl=public-read']);
+
+// ── 4. 刷新 CDN ────────────────────────────────────────────────────────────
+console.log('\n=== [4/5] 刷新 CDN ===');
+const profile = process.env.HCLOUD_PROFILE || 'default';
+const refreshArgs = [
+  'CDN',
+  'CreateRefreshTasks/v2',
+  `--cli-region=${CDN_REGION}`,
+  `--refresh_task.urls.1=${CDN_BASE}/`,
+  `--refresh_task.urls.2=${CDN_BASE}/index.html`,
+  `--cli-profile=${profile}`,
+];
+// KooCLI 首次运行会交互式询问《隐私政策声明》,无人值守时会卡死并报 USE_ERROR
+run('hcloud', refreshArgs, { input: 'y\ny\ny\n' });
+
+// ── 5. 探活 ────────────────────────────────────────────────────────────────
+console.log('\n=== [5/5] 线上探活 ===');
+if (dryRun) {
+  console.log('  (dry-run,跳过)');
+} else {
+  /*
+   * 每个 URL 轮询至多 3 分钟(CDN 刷新实际有 1-5 分钟延迟),
+   * 命中 200 / 内容是本次构建时才判通过 —— 只发请求不看结果的「探活」等于没探。
+   */
+  const indexHtmlContent = readFileSync(indexHtml, 'utf8');
+  const title = indexHtmlContent.match(/<title>([^<]*)<\/title>/)?.[1] ?? '';
+  let allOk = true;
+  for (const path of ['/', '/index.html']) {
+    let ok = false;
+    for (let i = 0; i < 18 && !ok; i++) {
+      const { status } = await probe(`${CDN_BASE}${path}`);
+      ok = status === 200;
+      if (!ok) await new Promise((r) => setTimeout(r, 10_000));
+    }
+    if (ok) console.log(`  ✓ ${path} → 200`);
+    else {
+      allOk = false;
+      console.log(`  ❌ ${path} 轮询 3 分钟仍未返回 200,请手动确认 CDN 状态`);
+    }
+  }
+  if (title) {
+    const { body } = await probe(`${CDN_BASE}/`);
+    if (body.includes(title)) console.log(`  ✓ 线上标题为本次构建:${title}`);
+    else {
+      allOk = false;
+      console.log(`  ⚠ 线上标题非本次构建(期望「${title}」),CDN 可能尚未刷新完`);
+    }
+  }
+  /*
+   * SPA 深链:OBS 静态网站托管用 ErrorDocument=index.html 回落 ——
+   * **状态码仍是 404**(OBS/S3 的标准行为),但响应体是 index.html,浏览器据此正常启动。
+   * 故判据是「响应体含本次构建的 <title>」,而不是状态码。
+   * 若将来换到 CDN 回源改写(状态码 200),这条断言同样成立。
+   */
+  const deep = await probe(`${CDN_BASE}/desktop/chat`);
+  if (title && deep.body.includes(title)) {
+    console.log('  ✓ SPA 深链 /desktop/chat 回落到 index.html');
+  } else {
+    allOk = false;
+    console.log('  ❌ SPA 深链 /desktop/chat 未回落(需在 OBS 桶「静态网站托管」把错误文档设为 index.html)');
+  }
+
+  if (!allOk) {
+    console.error('\n❌ 部署完成但线上校验未全部通过,见上方 ❌ / ⚠ 项');
+    process.exit(1);
+  }
+}
+
+console.log(`\n✅ 部署完成 → ${CDN_BASE}\n`);

+ 36 - 5
scripts/smoke-imhub.mjs

@@ -80,6 +80,19 @@ async function go(page, base, path) {
   await page.waitForFunction(() => document.body.innerText.trim().length > 20, null, {
     timeout: 15_000,
   });
+  /*
+   * 🔴 再等占位/PENDING 态退场(骨架屏、`--`、`加载中` 也算 innerText>20)。
+   *    移动端 projects/workbench 数据来自 MockApi 的模拟延迟(320ms),
+   *    不等的话会抓到标题+骨架就断言,误报「主区内容过少/疑似白屏」。
+   */
+  await page
+    .waitForFunction(
+      () => !/加载中|PENDING|正在加载|载入中/i.test(document.body.innerText),
+      null,
+      { timeout: 10_000 },
+    )
+    .catch(() => {}); // 有意的长加载态页面不阻塞
+  await page.waitForTimeout(300); // 给 @for 列表一帧落地
 }
 
 /** 无横向溢出 + 无哑按钮 + 无裸露原生控件 */
@@ -87,19 +100,32 @@ async function checkLayout(page) {
   const r = await page.evaluate(() => {
     const de = document.documentElement;
     const namelessButtons = [...document.querySelectorAll('button')].filter(
-      (b) => b.getBoundingClientRect().width > 0 && !b.textContent.trim() && !b.getAttribute('aria-label'),
-    ).length;
+      (b) =>
+        b.getBoundingClientRect().width > 0 &&
+        !b.textContent.trim() &&
+        // 可访问名来源:aria-label / aria-labelledby / title 文本(WAI-ARIA 1.2)
+        !b.getAttribute('aria-label') &&
+        !b.getAttribute('aria-labelledby') &&
+        !b.getAttribute('title'),
+    );
     return {
       docWidth: de.scrollWidth,
       winWidth: window.innerWidth,
-      namelessButtons,
+      namelessButtons: namelessButtons.length,
+      namelessSample: namelessButtons
+        .slice(0, 3)
+        .map((b) => (b.outerHTML || '').replace(/\s+/g, ' ').slice(0, 200)),
     };
   });
   assert.ok(
     r.docWidth <= r.winWidth + 1,
     `横向溢出:doc=${r.docWidth} > win=${r.winWidth}`,
   );
-  assert.equal(r.namelessButtons, 0, `${r.namelessButtons} 个按钮无可访问名`);
+  assert.equal(
+    r.namelessButtons,
+    0,
+    `${r.namelessButtons} 个按钮无可访问名:${r.namelessSample.join(' | ')}`,
+  );
 }
 
 /** 主区有可见文本 */
@@ -225,7 +251,12 @@ await test('desktop 会话页结构完整(AC-04.2 链路骨架)', async () =
    *    真正的发送语义由单测覆盖:chat.service.spec 的 sendText 系列
    *    + 多会话隔离用例(带线程根、按会话过滤)。
    */
-  const sidebar = await pc.locator('.list-col').count();
+  /*
+   * 🔴 2026-10-30 起左栏(会话树)从页面提升到 `DesktopShell` 外壳组件
+   *    —— 一级路由切换时左栏要保持不变,不能挂在某个页面里。
+   *    断言随之从页面内 `.list-col` 改为外壳的 `app-session-tree`。
+   */
+  const sidebar = await pc.locator('app-session-tree, .list-col').count();
   assert.ok(sidebar > 0, '左栏会话列表区域应存在');
   const mainArea = await pc.locator('.main-col').count();
   assert.ok(mainArea > 0, '右侧会话区应存在');

+ 8 - 3
src/app/core/api/parse-api.ts

@@ -1,3 +1,4 @@
+import { environment } from '../../../environments/environment';
 import { tsOf } from '../time';
 import { Scope } from '../acl';
 import { FmodeApi } from './fmode-api';
@@ -48,9 +49,13 @@ import {
  * 故**显式抛错**而非静默返回空数组 —— 空数组会把「未接入」伪装成「没有数据」。
  */
 
-const FUNCTION_API = 'https://server.fmode.cn/api/functions';
-const PARSE_BASE = 'https://server.fmode.cn/parse';
-const PARSE_APP_ID = 'ncloudmaster';
+/*
+ * 端点来自环境配置(黄金规则 6:serverURL 只在初始化处配置一次)。
+ * 见 `src/environments/environment.base.ts`。
+ */
+const FUNCTION_API = environment.endpoints.functionsBase;
+const PARSE_BASE = environment.endpoints.parseServer;
+const PARSE_APP_ID = environment.endpoints.parseAppId;
 
 /** 云函数 objectId(《API-契约》§二) */
 const FN = {

+ 4 - 3
src/app/core/matrix-client.service.ts

@@ -1,5 +1,6 @@
 import { effect, inject, Injectable, OnDestroy, signal } from '@angular/core';
 import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
+import { environment } from '../../environments/environment';
 import { SessionService } from './session.service';
 
 /**
@@ -897,6 +898,6 @@ export class MatrixClientService implements OnDestroy {
   }
 }
 
-/** homeserver 常量(与《API-契约》§1.2 一致) */
-export const MATRIX_HS = 'https://matrix.fmode.cn';
-export const MATRIX_SERVER_NAME = 'fmode.cn';
+/** homeserver 常量(与《API-契约》§1.2 一致;地址真源见 environment.base.ts) */
+export const MATRIX_HS = environment.endpoints.matrixHs;
+export const MATRIX_SERVER_NAME = environment.endpoints.matrixServerName;

+ 6 - 1
src/app/ui/components/button/button.html

@@ -3,8 +3,13 @@
   [class]="classes()"
   [disabled]="disabled() || loading()"
   [attr.aria-busy]="loading() || null"
-  [attr.aria-label]="ariaLabel() || null"
+  [attr.aria-label]="ariaLabel() || (loading() ? '加载中' : null)"
 >
+  <!--
+    🔴 [attr.aria-label] 的「加载中」兜底:加载态把内容整块换成 aria-hidden 的 spinner,
+       按钮就没有可访问名了(WAI-ARIA:aria-busy 不改变名字,disabled 也不会被读屏跳过)。
+       用属性块而非 `[attr.aria-busy]` 行内注释 —— 标签内的 HTML 注释会被当作属性解析,构建报 NG5002。
+  -->
   @if (loading()) {
     <i class="fa-solid fa-spinner animate-spin" aria-hidden="true"></i>
     @if (loadingText()) {

+ 24 - 0
src/environments/environment.base.ts

@@ -0,0 +1,24 @@
+/**
+ * 后端端点基线(全环境共享的默认值)
+ *
+ * 🔴 黄金规则 6:`serverURL` 只在初始化处配置一次,禁止散落各处。
+ *   这里是**唯一的**后端地址真源;`environment.ts` / `environment.production.ts`
+ *   / `environment.local.ts` / `environment.mock.ts` 各自继承并按需覆盖。
+ *
+ * 需要切到预发/测试环境时,**不改本文件**:改 `environment.local.ts`(不入库,见 .gitignore)。
+ */
+export const baseEndpoints = {
+  /** Parse REST 根(`/users/me`、`/classes/*` 等都拼在它后面) */
+  parseServer: 'https://server.fmode.cn/parse',
+  /** 云函数统一入口(`POST {id, token, params}`) */
+  functionsBase: 'https://server.fmode.cn/api/functions',
+  /** Parse 应用 ID(非密钥,公开信息) */
+  parseAppId: 'ncloudmaster',
+  /** Matrix homeserver 基址 */
+  matrixHs: 'https://matrix.fmode.cn',
+  /** Matrix 服务端名(用户 id 的 `@localpart:server` 后半段) */
+  matrixServerName: 'fmode.cn',
+} as const;
+
+/** 端点的形状(环境文件可覆盖任意子集) */
+export type Endpoints = { -readonly [K in keyof typeof baseEndpoints]: string };

+ 33 - 0
src/environments/environment.local.example.ts

@@ -0,0 +1,33 @@
+/**
+ * 环境配置(本地联调 —— `npm run start:local`)
+ *
+ * 🔴 本文件**不入库**(`.gitignore` 的 `src/environments/*.local.ts`)。
+ *    用途:把本地开发指向预发/测试环境,而不影响团队共享的 `environment.ts`。
+ *
+ * 用法:
+ *   1. 复制本模板为 `environment.local.ts`(同名去掉 `.example`)
+ *   2. 按需改 endpoints 里的地址
+ *   3. `npm run start:local`
+ *
+ * ⚠️ 不要在这里放任何密钥:前端包是公开可下载的,masterKey / AK / SK 一律不得出现。
+ */
+import { baseEndpoints, Endpoints } from './environment.base';
+
+export const environment = {
+  production: false,
+
+  useMockData: false,
+
+  mockLatency: 320,
+  mockFailureRate: 0,
+
+  version: '2.0.0-local',
+
+  /** 示例:把后端指向另一套环境(改这里即可,不要改 environment.base.ts) */
+  endpoints: {
+    ...baseEndpoints,
+    // parseServer: 'https://server-test.fmode.cn/parse',
+    // functionsBase: 'https://server-test.fmode.cn/api/functions',
+    // matrixHs: 'https://matrix-test.fmode.cn',
+  } as Endpoints,
+};

+ 27 - 0
src/environments/environment.mock.ts

@@ -0,0 +1,27 @@
+/**
+ * 环境配置(演示/冒烟 —— `npm run build:mock` 与 `npm run start:mock`)
+ *
+ * 用于两条不需要真实后端的路径:
+ *   ① `npm run smoke` —— Playwright 全路由冒烟(断言 UI 结构与路由)
+ *   ② UI 走查 / 设计评审 —— 无登录态也能看到完整界面
+ *
+ * 🟡 `npm run smoke` 需要先产出 mock 包(`npm run smoke:build`)。
+ */
+import { baseEndpoints, Endpoints } from './environment.base';
+
+export const environment = {
+  production: false,
+
+  /** 演示数据 */
+  useMockData: true,
+
+  /** 模拟网络延迟,让骨架屏/加载态真实可见 */
+  mockLatency: 320,
+
+  /** 0 = 不注入失败;调试错误态时临时调高 */
+  mockFailureRate: 0,
+
+  version: '2.0.0-mock',
+
+  endpoints: { ...baseEndpoints } as Endpoints,
+};

+ 20 - 5
src/environments/environment.production.ts

@@ -1,14 +1,29 @@
 /**
- * 环境配置(生产)
+ * 环境配置(生产 —— `npm run build` 走这里)
  *
- * 生产同样**保留演示数据开关**:v2 一期上线时云函数未就绪,
- * 若此时强行关闭开关会得到全空界面。故生产默认仍为演示数据,
- * 待云函数就绪后再改为 false(见 environment.ts 顶部说明)。
+ * 与开发环境唯一差别是 `production` 标记与版本号;后端端点同源,
+ * 避免「开发连 A、线上连 B」却不自知。
  */
+import { baseEndpoints, Endpoints } from './environment.base';
+
 export const environment = {
   production: true,
-  useMockData: true,
+
+  /**
+   * 🔴 线上走**真实云函数**。
+   *
+   * 曾是 `true`(v2 一期云函数未就绪时的临时值),导致线上无法登录/收发消息。
+   * 云函数(agentStatus / deliverables / provision / tokenSync / uploadUrl /
+   * workgroup / chatThreads)与 Matrix 均已就绪,改为 false。
+   * 需要发演示包时用 `npm run build:mock`,**不要**改回这里。
+   */
+  useMockData: false,
+
   mockLatency: 0,
   mockFailureRate: 0,
+
   version: '2.0.0',
+
+  /** 后端端点(唯一真源见 environment.base.ts) */
+  endpoints: { ...baseEndpoints } as Endpoints,
 };

+ 15 - 12
src/environments/environment.ts

@@ -1,25 +1,25 @@
 /**
- * 环境配置(开发)
+ * 环境配置(开发 —— `npm start` / `ng serve` 默认走这里)
  *
  * 🔴 `useMockData` 是**全局唯一数据源开关**(《数据访问层设计.md》§二):
  *    true  = 演示数据(MockApi)
- *    false = 真实云函数(ParseApi → https://server.fmode.cn)
- *
- * 切换真实数据的动作清单:
- *   ① useMockData: false
- *   ② ParseApi 已实现(按 docs/03-interface/api/API-契约.md)
- *   ③ 登录后 SessionService 会把 sessionToken 注入 ParseApi
+ *    false = 真实云函数(ParseApi → `endpoints.parseServer`)
  *
  * 业务服务与页面代码**零改动** —— 这是抽象层隔离的收益点。
+ *
+ * | 命令                  | 配置文件                  | useMockData |
+ * |-----------------------|---------------------------|-------------|
+ * | `npm start`           | environment.ts            | false       |
+ * | `npm run start:local` | environment.local.ts(不入库) | 按需     |
+ * | `npm run build`       | environment.production.ts | false       |
+ * | `npm run build:mock`  | environment.mock.ts       | true        |
  */
+import { baseEndpoints, Endpoints } from './environment.base';
+
 export const environment = {
   production: false,
 
-  /**
-   * 演示数据开关。
-   *
-   * 🔴 接入真实数据时改为 false(当前为 Matrix 真实接入验证阶段,默认已切换)。
-   */
+  /** 演示数据开关 */
   useMockData: false,
 
   /** 演示模式下的模拟网络延迟(ms),用于验证加载态 */
@@ -30,4 +30,7 @@ export const environment = {
 
   /** 应用版本号(FR-10 AC-10.3:禁止硬编码在组件里) */
   version: '2.0.0-dev',
+
+  /** 后端端点(唯一真源见 environment.base.ts) */
+  endpoints: { ...baseEndpoints } as Endpoints,
 };

+ 2 - 1
tsconfig.app.json

@@ -10,6 +10,7 @@
     "src/**/*.ts"
   ],
   "exclude": [
-    "src/**/*.spec.ts"
+    "src/**/*.spec.ts",
+    "src/environments/*.example.ts"
   ]
 }