diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md
index 4cc781e..0d3bc54 100644
--- a/docs/prototype/code-reader/figma-plugin/README.md
+++ b/docs/prototype/code-reader/figma-plugin/README.md
@@ -11,7 +11,7 @@
- 函数:名称、访问级别(公共 / 私有)、异步(是 / 否)、装饰器、输入参数(展开成一张带边框和表头的表格:名称 / 类型 / 默认值三列,每个参数一行)、输出参数。
- 导入:来源、仅类型(是 / 否,只有 TypeScript 有)、导入项(展开成每项一行,有别名写成「promises 作为 fsp」)。
- 常量 / 变量:名称、访问级别(只有最外层的声明有)、类型(写了标注才有)、值 / 初始值。
-- 块左边有种类标签(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释),底色也带这个种类的颜色;有子块的块左边有折叠箭头。
+- 块左边只画种类的图标,鼠标停上去提示名称(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释……)。图标、颜色、名称由插件定义,库规则认出的块由规则自己给;图标用 lucide,和插件预览页面同一套。块的底色也带这个种类的颜色;有子块的块左边有折叠箭头。
- 译文按词的来源上色,配色和 VS Code 默认深色主题一致:流程关键字(导入 / 来自 / 如果 / 返回 / 遍历 / 等待 / 公共 / 尝试 / 捕获)紫红,声明关键字(常量 / 函数 / 异步 / 接口 / 新建)和运算符(全等 / 非 / 或 / 与)蓝,变量浅蓝,函数黄,类型青,字符串橙,字面量淡绿。源码状态同样上色。
## 画了什么
@@ -26,6 +26,8 @@
| `Prototype / 库规则 · SQLAlchemy 数据表` | luffy 的 `backend/src/luffy_agent/referral/models.py` 里的 `ReferralRelation` | 库规则(文件导入了 sqlalchemy 才启用)把模型类整块换成「数据表」卡片:名称 / 表名 / 说明 / 继承,字段表(字段 / 类型 / 约束 / 说明)和索引表;`__tablename__`、`__table_args__`、各字段块并进卡片 |
| `Prototype / 枚举` | luffy 的 `referral/models.py` 里的 `CommissionStatus` | 枚举卡片:名称 / 访问级别 / 继承,成员收成一张表(成员 / 值 / 说明),说明取行尾注释;Python 用库规则(导入了 enum 才启用),TypeScript 的 enum 由语言插件报结构,画成同一张卡片 |
| `Prototype / 库规则 · Celery` | luffy 的 `backend/src/luffy_agent/celery_app.py` | 库规则(导入了 celery 才启用)两张卡片:「Celery 应用」名称 / 应用名 / 消息队列 / 结果存储;「定时任务」应用 + 任务表(任务 / 频率 / 说明),频率换算成「每 5 分钟」「每天」,说明取行尾注释,`**` 展开的另起一行 |
+| `Prototype / 库规则 · FastAPI 接口` | luffy 的 `user/routes.py` 里的 `update_user` | 库规则(导入了 fastapi 才启用)把挂在路由对象上的函数整块换成「接口」卡片,函数体照常是子块:名称 / 说明 / 标签 / 异步 / 依赖(`Depends(...)` 注入的参数),然后两张表。请求表一项一行:URL(路由对象的 prefix + 装饰器里的路径)/ 方法 / 路径参数 / 查询参数 / 请求体,没有的项不列。响应表每个状态码一行(状态码 / 内容 / 说明):成功那行来自 `status_code` 和 `response_model`,`responses={…}` 里写的各占一行,函数体里直接抛的异常按异常类上的 `status_code` 归行、说明列出抛出时的消息,有参数时 FastAPI 自动带的 422 淡色。挂到应用上时再叠的前缀(`/api/v1`)在别的文件里,不算 |
+| `Prototype / 块种类图标` | — | 每种块一个图标、颜色、名称的图例,库规则带的种类后面淡色写规则名;最上面一块「返回」停着鼠标、图标下面弹出名称提示 |
| `Prototype / 遍历 · 推导式` | luffy 邀请码工具的 `"".join(secrets.choice(ALPHABET) for _ in range(length))`,外加带条件的列表、字典推导 | 写在表达式里的遍历原地套一个带循环图标和结果类型(生成器 / 列表 / 字典)的小框,框里按读的顺序重排:「遍历 range(length) 的每个 _,仅当 …,取 …」 |
| `Prototype / 侧栏 · 复制菜单` | 同上 | 悬停行行尾第一个按钮「复制」(带小箭头),点开一组复制方式:文件名 / 文件名(不带后缀)/ 相对路径 / 绝对路径,右边淡色预览会复制出什么;文件夹没有「不带后缀」 |
| `Prototype / 问 AI` | routes.py 的 get_user 里的条件、语句、返回 | 块右上角只有图标(问 AI、编辑 / 预览),不带字;问过的问答灰字挂在块下面(问题 + 回答前两行,时间、删除);正在问的那轮正常颜色,回答逐字出来,底下输入框追问 |
@@ -67,7 +69,9 @@
- `Reader / TableCard`:SQLAlchemy 规则认出的数据表卡片。
- `Reader / EnumCard`:枚举卡片。
- `Reader / CeleryAppCard`、`Reader / BeatCard`:Celery 规则的两张卡片。
+- `Reader / RouteCard`:FastAPI 规则的接口卡片。
- `Reader / IterationBlock`:`Sample=generator | list | dict`,三种推导式的返回块。
+- `Reader / FilterChip`:`State=default | active`,筛选栏里一个属性的下拉框,各筛选栏共用。
- `Reader / FilterBar`:`Active=none | access | kind`,预览顶部的筛选栏,打开的下拉菜单是勾选列表,每个值后面是块数。
- `Reader / BlockTools`:`State=preview | edit`,块右上角的一排图标按钮(问 AI、编辑 / 预览),各种块共用。
- `Reader / AskPanel`:`State=history | asking`,块下面的问答区。
@@ -114,9 +118,10 @@ node --test test.js
- [ ] 停在「编辑」状态的块显示上了色的源码、蓝框,中文注释不是方块
- [ ] 不同种类的块一眼能分开:标签颜色、底色、边框都不同;同一种类(比如三个「返回」)颜色一致
- [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格
-- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐
+- [ ] 有子块的块左边有折叠箭头,没有子块的没有,种类图标仍然对齐;图标和一行字等高,一眼分得清是哪种
- [ ] 属性表:函数、导入、常量、变量的属性名一列对齐;导入项每项一行,左边竖线;输入参数是带边框和表头的表格,名称 / 类型 / 默认值三列对齐;长类型(`Annotated[...]`)换行不挤掉默认值列
- [ ] 数据表:字段表四列对齐,约束一格里外键目标、删除规则看得清;说明列长了换行不挤别的列
+- [ ] 接口:请求表、响应表列对齐;方法颜色和 Swagger 一致;响应说明列长了换行不挤别的列
- [ ] 遍历:小框和前后文字在同一行里对齐、太长时整体换行;循环图标和「循环」块同色
- [ ] 筛选:筛选栏固定在预览顶部、和代码块左右对齐;下拉菜单盖在代码块上面;藏掉的块不留空位,左边层级竖线跟着变短
- [ ] 文件注释:悬停行的按钮贴在行尾、不挤掉名字;注释灰字紧跟名字、长了截断;顶部输入框居中,看得出是 VS Code 自带的样子
diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js
index 548b857..11c9367 100644
--- a/docs/prototype/code-reader/figma-plugin/code.js
+++ b/docs/prototype/code-reader/figma-plugin/code.js
@@ -50,6 +50,7 @@ const COLORS = {
gitModified: "#e2c08d", // gitDecoration.modifiedResourceForeground
gitAdded: "#81b88b", // gitDecoration.addedResourceForeground
gitDeleted: "#c74e39", // gitDecoration.deletedResourceForeground
+ changeAdded: "#2ea043", // editorGutter.addedBackground
code: "#d4d4d4",
comment: "#6a9955",
folder: "#8b9ba8",
@@ -177,22 +178,31 @@ const FILES = {
routes: { name: "routes.py", ext: "py", lang: "python", workspace: "luffy", crumbs: ["backend", "src", "luffy_agent", "user", "routes.py"] },
};
-// 代码块的种类:标签文字 + 颜色。同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),标签区分具体是哪种。
+// 代码块的种类:插件定义的图标 + 颜色 + 名称。块上只画图标,名称是鼠标停上去的提示。
+// 同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。图标取 lucide。
const BLOCK_KINDS = {
- import: { label: "导入", color: "#c586c0" },
- function: { label: "函数", color: "#dcdcaa" },
- type: { label: "类型", color: "#4ec9b0" },
- field: { label: "字段", color: "#9cdcfe" },
- const: { label: "常量", color: "#9cdcfe" },
- variable: { label: "变量", color: "#9cdcfe" },
- if: { label: "条件", color: "#ce9178" },
- loop: { label: "循环", color: "#8f9cff" },
- try: { label: "尝试", color: "#f48771" },
- catch: { label: "捕获", color: "#f48771" },
- return: { label: "返回", color: "#89d185" },
- statement: { label: "语句", color: "#9d9d9d" },
- comment: { label: "注释", color: "#6a9955" },
+ import: { label: "导入", color: "#c586c0", icon: "import" },
+ function: { label: "函数", color: "#dcdcaa", icon: "square-function" },
+ type: { label: "类型", color: "#4ec9b0", icon: "braces" },
+ field: { label: "字段", color: "#9cdcfe", icon: "tag" },
+ const: { label: "常量", color: "#9cdcfe", icon: "lock" },
+ variable: { label: "变量", color: "#9cdcfe", icon: "variable" },
+ if: { label: "条件", color: "#ce9178", icon: "split" },
+ loop: { label: "循环", color: "#8f9cff", icon: "repeat" },
+ try: { label: "尝试", color: "#f48771", icon: "shield" },
+ catch: { label: "捕获", color: "#f48771", icon: "shield-alert" },
+ return: { label: "返回", color: "#89d185", icon: "corner-down-left" },
+ statement: { label: "语句", color: "#9d9d9d", icon: "code" },
+ comment: { label: "注释", color: "#6a9955", icon: "message-square-text" },
};
+// 插件里还有、原型示例文件里没用到的种类,以及库规则带的种类(规则自己给图标、颜色、名称),图例里一起列出来
+const EXTRA_KINDS = [
+ { label: "类", color: "#4ec9b0", icon: "box" },
+ { label: "枚举", color: "#b180d7", icon: "list-ordered" },
+ { label: "异常", color: "#f14c4c", icon: "triangle-alert" },
+ { label: "否则", color: "#ce9178", icon: "corner-down-right" },
+ { label: "最终", color: "#f48771", icon: "shield-check" },
+];
// 代码块树。at:源码行号(单行,或 [起, 止]);kind:种类;kids:嵌套在它里面的块;
// edit:原型里默认停在「编辑」状态,用来展示两种样子。块里显示的译文由 translateBlock 按模板直译生成。
// 没落进任何块的行只能是空行或收尾括号,测试会检查。
@@ -694,6 +704,26 @@ async function chooseFonts(api) {
throw new Error(`没有找到可用的中文字体。请在 Figma 中启用 ${FONT_FAMILIES.join("、")} 之一后重试。`);
}
+// ─── 用时 ────────────────────────────────────────────────────────────────────
+//
+// 生成慢的时候看慢在哪:按名目累计用时,结束提示里列出最慢的组件 / 画板和各类操作。
+let clock = null;
+
+function timed(label, fn) {
+ if (!clock) return fn();
+ const start = Date.now();
+ try {
+ return fn();
+ }
+ finally {
+ clock.set(label, (clock.get(label) || 0) + Date.now() - start);
+ }
+}
+
+function measured(label, fn) {
+ return function (...args) { return timed(`操作 · ${label}`, () => fn.apply(this, args)); };
+}
+
/**
* Auto-layout container. With `width` that axis is fixed and the other hugs;
* without it both axes hug. o: dir / width / height / pad / px / py / gap /
@@ -748,13 +778,14 @@ function topBorder(node, color) {
* `width`: fixed width, grows in height (table columns).
* 不读父节点宽度:每读一次都会逼 Figma 把整棵自动布局树重排一遍。
*/
-function text(ctx, parent, name, characters, o = {}) {
+const text = measured("建文字层", function text(ctx, parent, name, characters, o = {}) {
const node = ctx.api.createText();
node.name = name;
- node.fontName = o.font || (o.mono && !CJK.test(characters) ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular);
+ const font = o.font || (o.mono ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular);
+ node.fontName = font;
node.fontSize = o.size || 13;
node.lineHeight = { unit: "PIXELS", value: o.lineHeight || Math.round(node.fontSize * 1.5) };
- node.characters = characters;
+ writeText(ctx, node, characters, font);
node.fills = paint(o.color || COLORS.fg);
if (o.align) node.textAlignHorizontal = o.align;
parent.appendChild(node);
@@ -774,18 +805,42 @@ function text(ctx, parent, name, characters, o = {}) {
node.maxLines = 1;
}
return node;
-}
+});
/** 等宽字体没有中文字形:连续的中文字符换成中文字体。 */
-function useCjkFont(ctx, node) {
- const run = /[\u3000-\u9fff\uff00-\uffef]+/g;
+const LATIN_RUN = /[^\u3000-\u9fff\uff00-\uffef]+/g;
+
+/** 把 [start, end) 这段设成等宽字体,只动中文以外的字:等宽字体里没有中文字形。 */
+function monoRange(ctx, node, characters, start, end) {
+ const part = characters.slice(start, end);
let match;
- while ((match = run.exec(node.characters))) node.setRangeFontName(match.index, match.index + match[0].length, ctx.fonts.regular);
+ LATIN_RUN.lastIndex = 0;
+ while ((match = LATIN_RUN.exec(part))) node.setRangeFontName(start + match.index, start + match.index + match[0].length, ctx.fonts.mono);
}
-function paintRanges(node, ranges) {
- for (const range of ranges) node.setRangeFills(range.start, range.end, paint(range.color));
-}
+/**
+ * 放字,新建的文字层和实例里改字都走这里。font 是这段字想要的字体。
+ * 等宽字体里没有中文字形:夹着中文的一段要是先用等宽字体放进去,Figma 得给每个中文去找替代字体,一个文字层就要几十毫秒;
+ * 所以整段先用中文字体放进去,再把中文以外的部分换回等宽字体。
+ */
+const writeText = measured("放字", function writeText(ctx, node, characters, font) {
+ const mixed = font === ctx.fonts.mono && font !== ctx.fonts.regular && CJK.test(characters);
+ node.fontName = mixed ? ctx.fonts.regular : font;
+ node.characters = characters;
+ if (mixed) monoRange(ctx, node, characters, 0, characters.length);
+});
+
+const paintRanges = measured("分段上色", function paintRanges(node, ranges, base) {
+ // 每次上色都是一次往返:同色相邻的片段(中间只隔空白也算)并成一次,和底色一样的不画
+ const characters = node.characters;
+ const merged = [];
+ for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
+ const last = merged[merged.length - 1];
+ if (last && last.color === range.color && !characters.slice(last.end, range.start).trim()) last.end = range.end;
+ else merged.push({ ...range });
+ }
+ for (const range of merged) if (range.color !== base && range.end > range.start) node.setRangeFills(range.start, range.end, paint(range.color));
+});
function divider(ctx, parent, o = {}) {
const node = ctx.api.createFrame();
@@ -832,6 +887,27 @@ const LUCIDE = {
"ellipsis": [["circle", { cx: "12", cy: "12", r: "1" }], ["circle", { cx: "19", cy: "12", r: "1" }], ["circle", { cx: "5", cy: "12", r: "1" }]],
"columns-2": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], ["path", { d: "M12 3v18" }]],
"x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
+ // 代码块种类的图标
+ "import": [["path", { d: "M12 3v12" }], ["path", { d: "m8 11 4 4 4-4" }], ["path", { d: "M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" }]],
+ "square-function": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }], ["path", { d: "M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3" }], ["path", { d: "M9 11.2h5.7" }]],
+ "braces": [["path", { d: "M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" }], ["path", { d: "M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" }]],
+ "box": [["path", { d: "M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" }], ["path", { d: "m3.3 7 8.7 5 8.7-5" }], ["path", { d: "M12 22V12" }]],
+ "tag": [["path", { d: "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" }], ["circle", { cx: "7.5", cy: "7.5", r: ".5", fill: "currentColor" }]],
+ "lock": [["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2", ry: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]],
+ "variable": [["path", { d: "M8 21s-4-3-4-9 4-9 4-9" }], ["path", { d: "M16 3s4 3 4 9-4 9-4 9" }], ["line", { x1: "15", x2: "9", y1: "9", y2: "15" }], ["line", { x1: "9", x2: "15", y1: "9", y2: "15" }]],
+ "split": [["path", { d: "M16 3h5v5" }], ["path", { d: "M8 3H3v5" }], ["path", { d: "M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" }], ["path", { d: "m15 9 6-6" }]],
+ "corner-down-right": [["path", { d: "m15 10 5 5-5 5" }], ["path", { d: "M4 4v7a4 4 0 0 0 4 4h12" }]],
+ "shield": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]],
+ "shield-alert": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }], ["path", { d: "M12 8v4" }], ["path", { d: "M12 16h.01" }]],
+ "shield-check": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }], ["path", { d: "m9 12 2 2 4-4" }]],
+ "corner-down-left": [["path", { d: "M20 4v7a4 4 0 0 1-4 4H4" }], ["path", { d: "m9 10-5 5 5 5" }]],
+ "code": [["path", { d: "m16 18 6-6-6-6" }], ["path", { d: "m8 6-6 6 6 6" }]],
+ "database": [["ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }], ["path", { d: "M3 5V19A9 3 0 0 0 21 19V5" }], ["path", { d: "M3 12A9 3 0 0 0 21 12" }]],
+ "list-ordered": [["path", { d: "M11 5h10" }], ["path", { d: "M11 12h10" }], ["path", { d: "M11 19h10" }], ["path", { d: "M4 4h1v5" }], ["path", { d: "M4 9h2" }], ["path", { d: "M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02" }]],
+ "server-cog": [["path", { d: "m10.852 14.772-.383.923" }], ["path", { d: "M13.148 14.772a3 3 0 1 0-2.296-5.544l-.383-.923" }], ["path", { d: "m13.148 9.228.383-.923" }], ["path", { d: "m13.53 15.696-.382-.924a3 3 0 1 1-2.296-5.544" }], ["path", { d: "m14.772 10.852.923-.383" }], ["path", { d: "m14.772 13.148.923.383" }], ["path", { d: "M4.5 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-.5" }], ["path", { d: "M4.5 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-.5" }], ["path", { d: "M6 18h.01" }], ["path", { d: "M6 6h.01" }], ["path", { d: "m9.228 10.852-.923-.383" }], ["path", { d: "m9.228 13.148-.923.383" }]],
+ "calendar-clock": [["path", { d: "M16 14v2.2l1.6 1" }], ["path", { d: "M16 2v4" }], ["path", { d: "M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" }], ["path", { d: "M3 10h5" }], ["path", { d: "M8 2v4" }], ["circle", { cx: "16", cy: "16", r: "6" }]],
+ "globe": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }], ["path", { d: "M2 12h20" }]],
+ "package": [["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }], ["path", { d: "M12 22V12" }], ["polyline", { points: "3.29 7 12 12 20.71 7" }], ["path", { d: "m7.5 4.27 9 5.15" }]],
"git-pull-request": [["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["path", { d: "M6 9v12" }]],
"files": [["path", { d: "M20 7h-3a2 2 0 0 1-2-2V2" }], ["path", { d: "M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z" }], ["path", { d: "M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8" }]],
"search": [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]],
@@ -845,15 +921,17 @@ const LUCIDE = {
"settings": [["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]],
};
-function icon(ctx, name, color, size, o = {}) {
+const icon = measured("画 SVG 图标", function icon(ctx, name, color, size, o = {}) {
const shapes = LUCIDE[name];
if (!shapes) throw new Error(`未收录的图标: ${name}`);
const body = shapes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${value}"`).join(" ")}/>`).join("");
- const svg = ``;
+ // pad:四周留白(单位同 24 格的画布),图标框不变、图形缩小,和一行字等高又不显得挤
+ const pad = o.pad || 0;
+ const svg = ``;
const node = ctx.api.createNodeFromSvg(svg);
node.name = o.name || `Icon / ${name}`;
return node;
-}
+});
function folderGlyph(ctx) {
const node = ctx.api.createNodeFromSvg(``);
@@ -862,7 +940,7 @@ function folderGlyph(ctx) {
}
/** Combine components into a variant set and lay them out in a grid. */
-function componentSet(ctx, page, name, components, columns) {
+const componentSet = measured("拼成组件集", function componentSet(ctx, page, name, components, columns) {
for (const component of components) page.appendChild(component);
const set = ctx.api.combineAsVariants(components, page);
set.name = name;
@@ -880,7 +958,7 @@ function componentSet(ctx, page, name, components, columns) {
}
set.resize(columns * (width + gap) + gap, y);
return set;
-}
+});
/**
* Click wiring. NAVIGATE: only to a top-level frame on the page, never back
@@ -896,11 +974,11 @@ async function connect(source, destination, navigation) {
}]);
}
-function named(root, name) {
+const named = measured("按名字找图层", function named(root, name) {
const node = root.findOne((item) => item.name === name);
if (!node) throw new Error(`找不到图层: ${name}`);
return node;
-}
+});
function setText(root, name, characters) {
named(root, name).characters = characters;
@@ -923,7 +1001,7 @@ const SQLA_MODEL = {
],
indexes: [{ name: "ix_referral_relations_inviter_bind_time", columns: "inviter_user_id, bind_time" }],
};
-const TABLE_KIND = { label: "数据表", color: "#e8a860" };
+const TABLE_KIND = { label: "数据表", color: "#e8a860", icon: "database", rule: "SQLAlchemy" };
/** 约束一格:唯一 · 索引 · 外键 sys_user.id(删除时禁止)· 默认 …;「非空」是常态不写,可空才写。 */
function constraintSegments(constraints) {
@@ -969,7 +1047,7 @@ const ENUM_SAMPLE = {
members: [["PENDING", '"pending"', "待结算"], ["AVAILABLE", '"available"', "可提现"], ["WITHDRAWING", '"withdrawing"', "提现中"],
["WITHDRAWN", '"withdrawn"', "已提现"], ["INVALID", '"invalid"', "已失效"]],
};
-const ENUM_KIND = { label: "枚举", color: "#b180d7" };
+const ENUM_KIND = EXTRA_KINDS.find((kind) => kind.label === "枚举");
/** Reader / EnumCard — State=preview:枚举卡片。 */
function enumCard(ctx) {
@@ -993,8 +1071,8 @@ function enumCard(ctx) {
// 文件导入了 celery 才启用。两张卡片:创建应用(X = Celery(...))和定时任务表(X.conf.beat_schedule = {...});
// 改配置、自动发现任务这些语句照旧。示例:luffy 的 backend/src/luffy_agent/celery_app.py。
const CELERY_APP = { name: "celery_app", app: '"luffy_agent"', broker: "settings.CELERY_BROKER_URL", backend: "settings.CELERY_RESULT_BACKEND" };
-const CELERY_APP_KIND = { label: "Celery 应用", color: "#98c379", badgeWidth: 76 };
-const BEAT_KIND = { label: "定时任务", color: "#56b6c2", badgeWidth: 60 };
+const CELERY_APP_KIND = { label: "Celery 应用", color: "#98c379", icon: "server-cog", rule: "Celery" };
+const BEAT_KIND = { label: "定时任务", color: "#56b6c2", icon: "calendar-clock", rule: "Celery" };
// 频率按调度表达式换算成人话:秒数算式 → 每 N 秒 / 分钟 / 小时 / 天,crontab → 每天 hh:mm 这类;换算不了的照原样。
const BEAT_SCHEDULE = {
app: "celery_app",
@@ -1041,6 +1119,79 @@ function beatCard(ctx) {
return node;
}
+// ─── 库规则:FastAPI 接口 ────────────────────────────────────────────────────
+//
+// 文件导入了 fastapi 才启用。挂在路由对象上的函数(`@router.get(...)` 这类装饰器,router 是 APIRouter / FastAPI)整块换成「接口」卡片,函数体照常是子块。
+// 卡片:名称、说明、标签、异步、依赖(Depends(...) 注入的参数,不是请求带的),然后请求、响应各一张表:
+// - 请求:一项一行。URL(路由对象的 prefix + 装饰器里的路径,{参数} 单独上色)、方法、路径参数、查询参数、请求体(模型名);没有的项不列。
+// 参数的位置照 FastAPI 的规则认:路径里有同名的 = 路径参数,Query / Header / Cookie 照写法,其余简单类型 = 查询参数,Pydantic 模型 = 请求体。
+// - 响应:每个状态码一行(状态码 / 内容 / 说明)。成功那行 = status_code(没写就是 200)+ response_model(没写就用返回类型标注);
+// 装饰器 responses={…} 里写的各占一行;函数体里直接抛的异常按异常类上的 status_code 归到对应状态码,说明列出抛出时的消息;
+// 有参数或请求体时 FastAPI 自动带 422。
+// 挂到应用上时再叠的前缀(include_router、settings.API_V1_PREFIX)在别的文件里,URL 不算。
+// 示例:luffy 的 user/routes.py 的 update_user;Forbidden 在 core/exceptions/schemas.py,status_code = 403。
+const ROUTE_KIND = { label: "接口", color: "#61afef", icon: "globe", rule: "FastAPI" };
+const DATACLASS_KIND = { label: "数据类", color: "#ef7fb0", icon: "package", rule: "dataclasses" };
+// Swagger UI 的方法配色,看接口文档的人最熟
+const HTTP_METHOD_COLORS = { GET: "#61affe", POST: "#49cc90", PUT: "#fca130", PATCH: "#50e3c2", DELETE: "#f93e3e" };
+const ROUTE_SAMPLE = {
+ name: "update_user", doc: "更新用户信息。", tags: ["用户管理"], async: true,
+ deps: [["db", "get_db"], ["current_user", "get_current_user"]],
+ method: "PUT", url: "/users/{user_id}",
+ pathParams: [["user_id", "Int4Path"]],
+ body: "UserUpdateRequest",
+ // 状态码 / 内容 / 说明;auto:FastAPI 自动加的
+ responses: [
+ { status: 200, content: "UserDetailResponse", note: "成功" },
+ { status: 403, content: "Forbidden", note: "无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码" },
+ { status: 422, content: "参数校验失败", note: "FastAPI 自动返回", auto: true },
+ ],
+};
+
+/** 路径的着色片段:{参数} 按变量色。 */
+function pathSegments(path) {
+ return path.split(/(\{[^}]+\})/).filter(Boolean).map((part) => ({ text: part, color: part.startsWith("{") ? COLORS.tokenVariable : COLORS.tokenString }));
+}
+
+/** 「名称: 类型」一串,逗号隔开。 */
+const paramSegments = (params) => params.flatMap(([name, type], index) => [
+ ...(index ? [{ text: ", " }] : []),
+ { text: name, color: COLORS.tokenVariable }, { text: ": " }, ...typeSegments(type, "python"),
+]);
+
+/** Reader / RouteCard — State=preview:FastAPI 规则认出的接口卡片,请求、响应各一张表。 */
+function routeCard(ctx) {
+ const route = ROUTE_SAMPLE;
+ const { node, content } = cardShell(ctx, "State=preview", ROUTE_KIND, { kids: true });
+ propRow(ctx, content, "名称", [{ text: route.name, color: COLORS.tokenFunction }]);
+ propRow(ctx, content, "说明", [{ text: route.doc, color: COLORS.comment }]);
+ propRow(ctx, content, "标签", [{ text: route.tags.join("、"), color: COLORS.tokenString }]);
+ propRow(ctx, content, "异步", yesNo(route.async));
+ propRow(ctx, content, "依赖", route.deps.flatMap(([param, fn], index) => [
+ ...(index ? [{ text: "、", color: COLORS.dim }] : []),
+ { text: fn, color: COLORS.tokenFunction },
+ ...(param === "_" ? [] : [{ text: `(${param})`, color: COLORS.desc }]),
+ ]));
+ const request = [
+ ["URL", pathSegments(route.url)],
+ ["方法", [{ text: route.method, color: HTTP_METHOD_COLORS[route.method] }]],
+ ...(route.pathParams.length ? [["路径参数", paramSegments(route.pathParams)]] : []),
+ ...(route.body ? [["请求体", [{ text: route.body, color: COLORS.tokenType }]]] : []),
+ ];
+ propRow(ctx, content, "请求", [{ text: `${route.method} ${route.url}`, color: COLORS.desc }], { twisty: true });
+ propTable(ctx, content, "Request", [["项", 120], ["值", 0]], request.map(([key, value]) => [`Request / ${key}`, [
+ [{ text: key, color: COLORS.desc }],
+ value,
+ ]]));
+ propRow(ctx, content, "响应", [{ text: route.responses.map((response) => response.status).join(" / "), color: COLORS.desc }], { twisty: true });
+ propTable(ctx, content, "Response", [["状态码", 120], ["内容", 260], ["说明", 0]], route.responses.map((response) => [`Status / ${response.status}`, [
+ [{ text: String(response.status), color: response.status < 400 ? COLORS.changeAdded : COLORS.tokenString }],
+ [{ text: response.content, color: response.auto ? COLORS.desc : COLORS.tokenType }],
+ [{ text: response.note, color: response.auto ? COLORS.desc : COLORS.comment }],
+ ]]));
+ return node;
+}
+
// ─── 问 AI ───────────────────────────────────────────────────────────────────
//
// 块右上角「问 AI」→ 块下面展开对话:历史问答灰字在上(问题 + 回答前两行,右边时间和删除),
@@ -1086,18 +1237,17 @@ function askPanel(ctx, state) {
text(ctx, head, "Question", ASK_SAMPLE.question, { size: 12.5, lineHeight: 18, medium: true, color: COLORS.fgStrong });
const body = filled(ctx, node, "Current answer", {});
body.paddingLeft = 18;
- const answer = text(ctx, body, "Answer", ASK_SAMPLE.answer.map(([part]) => part.replace(/`/g, "")).join("") + " ▍",
- { size: 12.5, lineHeight: 20, color: COLORS.fg });
+ const answerText = ASK_SAMPLE.answer.map(([part]) => part.replace(/`/g, "")).join("") + " ▍";
+ const answer = text(ctx, body, "Answer", answerText, { size: 12.5, lineHeight: 20, color: COLORS.fg });
let start = 0;
for (const [part, style] of ASK_SAMPLE.answer) {
const plain = part.replace(/`/g, "");
if (style === "code") {
- answer.setRangeFontName(start, start + plain.length, ctx.fonts.mono);
+ monoRange(ctx, answer, answerText, start, start + plain.length);
answer.setRangeFills(start, start + plain.length, paint(COLORS.tokenFunction));
}
start += plain.length;
}
- useCjkFont(ctx, answer);
const field = filled(ctx, node, "Input", { dir: "HORIZONTAL", px: 8, py: 5, gap: 6, radius: 4, align: "CENTER", fill: COLORS.input, stroke: COLORS.focus });
text(ctx, field, "Placeholder", "继续问… Enter 发送,Esc 收起", { size: 12.5, lineHeight: 18, color: COLORS.dim });
append(field, icon(ctx, "send", COLORS.desc, 14));
@@ -1207,10 +1357,9 @@ function filterBar(ctx, active) {
text(ctx, label, "Text", "筛选", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
for (const facet of facets.keys()) {
const on = facet === preset.facet;
- const chip = append(node, frame(ctx, `Chip / ${facet}`, { dir: "HORIZONTAL", width: CHIP_W, height: 24, px: 8, gap: 4, radius: 4,
- align: "CENTER", fill: COLORS.input, stroke: on ? COLORS.focus : COLORS.borderStrong }));
- text(ctx, chip, "Text", on ? preset.chip : facet, { size: 12, lineHeight: 16, singleLine: true, color: on ? COLORS.fgStrong : COLORS.fg });
- append(chip, icon(ctx, "chevron-down", COLORS.desc, 12));
+ const chip = append(node, ctx.lib.filterChip.get(on ? "active" : "default").createInstance());
+ chip.name = `Chip / ${facet}`;
+ setText(chip, "Text", on ? preset.chip : facet);
}
append(node, frame(ctx, "Spacer", { width: 10, height: 1 }), true);
const hidden = filteredOut(FILTER_FILE, preset.allowed).length;
@@ -1239,6 +1388,16 @@ function filterBar(ctx, active) {
return node;
}
+/** Reader / FilterChip — State=default | active:筛选栏里一个属性的下拉框,文字在实例里改;打开着的那个描蓝边。 */
+function filterChip(ctx, state) {
+ const on = state === "active";
+ const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", width: CHIP_W, height: 24, px: 8, gap: 4, radius: 4,
+ align: "CENTER", fill: COLORS.input, stroke: on ? COLORS.focus : COLORS.borderStrong });
+ text(ctx, node, "Text", "属性", { size: 12, lineHeight: 16, singleLine: true, color: on ? COLORS.fgStrong : COLORS.fg });
+ append(node, icon(ctx, "chevron-down", COLORS.desc, 12));
+ return node;
+}
+
// ─── Components: 代码块 ──────────────────────────────────────────────────────
/** 哪些种类的块画成属性表,以及各自的行。 */
@@ -1268,12 +1427,9 @@ function codeBlock(ctx, kind, state) {
if (comment) node.dashPattern = [4, 3];
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" }));
- const badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: 44, height: 20, radius: 4, align: "CENTER", justify: "CENTER",
- fill: spec.color, fillOpacity: 0.18 }));
- text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color });
+ kindBadge(ctx, node, spec);
const content = append(node, frame(ctx, "Content", { width: 10 }), true);
const plain = text(ctx, content, "Plain", "导入 { xx } 来自 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: comment ? COLORS.desc : COLORS.fg });
- useCjkFont(ctx, plain);
const code = text(ctx, content, "Code", "import { xx } from 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20,
color: comment ? COLORS.comment : COLORS.code });
plain.visible = !editing;
@@ -1282,6 +1438,11 @@ function codeBlock(ctx, kind, state) {
return { node, toggle };
}
+/** 块左边的种类:只画图标(和一行字等高),名称是鼠标停上去的提示。 */
+function kindBadge(ctx, parent, spec) {
+ return append(parent, icon(ctx, spec.icon, spec.color, 20, { name: `Kind / ${spec.label}`, pad: 3, weight: 2.2 }));
+}
+
/** 块右上角放一个 Reader / BlockTools 实例,返回里面的切换按钮,演示里连 CHANGE_TO。 */
function blockTools(ctx, node, editing) {
const tools = ctx.lib.tools.get(editing ? "edit" : "preview").createInstance();
@@ -1334,13 +1495,12 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Lev
append(list, instance, true);
const plain = named(instance, "Plain");
const translated = translateBlock(fileKey, item);
- plain.characters = translated.text;
+ writeText(ctx, plain, translated.text, ctx.fonts.mono);
paintRanges(plain, translated.ranges);
- useCjkFont(ctx, plain);
const code = named(instance, "Code");
- code.characters = blockCode(fileKey, item.at);
- paintRanges(code, highlightCode(code.characters));
- useCjkFont(ctx, code);
+ const source = blockCode(fileKey, item.at);
+ writeText(ctx, code, source, ctx.fonts.mono);
+ paintRanges(code, highlightCode(source));
if (!item.kids) named(instance, "Fold icon").visible = false;
placed.push({ item, instance, depth });
if (item.kids) blockLevel(ctx, list, lib, fileKey, item.kids, depth + 1, placed, `Children / L${line}`);
@@ -1352,13 +1512,14 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Lev
function richText(ctx, parent, name, segments, o = {}) {
const node = text(ctx, parent, name, segments.map((segment) => segment.text).join("") || " ",
{ font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: o.color || COLORS.fg, width: o.width, hug: o.hug });
+ const ranges = [];
let start = 0;
for (const segment of segments) {
const end = start + segment.text.length;
- if (segment.color && end > start) node.setRangeFills(start, end, paint(segment.color));
+ if (segment.color) ranges.push({ start, end, color: segment.color });
start = end;
}
- useCjkFont(ctx, node);
+ paintRanges(node, ranges, o.color || COLORS.fg);
return node;
}
@@ -1502,9 +1663,7 @@ function cardShell(ctx, name, spec, o = {}) {
node.paddingRight = 10;
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
if (o.kids) append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" }));
- const badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: spec.badgeWidth || 44, height: 20, radius: 4, align: "CENTER",
- justify: "CENTER", fill: spec.color, fillOpacity: 0.18 }));
- text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color });
+ kindBadge(ctx, node, spec);
const content = append(node, frame(ctx, "Content", { width: 10, gap: 3 }), true);
const toggle = blockTools(ctx, node, o.editing);
return { node, content, toggle };
@@ -1746,15 +1905,14 @@ function statusBar(ctx, mode) {
return node;
}
-/** Editor / TitleBar — File=<文件>, Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换 */
-function editorTitle(ctx, lib, fileKey, mode) {
- const file = FILES[fileKey];
- const node = frame(ctx, `File=${fileKey}, Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W,
+/** Editor / TitleBar — Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换;标签页上的文件名和扩展名在实例里改。 */
+function editorTitle(ctx, lib, mode) {
+ const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W,
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
const tab = append(node, frame(ctx, "Tab", { dir: "HORIZONTAL", px: 12, py: 9, gap: 6, align: "CENTER", fill: COLORS.editor }));
topBorder(tab, COLORS.focus);
- text(ctx, tab, "Ext", EXT_LABEL[file.ext], { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS[file.ext] });
- text(ctx, tab, "Name", file.name, { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong });
+ text(ctx, tab, "Ext", EXT_LABEL.ts, { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS.ts });
+ text(ctx, tab, "Name", "file.ts", { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong });
append(tab, icon(ctx, "x", COLORS.fg, 14));
const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" }));
actions.paddingRight = 10;
@@ -1781,11 +1939,11 @@ function breadcrumb(ctx, fileKey) {
function createComponents(ctx, page) {
const sets = [];
const lib = {};
- const make = (name, entries, columns, build, node = (item) => item) => {
+ const make = (name, entries, columns, build, node = (item) => item) => timed(name, () => {
const map = new Map(entries.map((entry) => [entry, build(entry)]));
sets.push(componentSet(ctx, page, name, [...map.values()].map(node), columns));
return map;
- };
+ });
const fileKeys = Object.keys(FILES);
ctx.lib = lib;
lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode));
@@ -1797,8 +1955,10 @@ function createComponents(ctx, page) {
lib.enumCard = make("Reader / EnumCard", ["preview"], 1, () => enumCard(ctx));
lib.celeryApp = make("Reader / CeleryAppCard", ["preview"], 1, () => celeryAppCard(ctx));
lib.beat = make("Reader / BeatCard", ["preview"], 1, () => beatCard(ctx));
+ lib.route = make("Reader / RouteCard", ["preview"], 1, () => routeCard(ctx));
lib.iteration = make("Reader / IterationBlock", ITERATION_SAMPLES.map((sample) => sample.key), 3,
(key) => iterationBlock(ctx, ITERATION_SAMPLES.find((sample) => sample.key === key)));
+ lib.filterChip = make("Reader / FilterChip", ["default", "active"], 2, (state) => filterChip(ctx, state));
lib.filterBar = make("Reader / FilterBar", ["none", "access", "kind"], 3, (active) => filterBar(ctx, active));
lib.row = make("Explorer / TreeRow", ROW_VARIANTS.map(([type, state]) => `${type}/${state}`), 2,
(entry) => treeRow(ctx, ...entry.split("/")));
@@ -1809,8 +1969,7 @@ function createComponents(ctx, page) {
lib.windowTitle = make("Window / TitleBar", ["active"], 1, () => windowTitle(ctx));
lib.activity = make("Window / ActivityBar", ["reader"], 1, () => activityBar(ctx));
lib.status = make("Window / StatusBar", MODES, 1, (mode) => statusBar(ctx, mode));
- lib.editorTitle = make("Editor / TitleBar", fileKeys.flatMap((fileKey) => MODES.map((mode) => `${fileKey}/${mode}`)), 2,
- (entry) => editorTitle(ctx, lib, ...entry.split("/")));
+ lib.editorTitle = make("Editor / TitleBar", MODES, 2, (mode) => editorTitle(ctx, lib, mode));
lib.breadcrumb = make("Editor / Breadcrumb", fileKeys, 1, (fileKey) => breadcrumb(ctx, fileKey));
lib.card = new Map();
const setNames = new Set();
@@ -1818,12 +1977,14 @@ function createComponents(ctx, page) {
walkTree(nodes, (item, depth) => {
if (!GRID_KINDS[item.kind]) return;
const line = lineRange(item.at)[0];
- const built = new Map(["preview", "edit"].map((state) => [state, gridCard(ctx, fileKey, item, state, depth === 0)]));
- let name = `${GRID_KINDS[item.kind].set} / ${built.get("preview").title}`;
- if (setNames.has(name)) name += ` · L${line}`;
- setNames.add(name);
- sets.push(componentSet(ctx, page, name, [...built.values()].map((card) => card.node), 2));
- lib.card.set(`${fileKey}:${line}`, built);
+ timed("属性表卡片(函数 / 导入 / 常量 / 变量,共 36 套)", () => {
+ const built = new Map(["preview", "edit"].map((state) => [state, gridCard(ctx, fileKey, item, state, depth === 0)]));
+ let name = `${GRID_KINDS[item.kind].set} / ${built.get("preview").title}`;
+ if (setNames.has(name)) name += ` · L${line}`;
+ setNames.add(name);
+ sets.push(componentSet(ctx, page, name, [...built.values()].map((card) => card.node), 2));
+ lib.card.set(`${fileKey}:${line}`, built);
+ });
});
}
lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node);
@@ -1925,7 +2086,10 @@ function sidebarContent(ctx, pane, lib, selected, o) {
return rows;
}
-/** 编辑模式:VS Code 原生编辑器,整份源码。 */
+// 整份源码按这么多行拆成一段段文字:往一段长文字上色,每次都要把整段重排一遍,拆短了上色快得多;段与段紧挨着,看上去还是一整块
+const SOURCE_CHUNK_LINES = 8;
+
+/** 编辑模式:VS Code 原生编辑器,整份源码。上色按整份源码算一次(多行注释跨段也对),再切给各段。 */
function codeView(ctx, editor, fileKey) {
const view = filled(ctx, editor, "Code view", { dir: "HORIZONTAL", py: 4, gap: 22 });
view.layoutSizingVertical = "FILL";
@@ -1934,9 +2098,19 @@ function codeView(ctx, editor, fileKey) {
const lines = SOURCES[fileKey].map((line) => line || " ");
const numbers = append(view, frame(ctx, "Line numbers", { width: 48 }));
text(ctx, numbers, "No", lines.map((_, index) => String(index + 1)).join("\n"), { size: 13, lineHeight: 19, mono: true, align: "RIGHT", color: COLORS.dim });
- const source = text(ctx, view, "Source", lines.join("\n"), { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code });
- paintRanges(source, highlightCode(source.characters));
- useCjkFont(ctx, source);
+ const whole = lines.join("\n");
+ const ranges = highlightCode(whole);
+ const source = append(view, frame(ctx, "Source", {}));
+ let offset = 0;
+ for (let first = 0; first < lines.length; first += SOURCE_CHUNK_LINES) {
+ const chunk = lines.slice(first, first + SOURCE_CHUNK_LINES).join("\n");
+ const end = offset + chunk.length;
+ const node = text(ctx, source, `Lines ${first + 1}-${first + SOURCE_CHUNK_LINES > lines.length ? lines.length : first + SOURCE_CHUNK_LINES}`, chunk,
+ { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code });
+ paintRanges(node, ranges.filter((range) => range.end > offset && range.start < end)
+ .map((range) => ({ start: Math.max(range.start, offset) - offset, end: Math.min(range.end, end) - offset, color: range.color })));
+ offset = end + 1;
+ }
return view;
}
@@ -1954,6 +2128,10 @@ function previewView(ctx, editor, lib, fileKey, editorWidth) {
/** 一整个 VS Code 窗口:标题栏 / 活动栏 + 侧栏 + 编辑器 / 状态栏。 */
function windowBoard(ctx, page, lib, name, o) {
+ return timed(`Prototype / ${name}`, () => windowBoardContent(ctx, page, lib, name, o));
+}
+
+function windowBoardContent(ctx, page, lib, name, o) {
const width = o.width || WINDOW_W;
const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1;
const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor });
@@ -1972,8 +2150,13 @@ function windowBoard(ctx, page, lib, name, o) {
divider(ctx, main, { vertical: true });
const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor }));
editor.layoutSizingVertical = "FILL";
- const titleBar = lib.editorTitle.get(`${o.file}/${o.mode}`).createInstance();
+ const titleBar = lib.editorTitle.get(o.mode).createInstance();
titleBar.name = "Editor title";
+ const file = FILES[o.file];
+ setText(titleBar, "Name", file.name);
+ const ext = named(titleBar, "Ext");
+ ext.characters = EXT_LABEL[file.ext];
+ ext.fills = paint(COLORS[file.ext]);
append(editor, titleBar, true);
const crumbs = lib.breadcrumb.get(o.file).createInstance();
crumbs.name = "Breadcrumb";
@@ -2062,9 +2245,38 @@ function tooltip(ctx, screen, o, rows) {
return node;
}
+/**
+ * 块种类图例:插件定义的每种块一个图标、颜色、名称;块上只画图标,鼠标停在图标上显示名称。
+ * 库规则带的种类由规则自己给,后面淡色写是哪条规则。最上面是一块「返回」停着鼠标的样子。
+ */
+function kindLegend(ctx, page, lib) {
+ const board = frame(ctx, "Prototype / 块种类图标", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 6, fill: COLORS.editor });
+ page.appendChild(board);
+ const demo = filled(ctx, board, "Hover demo", {});
+ demo.paddingBottom = 34;
+ const block = lib.block.get("return/preview").node.createInstance();
+ block.name = "Block / 返回";
+ append(demo, block, true);
+ setText(block, "Plain", "返回 结果");
+ const tip = frame(ctx, "Tooltip", { px: 8, py: 4, radius: 4, fill: COLORS.widget, stroke: COLORS.widgetBorder });
+ text(ctx, tip, "Text", BLOCK_KINDS.return.label, { hug: true, size: 12, lineHeight: 16, color: COLORS.fgStrong });
+ demo.appendChild(tip);
+ tip.layoutPositioning = "ABSOLUTE";
+ tip.x = 26;
+ tip.y = 34;
+ const rule = [TABLE_KIND, DATACLASS_KIND, CELERY_APP_KIND, BEAT_KIND, ROUTE_KIND];
+ for (const spec of [...Object.values(BLOCK_KINDS), ...EXTRA_KINDS, ...rule]) {
+ const row = filled(ctx, board, `Kind row / ${spec.label}`, { dir: "HORIZONTAL", gap: 10, align: "CENTER" });
+ kindBadge(ctx, row, spec);
+ text(ctx, row, "Label", spec.rule ? `${spec.label} 库规则 · ${spec.rule}` : spec.label, { hug: true, size: 13, lineHeight: 20, color: spec.color });
+ }
+ return board;
+}
+
// ─── Entry ───────────────────────────────────────────────────────────────────
async function build(api, stage = () => {}) {
+ clock = new Map();
stage("加载中文字体");
const ctx = { api, fonts: await chooseFonts(api) };
stage("新建独立页面");
@@ -2104,10 +2316,9 @@ async function build(api, stage = () => {}) {
append(askBoard, block, true);
const translated = translateBlock("routes", item);
const plain = named(block, "Plain");
- plain.characters = translated.text;
+ writeText(ctx, plain, translated.text, ctx.fonts.mono);
paintRanges(plain, translated.ranges);
- useCjkFont(ctx, plain);
- named(block, "Code").characters = blockCode("routes", item.at);
+ writeText(ctx, named(block, "Code"), blockCode("routes", item.at), ctx.fonts.mono);
named(block, "Fold icon").visible = Boolean(item.kids);
if (state) {
const panel = lib.askPanel.get(state).createInstance();
@@ -2132,6 +2343,12 @@ async function build(api, stage = () => {}) {
instance.name = name;
append(celeryBoard, instance, true);
}
+ const routeBoard = frame(ctx, "Prototype / 库规则 · FastAPI 接口", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor });
+ page.appendChild(routeBoard);
+ const routeInstance = lib.route.get("preview").createInstance();
+ routeInstance.name = `Block / ${ROUTE_SAMPLE.name}`;
+ append(routeBoard, routeInstance, true);
+ const kindBoard = kindLegend(ctx, page, lib);
const iteration = frame(ctx, "Prototype / 遍历 · 推导式", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor });
page.appendChild(iteration);
for (const sample of ITERATION_SAMPLES) {
@@ -2154,48 +2371,52 @@ async function build(api, stage = () => {}) {
};
stage("连接演示入口");
+ // 连线各自独立,一起发出去再等,不一条条排队等 Figma 回话
+ const wires = [];
+ const link = (...args) => { wires.push(connect(...args)); };
// 代码块:「编辑」换成源码,「预览」换回译文
for (const built of lib.card.values()) {
- await connect(built.get("preview").toggle, built.get("edit").node, "CHANGE_TO");
- await connect(built.get("edit").toggle, built.get("preview").node, "CHANGE_TO");
+ link(built.get("preview").toggle, built.get("edit").node, "CHANGE_TO");
+ link(built.get("edit").toggle, built.get("preview").node, "CHANGE_TO");
}
for (const kind of PLAIN_KINDS) {
const preview = lib.block.get(`${kind}/preview`);
const edit = lib.block.get(`${kind}/edit`);
- await connect(preview.toggle, edit.node, "CHANGE_TO");
- await connect(edit.toggle, preview.node, "CHANGE_TO");
+ link(preview.toggle, edit.node, "CHANGE_TO");
+ link(edit.toggle, preview.node, "CHANGE_TO");
}
// 窗口之间:右上角切编辑 / 预览;侧栏点别的文件就用编辑器打开它
for (const [fileKey, pair] of Object.entries(boards)) {
- await connect(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE");
- await connect(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE");
+ link(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE");
+ link(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE");
for (const view of [pair.edit, pair.preview]) {
for (const [other, target] of Object.entries(boards)) {
- if (other !== fileKey && view.rows.has(other)) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE");
+ if (other !== fileKey && view.rows.has(other)) link(view.rows.get(other), target.edit.screen, "NAVIGATE");
}
}
}
// 文件注释:悬停出的「添加注释」→ 顶部输入框,输入框里回车 → 保存后侧栏显示注释
- await connect(named(notes.hover.rows.get("hover"), "Action / 添加注释"), notes.typing.screen, "NAVIGATE");
- await connect(named(notes.typing.quickInput, "Field"), notes.saved.screen, "NAVIGATE");
- await connect(named(notes.saved.rows.get("hover"), "Action / 复制"), notes.copy.screen, "NAVIGATE");
+ link(named(notes.hover.rows.get("hover"), "Action / 添加注释"), notes.typing.screen, "NAVIGATE");
+ link(named(notes.typing.quickInput, "Field"), notes.saved.screen, "NAVIGATE");
+ link(named(notes.saved.rows.get("hover"), "Action / 复制"), notes.copy.screen, "NAVIGATE");
// 筛选:默认栏里点「访问级别」「种类」打开各自的下拉菜单;「清除」回到不筛
- await connect(named(filters.none.filter, "Chip / 访问级别"), filters.access.screen, "NAVIGATE");
- await connect(named(filters.none.filter, "Chip / 种类"), filters.kind.screen, "NAVIGATE");
- await connect(named(filters.access.filter, "Clear"), filters.none.screen, "NAVIGATE");
- await connect(named(filters.kind.filter, "Clear"), filters.none.screen, "NAVIGATE");
+ link(named(filters.none.filter, "Chip / 访问级别"), filters.access.screen, "NAVIGATE");
+ link(named(filters.none.filter, "Chip / 种类"), filters.kind.screen, "NAVIGATE");
+ link(named(filters.access.filter, "Clear"), filters.none.screen, "NAVIGATE");
+ link(named(filters.kind.filter, "Clear"), filters.none.screen, "NAVIGATE");
// 审查工单:侧栏「选工单」→ 选工单弹框,点工单 → 树里只剩 PR 改过的文件;「显示全部」→ 退出审查
- await connect(named(boards.routes.preview.sidebar, "Action / 选工单"), review.pick.screen, "NAVIGATE");
- await connect(named(review.pick.picker, `Issue / #${REVIEW.issue.number}`), review.files.screen, "NAVIGATE");
- await connect(named(review.files.sidebar, "Action / 显示全部"), boards.routes.preview.screen, "NAVIGATE");
+ link(named(boards.routes.preview.sidebar, "Action / 选工单"), review.pick.screen, "NAVIGATE");
+ link(named(review.pick.picker, `Issue / #${REVIEW.issue.number}`), review.files.screen, "NAVIGATE");
+ link(named(review.files.sidebar, "Action / 显示全部"), boards.routes.preview.screen, "NAVIGATE");
+ await Promise.all(wires);
stage("排布画板");
const screens = [...Object.values(boards).flatMap((pair) => [pair.edit.screen, pair.preview.screen]), narrow.screen,
...Object.values(notes).map((board) => board.screen), ...Object.values(filters).map((board) => board.screen),
- ...Object.values(review).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard, askBoard];
+ ...Object.values(review).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard, routeBoard, kindBoard, askBoard];
let y = 0;
for (const screen of screens) {
screen.x = 0;
@@ -2211,19 +2432,35 @@ async function build(api, stage = () => {}) {
const start = boards.envLock.preview.screen;
page.flowStartingPoints = [{ nodeId: start.id, name: "预览 envLock.ts" }, { nodeId: notes.hover.screen.id, name: "文件注释" },
{ nodeId: filters.none.screen.id, name: "筛选" }, { nodeId: boards.routes.preview.screen.id, name: "审查工单" }];
- page.selection = [start];
- api.viewport.scrollAndZoomIntoView(page.selection);
- return { page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, askBoard, screens };
+ // 不选中任何画板(选中会把它在图层面板里层层展开),顶层全部收起
+ for (const node of page.children) node.expanded = false;
+ api.viewport.scrollAndZoomIntoView([start]);
+ return { clock, page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, routeBoard, kindBoard, askBoard, screens };
+}
+
+/** 各阶段用时,写进结束提示,慢了看得出慢在哪一步。 */
+function timingText(marks, end, spent) {
+ const seconds = (ms) => (ms / 1000).toFixed(1);
+ const parts = marks.map(([name, at], index) => `${name} ${seconds((marks[index + 1] ? marks[index + 1][1] : end) - at)}`);
+ const top = (entries, count) => entries.sort((a, b) => b[1] - a[1]).slice(0, count).map(([label, ms]) => `${label} ${seconds(ms)}`).join("、");
+ const all = [...spent];
+ const ops = all.filter(([label]) => label.startsWith("操作 · ")).map(([label, ms]) => [label.slice(5), ms]);
+ const parts2 = all.filter(([label]) => !label.startsWith("操作 · "));
+ return `用时 ${seconds(end - marks[0][1])} 秒(${parts.join("、")})。最慢的几块:${top(parts2, 6)}。按操作算:${top(ops, ops.length)}`;
}
async function run(api) {
let currentStage = "初始化";
+ const marks = [];
const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity });
await new Promise((resolve) => setTimeout(resolve, 50));
try {
- await build(api, (stage) => { currentStage = stage; });
+ const { clock: spent } = await build(api, (stage) => {
+ currentStage = stage;
+ marks.push([stage, Date.now()]);
+ });
notice.cancel();
- api.closePlugin("已生成「代码阅读」的组件与演示画板;选中「Prototype / envLock.ts · 预览」后点演示。");
+ api.closePlugin(`已生成「代码阅读」的组件与演示画板,${timingText(marks, Date.now(), spent)}。从「Prototype / envLock.ts · 预览」开始演示。`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
notice.cancel();
@@ -2232,6 +2469,6 @@ async function run(api) {
}
if (typeof module !== "undefined") {
- module.exports = { REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
+ module.exports = { ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
}
if (typeof figma !== "undefined") void run(figma);
diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js
index 4edd0cf..bbd115a 100644
--- a/docs/prototype/code-reader/figma-plugin/test.js
+++ b/docs/prototype/code-reader/figma-plugin/test.js
@@ -2,7 +2,7 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
-const { REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
+const { ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
const { fakeFigma } = require("../../figma-lab/fake-figma");
function action(node) { return node.reactions[0].actions[0]; }
@@ -35,7 +35,9 @@ const EXPECTED_SETS = [
["Reader / EnumCard", 1],
["Reader / CeleryAppCard", 1],
["Reader / BeatCard", 1],
+ ["Reader / RouteCard", 1],
["Reader / IterationBlock", 3],
+ ["Reader / FilterChip", 2],
["Reader / FilterBar", 3],
["Explorer / TreeRow", 10],
["Window / QuickInput", 1],
@@ -45,7 +47,7 @@ const EXPECTED_SETS = [
["Window / TitleBar", 1],
["Window / ActivityBar", 1],
["Window / StatusBar", 2],
- ["Editor / TitleBar", 6],
+ ["Editor / TitleBar", 2],
["Editor / Breadcrumb", 3],
["Import / 'node:fs'", 2],
["Import / 'vscode'", 2],
@@ -280,15 +282,15 @@ test("build creates the variant sets, reuses them on boards and wires the flows"
assert.equal(instance.mainComponentId, variant.id);
const parsed = translateBlock(fileKey, item);
assert.equal(named(instance, "Plain").characters, parsed.text);
- assert.equal(named(instance, "Plain").rangeFills.length, parsed.ranges.length);
+ // 同色相邻的片段并成一次上色,次数不多于片段数
+ const fills = named(instance, "Plain").rangeFills.length;
+ assert.ok(fills <= parsed.ranges.length && (fills > 0) === (parsed.ranges.length > 0), `L${lineRange(item.at)[0]} painted ${fills} of ${parsed.ranges.length}`);
assert.equal(named(instance, "Code").characters, blockCode(fileKey, item.at));
assert.ok(named(instance, "Code").rangeFills.length > 0, "code is highlighted");
assert.equal(named(instance, "Fold icon").visible, Boolean(item.kids), "only blocks with children show the fold arrow");
- assert.equal(named(instance, "Kind").characters, BLOCK_KINDS[item.kind].label);
+ assert.ok(named(instance, `Kind / ${BLOCK_KINDS[item.kind].label}`), "the kind shows as its icon");
}
}
- const issueComment = lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance;
- assert.ok(named(issueComment, "Code").rangeFonts.length >= 2, "Chinese comment lines use the CJK font");
const { envLock, issueLock } = result.boards;
const toPreview = action(named(envLock.edit.titleBar, "Seg / 预览"));
@@ -311,9 +313,10 @@ test("build creates the variant sets, reuses them on boards and wires the flows"
for (const instance of instances) assert.ok(setIds.has(instance.mainComponentId), `${instance.name} comes from a set`);
}
// 编辑窗口显示整份源码,预览窗口显示代码块树
- assert.equal(named(envLock.edit.view, "Source").characters.split("\n").length, 61);
- assert.equal(named(issueLock.edit.view, "Source").characters.split("\n").length, 25);
- assert.ok(named(issueLock.edit.view, "Source").rangeFonts.length >= 2);
+ // 源码拆成一段段文字,拼起来就是整份源码
+ const sourceText = (view) => named(view, "Source").children.map((node) => node.characters).join("\n");
+ assert.equal(sourceText(envLock.edit.view), SOURCES.envLock.map((line) => line || " ").join("\n"));
+ assert.equal(sourceText(issueLock.edit.view).split("\n").length, 25);
assert.equal(named(envLock.preview.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id);
assert.equal(named(result.narrow.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id);
assert.equal(named(issueLock.preview.view, "Block tree / issueLock.ts").mainComponentId, lib.tree.get("issueLock").node.id);
@@ -395,7 +398,7 @@ test("the SQLAlchemy rule turns a model class into a table card", async () => {
const api = fakeFigma();
const { tableBoard } = await build(api);
const card = tableBoard.findOne((node) => node.name === `Block / ${SQLA_MODEL.name}`);
- assert.equal(named(card, "Kind").characters, "数据表");
+ assert.ok(named(card, "Kind / 数据表"));
assert.deepEqual(card.findAll((node) => node.name.startsWith("Prop / ")).map((node) => node.name),
["Prop / 名称", "Prop / 表名", "Prop / 说明", "Prop / 继承", "Prop / 字段", "Prop / 索引"]);
assert.equal(card.findAll((node) => node.name.startsWith("Column / ")).length, SQLA_MODEL.columns.length);
@@ -407,7 +410,7 @@ test("enums get their own card with a member table", async () => {
const api = fakeFigma();
const { enumBoard } = await build(api);
const card = enumBoard.findOne((node) => node.name === `Block / ${ENUM_SAMPLE.name}`);
- assert.equal(named(card, "Kind").characters, "枚举");
+ assert.ok(named(card, "Kind / 枚举"));
assert.equal(card.findAll((node) => node.name.startsWith("Member / ")).length, ENUM_SAMPLE.members.length);
assert.equal(named(named(card.findOne((node) => node.name === "Member / PENDING"), "说明"), "Text").characters, "待结算");
});
@@ -416,16 +419,66 @@ test("the Celery rule draws the app and the beat schedule", async () => {
const api = fakeFigma();
const { celeryBoard } = await build(api);
const app = celeryBoard.findOne((node) => node.name === "Block / celery_app");
- assert.equal(named(app, "Kind").characters, "Celery 应用");
+ assert.ok(named(app, "Kind / Celery 应用"));
assert.deepEqual(app.findAll((node) => node.name.startsWith("Prop / ")).map((node) => node.name),
["Prop / 名称", "Prop / 应用名", "Prop / 消息队列", "Prop / 结果存储"]);
const beat = celeryBoard.findOne((node) => node.name === "Block / beat_schedule");
- assert.equal(named(beat, "Kind").characters, "定时任务");
+ assert.ok(named(beat, "Kind / 定时任务"));
assert.equal(beat.findAll((node) => node.name.startsWith("Entry / ")).length, BEAT_SCHEDULE.entries.length + 1);
const entry = beat.findOne((node) => node.name === "Entry / deployment.notify_expiring_deployments");
assert.equal(named(named(entry, "频率"), "Text").characters, "每天");
});
+test("the FastAPI rule turns a route into a card with a request table and a status-code table", async () => {
+ const api = fakeFigma();
+ const { routeBoard, components } = await build(api);
+ assert.equal(routeBoard.children.length, 1);
+ const card = components.lib.route.get("preview");
+ assert.equal(named(named(card, "Prop / 依赖"), "Value").characters, "get_db(db)、get_current_user(current_user)");
+ // 请求:一项一行
+ const cell = (row, column) => named(named(named(card, row), column), "Text").characters;
+ assert.deepEqual(named(card, "Request").findAll((node) => node.name.startsWith("Request / ")).map((node) => node.name.slice(10)),
+ ["URL", "方法", "路径参数", "请求体"]);
+ assert.equal(cell("Request / URL", "值"), "/users/{user_id}");
+ assert.equal(cell("Request / 方法", "值"), "PUT");
+ assert.equal(cell("Request / 路径参数", "值"), "user_id: Int4Path");
+ assert.equal(cell("Request / 请求体", "值"), "UserUpdateRequest");
+ // 响应:每个状态码一行
+ assert.deepEqual(named(card, "Response").findAll((node) => node.name.startsWith("Status / ")).map((node) => node.name.slice(9)),
+ ROUTE_SAMPLE.responses.map((response) => String(response.status)));
+ assert.equal(cell("Status / 200", "内容"), "UserDetailResponse");
+ assert.equal(cell("Status / 403", "内容"), "Forbidden");
+ assert.match(cell("Status / 403", "说明"), /无权修改角色/);
+});
+
+test("monospace text with Chinese is written in the CJK font first, then everything else switched to mono", async () => {
+ // 等宽字体里没有中文字形:先用它放中文,Figma 要给每个中文找替代字体,慢
+ const api = fakeFigma({ fonts: [{ family: "Noto Sans SC", style: "Regular" }, { family: "Noto Sans SC", style: "Medium" },
+ { family: "JetBrains Mono", style: "Regular" }] });
+ const { components } = await build(api);
+ const comment = components.lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance;
+ const code = named(comment, "Code");
+ assert.equal(code.fontName.family, "Noto Sans SC");
+ assert.ok(code.rangeFonts.length >= 2);
+ for (const { start, end, font } of code.rangeFonts) {
+ assert.equal(font.family, "JetBrains Mono");
+ assert.doesNotMatch(code.characters.slice(start, end), /[\u3000-\u9fff]/, "no Chinese inside a mono range");
+ }
+ const plain = named(components.lib.tree.get("envLock").placed.find(({ item }) => item.kind === "return").instance, "Code");
+ assert.equal(plain.fontName.family, "JetBrains Mono", "code without Chinese stays mono as a whole");
+ assert.equal(plain.rangeFonts.length, 0);
+});
+
+test("block kinds show as icons, the legend lists every kind with its name", async () => {
+ const api = fakeFigma();
+ const { kindBoard } = await build(api);
+ const rows = kindBoard.findAll((node) => node.name.startsWith("Kind row / "));
+ assert.ok(rows.length >= Object.keys(BLOCK_KINDS).length + 5);
+ for (const row of rows) assert.ok(row.findOne((node) => node.name.startsWith("Kind / ")), `${row.name} has its icon`);
+ assert.equal(named(named(kindBoard, "Tooltip"), "Text").characters, BLOCK_KINDS.return.label);
+ assert.equal(new Set(Object.values(BLOCK_KINDS).map((kind) => kind.icon)).size, Object.keys(BLOCK_KINDS).length, "each kind has its own icon");
+});
+
test("block tools are icons only, and asking AI opens a thread under the block with grey history", async () => {
const api = fakeFigma();
const { components, askBoard } = await build(api);
@@ -505,7 +558,7 @@ test("the plugin stays small enough for Figma to build quickly", async () => {
if (node.type !== "INSTANCE") node.children.forEach(walk);
};
result.page.children.forEach(walk);
- // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏、块右上角按钮做成共用组件后,现在 23 张画板约 2800 个
+ // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏、块右上角按钮、筛选下拉框、编辑器标题栏做成共用组件后,现在 24 张画板约 2960 个
assert.ok(owned < 3000, `page owns ${owned} layers outside instances`);
});