Day 17 / 共 20 天 · 第 4 周 前端与工程收官

前端分层走一页:跟着 Prompt 页面从上挖到底

昨天认识了壳应用和路由。今天用 docs/reference/frontend-packages.md 里的六层依赖图打底,然后真的跟着一个页面的代码——Prompt 列表页——从 loop-pages 一路往下挖到 loop-base,顺路搞懂前端版的"接口与实现分离":Adapter 模式。

📍 你在整门课的位置 · 第 4 周 前端与工程收官(共 4 周 · 20 天)
D15 跨模块组装图 D16 前端壳与路由 D17 前端分层 D18 IDL 端到端 D19 动手改功能
L01

先看全景:六层依赖结构

docs/reference/frontend-packages.md 把 59 个包的关系画成一张六层图(高层级只能依赖低层级,禁止反向依赖):

Level-6  apps/cozeloop/                    主 SPA 应用(React 18, Rsbuild)
   ↑
Level-5  packages/loop-pages/              5 个页面模块(auth/evaluate/observation/prompt/tag-pages)
Level-4  packages/loop-modules/            高阶业务模块(evaluate)
Level-3  packages/loop-components/         13 个 UI 组件包 + adapter
Level-2  packages/loop-base/               20 个基础库(account/api-schema/hooks/stores…)
Level-1  config/ (6) + infra/ (4+)         工具链配置 + 基础设施(idl 转换等)
💡 这张图和 Day 15 的后端依赖图是同一种思维的两种呈现 后端靠"模块间不直接 import + 本地客户端"防止乱依赖;前端靠"Rush 的 level-N 标签 + lint 规则"防止乱依赖。核心诉求一致:越基础的东西越稳定、被依赖的次数越多,越顶层的东西越容易变、只能往下依赖。
生活类比:图书馆的分区 一楼是"工具间"(扳手、梯子,谁都能借用);二楼是"基础资料室"(字典、百科);三楼是"专业书架"(分门类的参考书);四楼是"研究室";五楼是"展览厅"(把各专业内容组织成一次展览);六楼是"接待大厅"(游客看到的最终入口)。你不会看到"工具间"里摆着"研究室专用的论文草稿"——楼层越高,内容越"面向最终读者",也越依赖楼下的积累。
L02

跟着 Prompt 列表页真实代码往下挖

打开 frontend/packages/loop-pages/prompt-pages/src/pages/list/index.tsx 顶部的 import(这是 Level-5 页面包),逐条对照它依赖了哪些更底层的包:

import {
  promptDisplayColumns, PromptList, PromptDeleteModal, PromptCreateModal,
} from '@cozeloop/prompt-components-v2';        // Level-3:Prompt 专用组件

import { I18n } from '@cozeloop/i18n-adapter';               // Level-2:国际化(也带 adapter 后缀,见 L04)
import { useModalData, useRefresh } from '@cozeloop/hooks';  // Level-2:通用 hooks
import { TableColActions } from '@cozeloop/components';       // Level-2:基础 UI 组件

import {
  useNavigateModule, useOpenWindow, useSpace, useUserInfo,
} from '@cozeloop/biz-hooks-adapter';           // Level-3:业务 hooks(走 Adapter)

import { UserSelect } from '@cozeloop/biz-components-adapter'; // Level-3:业务组件(走 Adapter)
import { type Prompt } from '@cozeloop/api-schema/prompt';     // Level-2:从 IDL 生成的类型(Day 18 细讲)
💡 光看 import 语句就能画出这一页的依赖子图 这正是 Day 15 强调的"代码即文档"——不需要额外画图工具,一个页面顶部的 import 列表本身就是它的依赖清单,而且这份清单永远和实际代码同步(画在 Word 里的架构图很容易过时,import 语句不会騙人)。
prompt-pages(L5)页面级:组装页面布局、处理页面级状态
prompt-components-v2(L3)组件级:可复用的 Prompt 列表表格、弹窗等 UI 单元
biz-hooks-adapter / biz-components-adapter(L3)业务通用能力,但内容因开源/商业版而异,见 L04
components / hooks / i18n-adapter(L2)最基础的通用能力,几乎所有页面都会用到
api-schema(L2)从后端 IDL 生成的 TypeScript 类型,Day 18 的主角
L03

Level-2 基础库特写:api-schema

@cozeloop/api-schema/prompt 里的 Prompt 类型不是手写的——它是 Day 18 要细讲的"IDL 代码生成"产物。frontend/packages/loop-base/api-schema 这个包的所有 .ts 类型文件,都是跑一条命令自动生成出来的:

cd frontend && rush update-api   # 从 idl/thrift/ 生成 TS 类型,落进 packages/loop-base/api-schema
💡 为什么单独抽出一个 api-schema 包,而不是每个页面包自己生成 因为多个页面包会用到同一个类型(比如 Prompt 类型在 prompt-pages 和可能的 evaluate-pages 里都会用到,评测时要引用一个 Prompt)。抽成 Level-2 的独立包,保证"同一个后端概念在前端只有一份类型定义",避免各个页面包各自维护一份、久而久之定义漂移不一致。
Level-2 里还有什么account(用户账户)、stores(Zustand 全局状态)、route(路由工具)、tea(数据埋点)、i18n(国际化底座)——都是"跟具体业务无关,但几乎每个页面都要用"的基础设施,对应后端 Day 15 里 foundation 模块的地位。
L04

Adapter 模式:为什么名字里都带个"adapter"

🤔 痛点:Coze Loop 同时有开源版和商业版,很多功能两边表现不一样 比如"用户选择器"(UserSelect)在开源版可能只能选本地账号,商业版可能要接企业级的组织架构服务。如果在组件内部写一堆 if (isCommercial) {...} else {...},代码会越改越乱,而且开源仓库里还得留着商业版的代码分支(不利于开源发布)。
💡 本质:和 Day 15 后端"接口在 domain,实现在 infra"是同一个思路的前端版 docs/reference/frontend-packages.md:90-102 画得很清楚:

adapter-interfaces/           # 接口定义(Level-3)
    ↑
*-adapter/                    # 接口实现(evaluate-adapter, observation-adapter…)
    ↑
components-with-adapter/      # 消费者

页面代码永远只 import xxx-adapter 这个包名(比如 @cozeloop/biz-components-adapter),而这个包在开源版仓库里放的是开源实现,商业化仓库同步时会替换成商业版实现——包名和调用方式完全不变。

生活类比:插座和电器 adapter-interfaces 就是"国标插座的形状规范",*-adapter 是"具体插进去的电器"——你家的插座(页面组件)只关心"插孔形状对不对",不关心插的是电风扇还是台灯。换一个电器(开源版换商业版实现)完全不用改墙上的插座。
L05

真实的 Adapter 接口定义长什么样

frontend/packages/loop-components/adapter-interfaces/package.jsonexports 字段按业务域拆分导出入口:

{
  "name": "@cozeloop/adapter-interfaces",
  "exports": {
    ".": ["./src/index.ts"],
    "./evaluate": ["./src/evaluate/index.ts"],
    "./prompt": ["./src/prompt/index.ts"],
    "./observation": ["./src/observation/index.ts"]
  }
}

目录结构上,adapter-interfaces/src/ 下每个业务域一个文件夹(evaluate/experiments.tsobservation/trace.ts 等),每个文件定义的是纯 TypeScript 接口/类型,不含任何实现——这和后端 components/model/interface.go(Eino 教程 Day 08 讲过的"接口与实现分离")几乎是同一种代码组织方式。

👶 小白问:那 components-with-adapter 又是干嘛的?为什么不直接让页面 import *-adapter 完事?

👨‍🏫 老师:有些组件需要"接口实现"之外再包一层通用的 UI 逻辑(比如加载状态、错误边界)——这层通用逻辑在开源版商业版都一样,不需要重复实现。components-with-adapter 就是"消费 *-adapter 的具体实现 + 提供通用 UI 包装"的那一层,页面代码最终 import 的可能是这一层,也可能直接 import *-adapter,取决于该组件是否需要这层包装。

⚠️ 给自己写代码的红线 docs/reference/frontend-packages.md:102 原话:"新增商业版/开源版差异必须走 adapter 接口,不可在组件中硬编码条件分支"——这是本仓库前端的一条硬约束,和后端"domain 绝不引用 infra"一样,是靠代码评审和架构纪律维护的,没有编译器能强制检查"你是不是手写了一个 if-else 分支去区分版本"。
L06

一个容易confuse的小细节:包名 ≠ 文件夹名

如果你直接在 frontend/packages/loop-components/ 下找"biz-components-adapter"文件夹,会找不到——它的真实文件夹叫 biz-components。答案在 rush.json 里的注册记录:

{ "packageName": "@cozeloop/biz-components-adapter", "projectFolder": "frontend/packages/loop-components/biz-components", "tags": ["level-3"] }
💡 packageName(导入名)和 projectFolder(磁盘路径)是两个独立字段 Rush 允许一个包的 npm 包名和它在磁盘上的文件夹名不一致——biz-components 文件夹产出的包对外叫 @cozeloop/biz-components-adapter,这个命名本身就在提示使用者"这是一个走 Adapter 模式的包",即使它的源码文件夹名字看起来更朴素。
读源码遇到"import 的包名在 packages/ 下找不到同名文件夹"时,第一反应应该是去 rush.json 里搜 packageName,而不是怀疑自己找错了仓库。
L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • 前端六层依赖结构从上到下分别是什么?
  • 怎么只靠一个页面文件的 import 语句就画出它的依赖子图?
  • 为什么要把 api-schema 单独抽成一个 Level-2 包?
  • Adapter 模式解决的核心问题是什么?和后端 Day 15 的哪个设计思路对应?
  • 遇到 import 的包名在磁盘上找不到对应文件夹,该去哪里查?
🎵 记忆口诀六层图定边界,import 就是依赖清单,Adapter 拐一道弯换版本,包名文件夹名各查各的表」——前端和后端的分层思路,本质上是同一套工程智慧的两种语言实现。

✋ 动手 5 分钟(可选)

# 1. 完整看一遍六层依赖参考文档
cat docs/reference/frontend-packages.md

# 2. 看 Prompt 列表页的全部 import,数一数涉及几个不同层级的包
head -30 frontend/packages/loop-pages/prompt-pages/src/pages/list/index.tsx

# 3. 找出所有带 "adapter" 字样的包名
grep "adapter" rush.json | grep packageName

# 4. 看 adapter-interfaces 包按业务域拆出的几个入口
cat frontend/packages/loop-components/adapter-interfaces/package.json
明天预告 · Day 18:今天多次提到"从 IDL 生成 TS 类型",明天彻底搞懂这条链路——一个 Thrift 字段改动,怎么同时变成后端的 Go 结构体和前端的 TypeScript 类型,rush update-api 和 kitex 脚本到底做了什么。
← 上一天 Day 16 下一天 · IDL 端到端 →