✨ feat(prototype): 代码阅读库规则 SQLAlchemy 数据表卡片原型
This commit is contained in:
1 parent
1fa66da0d0
commit
32be7d54b6
3 files changed
+99
-21
No files matched your search
@@ -23,6 +23,7 @@
|
||||
| `Prototype / issueLock.ts · 编辑 / 预览` | `vscode/src/panel/issueLock.ts` | 中文注释块、多行返回语句块 |
|
||||
| `Prototype / routes.py · 编辑 / 预览` | luffy-agent-platform 的 `backend/src/luffy_agent/user/routes.py` | Python:带装饰器、默认值、返回类型的 FastAPI 路由函数,属性表;`is not None` → 不是 空 |
|
||||
| `Prototype / envLock.ts · 预览(编辑区变窄)` | — | 窗口只有 1000 宽时块跟着变窄,长句换行 |
|
||||
| `Prototype / 库规则 · SQLAlchemy 数据表` | luffy 的 `backend/src/luffy_agent/referral/models.py` 里的 `ReferralRelation` | 库规则(文件导入了 sqlalchemy 才启用)把模型类整块换成「数据表」卡片:名称 / 表名 / 说明 / 继承,字段表(字段 / 类型 / 约束 / 说明)和索引表;`__tablename__`、`__table_args__`、各字段块并进卡片 |
|
||||
| `Prototype / 遍历 · 推导式` | luffy 邀请码工具的 `"".join(secrets.choice(ALPHABET) for _ in range(length))`,外加带条件的列表、字典推导 | 写在表达式里的遍历原地套一个带循环图标和结果类型(生成器 / 列表 / 字典)的小框,框里按读的顺序重排:「遍历 range(length) 的每个 _,仅当 …,取 …」 |
|
||||
| `Prototype / 筛选 · 默认` | `envLock.ts` | 预览顶部固定一条筛选栏:「筛选」+ 当前文件出现过的属性各一个下拉框(种类、访问级别、异步、仅类型)+ 右边「共 33 块」 |
|
||||
| `Prototype / 筛选 · 按访问级别` | 同上 | 访问级别下拉菜单打开,只勾「公共」:两个私有常量和私有函数 `chmodAll`(连同函数体)藏起来;条件、返回这些没有访问级别的块照常显示;右边「已隐藏 3 块 · 清除」 |
|
||||
@@ -55,6 +56,7 @@
|
||||
- `Reader / ModeToggle`:编辑器右上角的「编辑 / 预览」。
|
||||
- `Explorer / TreeRow`:侧栏文件树的一行,文件夹展开 / 收起 / 展开且悬停,文件默认 / 选中 / 有改动 / 有改动且选中 / 悬停,共 8 个。名字后面的注释层默认藏起来,有注释的实例里打开;悬停状态行尾带「添加注释」「编辑注释」两个按钮,实例里按有没有注释显示其中一个。
|
||||
- `Explorer / Header`:侧栏顶部。
|
||||
- `Reader / TableCard`:SQLAlchemy 规则认出的数据表卡片。
|
||||
- `Reader / IterationBlock`:`Sample=generator | list | dict`,三种推导式的返回块。
|
||||
- `Reader / FilterBar`:`Active=none | access | kind`,预览顶部的筛选栏,打开的下拉菜单是勾选列表,每个值后面是块数。
|
||||
- `Window / QuickInput`:VS Code 顶部弹出的单行输入框,标题和内容在实例里改。
|
||||
@@ -101,6 +103,7 @@ node --test test.js
|
||||
- [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格
|
||||
- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐
|
||||
- [ ] 属性表:函数、导入、常量、变量的属性名一列对齐;导入项每项一行,左边竖线;输入参数是带边框和表头的表格,名称 / 类型 / 默认值三列对齐;长类型(`Annotated[...]`)换行不挤掉默认值列
|
||||
- [ ] 数据表:字段表四列对齐,约束一格里外键目标、删除规则看得清;说明列长了换行不挤别的列
|
||||
- [ ] 遍历:小框和前后文字在同一行里对齐、太长时整体换行;循环图标和「循环」块同色
|
||||
- [ ] 筛选:筛选栏固定在预览顶部、和代码块左右对齐;下拉菜单盖在代码块上面;藏掉的块不留空位,左边层级竖线跟着变短
|
||||
- [ ] 文件注释:悬停行的按钮贴在行尾、不挤掉名字;注释灰字紧跟名字、长了截断;顶部输入框居中,看得出是 VS Code 自带的样子
|
||||
|
||||
+82
-20
@@ -898,6 +898,59 @@ function setText(root, name, characters) {
|
||||
named(root, name).characters = characters;
|
||||
}
|
||||
|
||||
// ─── 库规则:SQLAlchemy 数据表 ────────────────────────────────────────────────
|
||||
//
|
||||
// 库规则按库注册,文件导入了这个库才启用。SQLAlchemy 规则把模型类(父类链里有模型基类、字段用 mapped_column)
|
||||
// 整块换成「数据表」卡片:表名、说明、继承,字段和索引各一张表;__tablename__、__table_args__、各字段块并进卡片,不再单独成块。
|
||||
// 示例:luffy 的 backend/src/luffy_agent/referral/models.py 里的 ReferralRelation,下面是规则从源码读出的结构。
|
||||
const SQLA_MODEL = {
|
||||
name: "ReferralRelation", table: "referral_relations", comment: "邀请关系", bases: "IntPK, TimestampMixin, Base",
|
||||
columns: [
|
||||
{ name: "inviter_user_id", type: "整数", constraints: [["索引"], ["外键", "sys_user.id", "删除时禁止"]], comment: "邀请人用户ID" },
|
||||
{ name: "invitee_user_id", type: "整数", constraints: [["唯一"], ["外键", "sys_user.id", "删除时禁止"]], comment: "被邀请人用户ID(唯一, 一人只绑一个邀请人)" },
|
||||
{ name: "invite_code", type: "字符串(16)", constraints: [], comment: "绑定时使用的邀请码" },
|
||||
{ name: "bind_time", type: "日期时间(带时区)", constraints: [], comment: "绑定时间" },
|
||||
{ name: "expire_time", type: "日期时间(带时区)", constraints: [], comment: "归属过期时间" },
|
||||
{ name: "status", type: "枚举 RelationStatus", constraints: [["默认", "RelationStatus.ACTIVE"]], comment: "关系状态" },
|
||||
],
|
||||
indexes: [{ name: "ix_referral_relations_inviter_bind_time", columns: "inviter_user_id, bind_time" }],
|
||||
};
|
||||
const TABLE_KIND = { label: "数据表", color: "#e8a860" };
|
||||
|
||||
/** 约束一格:唯一 · 索引 · 外键 sys_user.id(删除时禁止)· 默认 …;「非空」是常态不写,可空才写。 */
|
||||
function constraintSegments(constraints) {
|
||||
return constraints.flatMap(([word, target, note], index) => [
|
||||
...(index ? [{ text: " · ", color: COLORS.dim }] : []),
|
||||
{ text: word, color: COLORS.tokenStorage },
|
||||
...(target ? [{ text: " " }, { text: target, color: word === "外键" ? COLORS.tokenString : COLORS.tokenVariable }] : []),
|
||||
...(note ? [{ text: `(${note})`, color: COLORS.desc }] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Reader / TableCard — State=preview:SQLAlchemy 规则认出的数据表卡片。 */
|
||||
function tableCard(ctx) {
|
||||
const model = SQLA_MODEL;
|
||||
const { node, content } = cardShell(ctx, "State=preview", TABLE_KIND, { kids: false });
|
||||
propRow(ctx, content, "名称", [{ text: model.name, color: COLORS.tokenType }]);
|
||||
propRow(ctx, content, "表名", [{ text: model.table, color: COLORS.tokenString }]);
|
||||
propRow(ctx, content, "说明", [{ text: model.comment, color: COLORS.comment }]);
|
||||
propRow(ctx, content, "继承", model.bases.split(", ").flatMap((base, index) =>
|
||||
[...(index ? [{ text: ", " }] : []), { text: base, color: COLORS.tokenType }]));
|
||||
propRow(ctx, content, "字段", countSegments(model.columns), { twisty: true });
|
||||
propTable(ctx, content, "Columns", [["字段", 170], ["类型", 170], ["约束", 290], ["说明", 0]], model.columns.map((column) => [`Column / ${column.name}`, [
|
||||
[{ text: column.name, color: COLORS.tokenVariable }],
|
||||
[{ text: column.type, color: COLORS.tokenType }],
|
||||
constraintSegments(column.constraints),
|
||||
[{ text: column.comment, color: COLORS.comment }],
|
||||
]]));
|
||||
propRow(ctx, content, "索引", countSegments(model.indexes), { twisty: true });
|
||||
propTable(ctx, content, "Indexes", [["名称", 330], ["列", 0]], model.indexes.map((index) => [`Index / ${index.name}`, [
|
||||
[{ text: index.name, color: COLORS.tokenString }],
|
||||
[{ text: index.columns, color: COLORS.tokenVariable }],
|
||||
]]));
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── 筛选 ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// 每块带几条「属性 = 值」,由语言插件从语法树换算成各语言统一的属性,翻译插件给属性和值起显示名;
|
||||
@@ -1123,19 +1176,18 @@ function subRows(ctx, parent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入参数:带边框和表头的表格,名称 / 类型 / 默认值三列,每个参数一行。
|
||||
* 名称、类型两列固定宽度,默认值占剩下的宽度;格子之间、行之间一条分隔线,表头底色深一档、字用说明色。
|
||||
* 属性表里的子表格:带边框和表头,columns 是 [列名, 宽度],宽度 0 的列占剩下的宽度;
|
||||
* 格子之间、行之间一条分隔线,表头底色深一档、字用说明色。rows 是 [行名, 每格的着色片段]。
|
||||
*/
|
||||
const PARAM_COLUMNS = [["名称", 150], ["类型", 220], ["默认值", 0]];
|
||||
function paramRows(ctx, parent, params, lang) {
|
||||
const wrap = filled(ctx, parent, "Params", {});
|
||||
function propTable(ctx, parent, name, columns, rows) {
|
||||
const wrap = filled(ctx, parent, name, {});
|
||||
wrap.paddingLeft = 5;
|
||||
const table = append(wrap, frame(ctx, "Table", { width: 10, radius: 4, stroke: COLORS.borderStrong }), true);
|
||||
table.clipsContent = true;
|
||||
const row = (name, cells, header) => {
|
||||
const node = filled(ctx, table, name, { dir: "HORIZONTAL", fill: header ? COLORS.side : null });
|
||||
const row = (rowName, cells, header) => {
|
||||
const node = filled(ctx, table, rowName, { dir: "HORIZONTAL", fill: header ? COLORS.side : null });
|
||||
cells.forEach((segments, index) => {
|
||||
const [label, width] = PARAM_COLUMNS[index];
|
||||
const [label, width] = columns[index];
|
||||
const cell = width ? append(node, frame(ctx, label, { width, px: 8, py: 3 })) : filled(ctx, node, label, { px: 8, py: 3 });
|
||||
cell.layoutSizingVertical = "FILL";
|
||||
if (index < cells.length - 1) {
|
||||
@@ -1145,19 +1197,23 @@ function paramRows(ctx, parent, params, lang) {
|
||||
}
|
||||
richText(ctx, cell, "Text", segments);
|
||||
});
|
||||
return node;
|
||||
};
|
||||
row("Header", PARAM_COLUMNS.map(([label]) => [{ text: label, color: COLORS.desc }]), true);
|
||||
for (const param of params) {
|
||||
const node = row(`Param / ${param.name}`, [
|
||||
[{ text: param.name, color: COLORS.tokenVariable }],
|
||||
param.type ? typeSegments(param.type, lang) : [{ text: "未标注", color: COLORS.desc }],
|
||||
param.value ? segmentsOf(translateCode(param.value, lang)) : [],
|
||||
]);
|
||||
if (!header) {
|
||||
node.strokes = paint(COLORS.borderStrong);
|
||||
node.strokeTopWeight = 1;
|
||||
node.strokeBottomWeight = node.strokeLeftWeight = node.strokeRightWeight = 0;
|
||||
}
|
||||
};
|
||||
row("Header", columns.map(([label]) => [{ text: label, color: COLORS.desc }]), true);
|
||||
for (const [rowName, cells] of rows) row(rowName, cells);
|
||||
}
|
||||
|
||||
/** 输入参数:名称 / 类型 / 默认值三列的表格,每个参数一行。 */
|
||||
function paramRows(ctx, parent, params, lang) {
|
||||
propTable(ctx, parent, "Params", [["名称", 150], ["类型", 220], ["默认值", 0]], params.map((param) => [`Param / ${param.name}`, [
|
||||
[{ text: param.name, color: COLORS.tokenVariable }],
|
||||
param.type ? typeSegments(param.type, lang) : [{ text: "未标注", color: COLORS.desc }],
|
||||
param.value ? segmentsOf(translateCode(param.value, lang)) : [],
|
||||
]]));
|
||||
}
|
||||
|
||||
/** 导入项的子行:每项一行,有别名时写成「名称 作为 别名」。 */
|
||||
@@ -1481,6 +1537,7 @@ function createComponents(ctx, page) {
|
||||
lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode));
|
||||
lib.block = make("Reader / CodeBlock", BLOCK_VARIANTS.map(([kind, state]) => `${kind}/${state}`), 2,
|
||||
(entry) => codeBlock(ctx, ...entry.split("/")), (item) => item.node);
|
||||
lib.tableCard = make("Reader / TableCard", ["preview"], 1, () => tableCard(ctx));
|
||||
lib.iteration = make("Reader / IterationBlock", ITERATION_SAMPLES.map((sample) => sample.key), 3,
|
||||
(key) => iterationBlock(ctx, ITERATION_SAMPLES.find((sample) => sample.key === key)));
|
||||
lib.filterBar = make("Reader / FilterBar", ["none", "access", "kind"], 3, (active) => filterBar(ctx, active));
|
||||
@@ -1701,6 +1758,11 @@ async function build(api, stage = () => {}) {
|
||||
const narrow = windowBoard(ctx, page, lib, "envLock.ts · 预览(编辑区变窄)", { file: "envLock", mode: "preview", width: WINDOW_NARROW });
|
||||
const filters = Object.fromEntries(Object.entries({ none: "默认", access: "按访问级别", kind: "按种类" }).map(([key, label]) =>
|
||||
[key, windowBoard(ctx, page, lib, `筛选 · ${label}`, { file: FILTER_FILE, mode: "preview", filter: key })]));
|
||||
const tableBoard = frame(ctx, "Prototype / 库规则 · SQLAlchemy 数据表", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor });
|
||||
page.appendChild(tableBoard);
|
||||
const tableInstance = lib.tableCard.get("preview").createInstance();
|
||||
tableInstance.name = `Block / ${SQLA_MODEL.name}`;
|
||||
append(tableBoard, tableInstance, true);
|
||||
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) {
|
||||
@@ -1754,7 +1816,7 @@ async function build(api, stage = () => {}) {
|
||||
|
||||
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), iteration];
|
||||
...Object.values(notes).map((board) => board.screen), ...Object.values(filters).map((board) => board.screen), iteration, tableBoard];
|
||||
let y = 0;
|
||||
for (const screen of screens) {
|
||||
screen.x = 0;
|
||||
@@ -1772,7 +1834,7 @@ async function build(api, stage = () => {}) {
|
||||
{ nodeId: filters.none.screen.id, name: "筛选" }];
|
||||
page.selection = [start];
|
||||
api.viewport.scrollAndZoomIntoView(page.selection);
|
||||
return { page, components, boards, narrow, notes, filters, iteration, screens };
|
||||
return { page, components, boards, narrow, notes, filters, iteration, tableBoard, screens };
|
||||
}
|
||||
|
||||
async function run(api) {
|
||||
@@ -1791,6 +1853,6 @@ async function run(api) {
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = { 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 = { 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);
|
||||
+14
-1
@@ -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 { 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 { 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]; }
|
||||
@@ -27,6 +27,7 @@ const EXPECTED_LINKS = 106;
|
||||
const EXPECTED_SETS = [
|
||||
["Reader / ModeToggle", 2],
|
||||
["Reader / CodeBlock", 18],
|
||||
["Reader / TableCard", 1],
|
||||
["Reader / IterationBlock", 3],
|
||||
["Reader / FilterBar", 3],
|
||||
["Explorer / TreeRow", 8],
|
||||
@@ -381,6 +382,18 @@ test("comprehensions read in order: source, variable, condition, then the elemen
|
||||
assert.equal(named(block, "After").characters, ")");
|
||||
});
|
||||
|
||||
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.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);
|
||||
const inviter = card.findOne((node) => node.name === "Column / inviter_user_id");
|
||||
assert.equal(named(named(inviter, "约束"), "Text").characters, "索引 · 外键 sys_user.id(删除时禁止)");
|
||||
});
|
||||
|
||||
test("boards and sets sit on the page without overlapping", async () => {
|
||||
const api = fakeFigma();
|
||||
const result = await build(api);
|
||||
|
||||
Reference in new issue
Block a user