✨ feat(vscode): 看板底栏新增工单讨论 Tab 并支持系统气泡与 Gitea 评论同步
This commit is contained in:
1 parent
8ecb40b330
commit
df17821408
14 files changed
+1555
-2
No files matched your search
@@ -0,0 +1,36 @@
|
||||
# 工单讨论
|
||||
|
||||
1:1 还原 VS Code 看板界面:
|
||||
- 顶部 Header(筛选框、发布、锁环境、刷新、范围切换、设置)
|
||||
- 4 列看板(待办 19、进行中 2、审查 8、完成 178),当前选中 `#458`
|
||||
- 底部抽屉(左侧 5 个图标 Rail,右侧内容区)
|
||||
|
||||
在底栏左侧新增「讨论」Tab(`MessageCircle` 图标):
|
||||
- 点击进入当前工单讨论流,数据双向同步 Gitea 评论系统
|
||||
- 插件状态更新渲染为居中的系统气泡(无头像、低对比度圆角药丸)
|
||||
- 同事发言居左(灰底卡片、带头像和用户名),自己发言居右(蓝底卡片)
|
||||
- 底部附带支持 Markdown 的输入框与发送按钮
|
||||
|
||||
## 画板清单
|
||||
|
||||
| 画板 | 界面说明 | 演示要点 |
|
||||
| --- | --- | --- |
|
||||
| Prototype / 看板 · 工单详情(现状) | 1:1 当前 VS Code 看板 | 底部为工单属性表(STATE JSON),讨论 Tab 未激活 |
|
||||
| Prototype / 看板 · 工单讨论(有评论) | 选中 #458 后的讨论视图 | 讨论 Tab 激活(蓝条高亮),展示系统气泡与对话流 |
|
||||
| Prototype / 看板 · 工单讨论(空状态) | 选中无评论工单(#459) | 讨论 Tab 激活,居中提示「还没有讨论」,输入框就绪 |
|
||||
|
||||
## 导入与运行
|
||||
|
||||
1. 打开 Figma Desktop 任意 Design 文件。
|
||||
2. `Plugins > Development > Import plugin from manifest…`。
|
||||
3. 选 `docs/prototype/issue-chat/figma-plugin/manifest.json`。
|
||||
4. `Plugins > Development > 工单讨论`。
|
||||
5. 选中 `Prototype / 看板 · 工单讨论(有评论)`,点右上角 Present。
|
||||
6. 点击左侧底栏的「工单」图标跳回属性表,点「讨论」图标切回讨论流。
|
||||
|
||||
## 本地检查
|
||||
|
||||
```bash
|
||||
node --check docs/prototype/issue-chat/figma-plugin/code.js
|
||||
node --test docs/prototype/issue-chat/figma-plugin/test.js
|
||||
```
|
||||
+874
@@ -0,0 +1,874 @@
|
||||
// 工单讨论 Design Lab — local Figma Desktop development plugin.
|
||||
/* global figma */
|
||||
// 完整复刻 VS Code 看板界面:顶部操作栏 + 4列看板 + 底部详情抽屉。
|
||||
// 左侧 Rail 新增「讨论」Tab,展示当前工单的 Gitea 讨论流;
|
||||
// 插件更新的状态评论渲染为居中的系统气泡,人的发言带头像与卡片。
|
||||
const PAGE_NAME = "工单讨论 · Design Lab";
|
||||
const NAMESPACE = "issue_chat";
|
||||
const GENERATOR = "lab.issue_chat.design-lab";
|
||||
const WINDOW_W = 1280;
|
||||
const WINDOW_H = 780;
|
||||
const HEADER_H = 32;
|
||||
const KANBAN_H = 410;
|
||||
const BOTTOM_H = WINDOW_H - HEADER_H - KANBAN_H; // 338px
|
||||
const RAIL_W = 36;
|
||||
const DETAIL_W = WINDOW_W - RAIL_W; // 1244px
|
||||
const COL_W = 296;
|
||||
|
||||
// 真实 VS Code Dark Modern 色值与 Tailwind 对应色
|
||||
const COLORS = {
|
||||
editor: "#1f1f1f", // editor.background
|
||||
side: "#181818", // sideBar.background
|
||||
header: "#1e1e1e",
|
||||
border: "#2b2b2b", // panel.border
|
||||
borderStrong: "#3c3c3c",
|
||||
fg: "#cccccc", // foreground
|
||||
fgStrong: "#e8e8e8",
|
||||
desc: "#9d9d9d", // descriptionForeground
|
||||
dim: "#6e7681", // line numbers / muted
|
||||
activityIdle: "#868686", // inactive tab
|
||||
activityOn: "#d7d7d7", // active tab
|
||||
focus: "#0078d4", // focusBorder / active bar
|
||||
hover: "#2a2d2e",
|
||||
input: "#313131", // input.background
|
||||
inputBorder: "#3c3c3c",
|
||||
// 卡片色值:Tailwind slate-900 / slate-700
|
||||
cardBg: "#0f172a",
|
||||
cardBorder: "#334155",
|
||||
cardFg: "#f1f5f9",
|
||||
cardSelectedBorder: "#0078d4",
|
||||
// 讨论气泡
|
||||
systemPill: "#262626",
|
||||
systemBorder: "#383838",
|
||||
systemFg: "#9d9d9d",
|
||||
myBubble: "#0e3753",
|
||||
myBorder: "#0078d4",
|
||||
theirBubble: "#262626",
|
||||
theirBorder: "#383838",
|
||||
badgeBg: "#2d333b",
|
||||
badgeFg: "#adbac7",
|
||||
pillBg: "#2d333b",
|
||||
};
|
||||
const FONT_FAMILIES = ["PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"];
|
||||
const DISPLAY_FONTS = [{ family: "Fraunces", style: "SemiBold" }, { family: "Inter", style: "Semi Bold" }];
|
||||
|
||||
// 真实看板数据(完全对齐当前状态)
|
||||
const COLUMNS = [
|
||||
{ id: "todo", label: "待办", count: "19", hasPlus: true },
|
||||
{ id: "in-progress", label: "进行中", count: "2", hasPlus: false },
|
||||
{ id: "review", label: "审查", count: "8", hasPlus: false },
|
||||
{ id: "done", label: "完成", count: "178", hasPlus: false },
|
||||
];
|
||||
|
||||
const CARDS = {
|
||||
todo: [
|
||||
{ number: 459, title: "✨ 桌面端: 工作区外访问授权门, 按 opencode 模型做、交互" },
|
||||
{ number: 453, title: "编程大赛投票:补齐防刷票机制" },
|
||||
{ number: 431, title: "钉机应用无逃生通道: 升档重发 / 挂起恢复 撞上原机满载时无解" },
|
||||
{ number: 426, title: "编程大赛:报名与提交作品拆为两步(下一届活动前完成)" },
|
||||
{ number: 419, title: "管理后台统计各模型用量" },
|
||||
{ number: 415, title: "微信登录支持快捷登录 (扫码一键绑定并登录)" },
|
||||
],
|
||||
"in-progress": [
|
||||
{ number: 458, title: "✨ 桌面端: 接真实执行链路, 登录即设备、授权门、急停、更新", selected: true },
|
||||
{ number: 456, title: "生图按用量计费(成本目录 × 倍率,计入会话花费)" },
|
||||
],
|
||||
review: [
|
||||
{ number: 455, title: "✨ 会话设置面板移入右侧工具栏并换用齿轮图标", readOnly: true, owner: "C" },
|
||||
{ number: 444, title: "算法备案:补齐文本 AIGC 溯源标识持久化及验收截图", readOnly: true, owner: "C" },
|
||||
{ number: 438, title: "发布链路可靠性设计重审:失败归因、超时分层、开发库权限" },
|
||||
{ number: 435, title: "✨ 功能建议:案例库案例可跳转作者页", readOnly: true, owner: "C" },
|
||||
],
|
||||
done: [
|
||||
{ number: 452, title: "✨ 桌面端: 照 Figma 原型实现客户端 UI" },
|
||||
{ number: 448, title: "✨ 会话消息区 UI 优化" },
|
||||
{ number: 441, title: "大赛作品墙找不到作品:加作品专属页、固定编号、搜索" },
|
||||
],
|
||||
};
|
||||
|
||||
// #458 工单的讨论流
|
||||
const THREAD_458 = [
|
||||
{ kind: "system", body: "chehongweo 把工单放到待办", time: "09:34" },
|
||||
{ kind: "theirs", author: "chehongweo", body: "桌面端先接真实执行链路,登录即设备这个按 issue 452 规格来接吗?", time: "14:12" },
|
||||
{ kind: "system", body: "cruldra 更新了工单状态,放到进行中", time: "16:40" },
|
||||
{ kind: "system", body: "chehongweo 提交了审查 · 看过了,标题栏图标可以。申请移交不要写在卡片上。", time: "18:02" },
|
||||
{ kind: "mine", author: "cruldra", body: "改到工单详情面板了,顶部做成纯图标,你再看一眼。", time: "18:20" },
|
||||
];
|
||||
|
||||
const RAIL_TABS = [
|
||||
{ id: "issue", name: "工单", icon: "clipboard-list" },
|
||||
{ id: "chat", name: "讨论", icon: "message-circle" },
|
||||
{ id: "profile", name: "Profile", icon: "layers" },
|
||||
{ id: "sessions", name: "会话", icon: "messages-square" },
|
||||
{ id: "changes", name: "改动", icon: "file-diff" },
|
||||
];
|
||||
|
||||
function nextPageName(pages) {
|
||||
const names = new Set(pages.map((page) => page.name));
|
||||
let name = PAGE_NAME;
|
||||
for (let index = 2; names.has(name); index += 1) name = `${PAGE_NAME} ${index}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
function rgb(hex) {
|
||||
return {
|
||||
r: parseInt(hex.slice(1, 3), 16) / 255,
|
||||
g: parseInt(hex.slice(3, 5), 16) / 255,
|
||||
b: parseInt(hex.slice(5, 7), 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
function paint(color, opacity = 1) {
|
||||
return [{ type: "SOLID", color: rgb(color), opacity }];
|
||||
}
|
||||
|
||||
async function chooseFonts(api) {
|
||||
const available = (await api.listAvailableFontsAsync()).map((entry) => entry.fontName);
|
||||
const has = (font) => available.some((item) => item.family === font.family && item.style === font.style);
|
||||
for (const family of FONT_FAMILIES) {
|
||||
const styles = available.filter((font) => font.family === family);
|
||||
const regular = styles.find((font) => ["Regular", "Normal", "Book"].includes(font.style));
|
||||
if (!regular) continue;
|
||||
const medium = styles.find((font) => ["Medium", "Semibold", "SemiBold"].includes(font.style)) || regular;
|
||||
await api.loadFontAsync(regular);
|
||||
if (medium !== regular) await api.loadFontAsync(medium);
|
||||
const display = DISPLAY_FONTS.find(has) || medium;
|
||||
if (display !== medium) await api.loadFontAsync(display);
|
||||
return { regular, medium, display };
|
||||
}
|
||||
throw new Error(`没有找到可用的中文字体。请在 Figma 中启用 ${FONT_FAMILIES.join("、")} 之一后重试。`);
|
||||
}
|
||||
|
||||
function frame(ctx, name, o = {}) {
|
||||
const node = o.component ? ctx.api.createComponent() : ctx.api.createFrame();
|
||||
node.name = name;
|
||||
node.layoutMode = o.dir || "VERTICAL";
|
||||
const horizontal = node.layoutMode === "HORIZONTAL";
|
||||
node.fills = o.fill ? paint(o.fill, o.fillOpacity) : [];
|
||||
node.strokes = o.stroke ? paint(o.stroke) : [];
|
||||
node.strokeWeight = o.strokeWidth || 1;
|
||||
node.clipsContent = false;
|
||||
node.cornerRadius = o.radius || 0;
|
||||
node.paddingTop = node.paddingBottom = o.py ?? o.pad ?? 0;
|
||||
node.paddingLeft = node.paddingRight = o.px ?? o.pad ?? 0;
|
||||
node.itemSpacing = o.gap || 0;
|
||||
node.counterAxisAlignItems = o.align || "MIN";
|
||||
node.primaryAxisAlignItems = o.justify || "MIN";
|
||||
if (o.width) {
|
||||
node.resize(o.width, o.height || 1);
|
||||
node.primaryAxisSizingMode = horizontal ? "FIXED" : o.height ? "FIXED" : "AUTO";
|
||||
node.counterAxisSizingMode = horizontal ? (o.height ? "FIXED" : "AUTO") : "FIXED";
|
||||
} else {
|
||||
node.primaryAxisSizingMode = "AUTO";
|
||||
node.counterAxisSizingMode = "AUTO";
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function append(parent, node, fill = false) {
|
||||
parent.appendChild(node);
|
||||
if (fill) node.layoutSizingHorizontal = "FILL";
|
||||
return node;
|
||||
}
|
||||
|
||||
function text(ctx, parent, name, characters, o = {}) {
|
||||
const node = ctx.api.createText();
|
||||
node.name = name;
|
||||
node.fontName = o.display ? ctx.fonts.display : o.medium ? ctx.fonts.medium : ctx.fonts.regular;
|
||||
node.fontSize = o.size || 12;
|
||||
node.lineHeight = { unit: "PIXELS", value: o.lineHeight || Math.round(node.fontSize * 1.45) };
|
||||
node.characters = characters;
|
||||
node.fills = paint(o.color || COLORS.fg);
|
||||
if (o.align) node.textAlignHorizontal = o.align;
|
||||
parent.appendChild(node);
|
||||
if (o.hug) {
|
||||
node.textAutoResize = "WIDTH_AND_HEIGHT";
|
||||
return node;
|
||||
}
|
||||
node.textAutoResize = "HEIGHT";
|
||||
node.layoutSizingHorizontal = "FILL";
|
||||
if (o.singleLine) {
|
||||
node.textTruncation = "ENDING";
|
||||
node.maxLines = 1;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function writeText(ctx, node, characters) {
|
||||
if (!node) return;
|
||||
const style = node.fontName && node.fontName.style;
|
||||
node.fontName = style && style !== "Regular" && style !== "Normal" && style !== "Book" ? ctx.fonts.medium : ctx.fonts.regular;
|
||||
node.characters = characters;
|
||||
}
|
||||
|
||||
const LUCIDE = {
|
||||
"search": [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]],
|
||||
"rocket": [["path", { d: "M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" }], ["path", { d: "m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" }], ["path", { d: "M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" }], ["path", { d: "M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" }]],
|
||||
"lock": [["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2", ry: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]],
|
||||
"refresh-cw": [["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }]],
|
||||
"user": [["path", { d: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }], ["circle", { cx: "12", cy: "7", r: "4" }]],
|
||||
"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" }]],
|
||||
"plus": [["path", { d: "M5 12h14" }], ["path", { d: "M12 5v14" }]],
|
||||
"clipboard-list": [["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }], ["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }], ["path", { d: "M12 11h4" }], ["path", { d: "M12 16h4" }], ["path", { d: "M8 11h.01" }], ["path", { d: "M8 16h.01" }]],
|
||||
"message-circle": [["path", { d: "M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719" }]],
|
||||
"layers": [["path", { d: "M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z" }], ["path", { d: "M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12" }], ["path", { d: "M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17" }]],
|
||||
"messages-square": [["path", { d: "M16 10a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 14.286V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" }], ["path", { d: "M20 9a2 2 0 0 1 2 2v10.286a.71.71 0 0 1-1.212.502l-2.202-2.202A2 2 0 0 0 17.172 19H10a2 2 0 0 1-2-2v-1" }]],
|
||||
"file-diff": [["path", { d: "M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" }], ["path", { d: "M9 10h6" }], ["path", { d: "M12 13V7" }], ["path", { d: "M9 17h6" }]],
|
||||
"external-link": [["path", { d: "M15 3h6v6" }], ["path", { d: "M10 14 21 3" }], ["path", { d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" }]],
|
||||
"user-plus": [["path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }], ["circle", { cx: "9", cy: "7", r: "4" }], ["line", { x1: "19", x2: "19", y1: "8", y2: "14" }], ["line", { x1: "22", x2: "16", y1: "11", y2: "11" }]],
|
||||
"send": [["path", { d: "m22 2-7 20-4-9-9-4Z" }], ["path", { d: "M22 2 11 13" }]],
|
||||
"terminal": [["polyline", { points: "4 17 10 11 4 5" }], ["line", { x1: "12", x2: "20", y1: "19", y2: "19" }]],
|
||||
"play": [["polygon", { points: "6 3 20 12 6 21 6 3" }]],
|
||||
};
|
||||
|
||||
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
|
||||
const node = ctx.api.createNodeFromSvg(svg);
|
||||
node.name = o.name || `Icon / ${name}`;
|
||||
return node;
|
||||
}
|
||||
|
||||
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;
|
||||
set.layoutMode = "NONE";
|
||||
set.fills = paint(COLORS.side);
|
||||
set.strokes = paint(COLORS.border);
|
||||
set.cornerRadius = 16;
|
||||
const gap = 32;
|
||||
const width = Math.max(...components.map((node) => node.width));
|
||||
let y = gap;
|
||||
for (let index = 0; index < components.length; index += columns) {
|
||||
const row = components.slice(index, index + columns);
|
||||
row.forEach((node, column) => { node.x = gap + column * (width + gap); node.y = y; });
|
||||
y += Math.max(...row.map((node) => node.height)) + gap;
|
||||
}
|
||||
set.resize(columns * (width + gap) + gap, y);
|
||||
return set;
|
||||
}
|
||||
|
||||
async function connect(source, destination, navigation) {
|
||||
await source.setReactionsAsync([{
|
||||
trigger: { type: "ON_CLICK" },
|
||||
actions: [{
|
||||
type: "NODE",
|
||||
destinationId: destination.id,
|
||||
navigation,
|
||||
...(navigation === "NAVIGATE" ? { resetInteractiveComponents: true } : {}),
|
||||
transition: { type: "DISSOLVE", duration: 0.16, easing: { type: "EASE_OUT" } },
|
||||
}],
|
||||
}]);
|
||||
}
|
||||
|
||||
// ─── 组件定义 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** 看板卡片组件:普通卡片 / 选中发光卡片 / 同事只读卡片 */
|
||||
function createCardComponent(ctx, stateKey) {
|
||||
const selected = stateKey === "selected";
|
||||
const readOnly = stateKey === "readonly";
|
||||
const cardW = COL_W - 16;
|
||||
const node = frame(ctx, `State=${stateKey}`, {
|
||||
component: true,
|
||||
width: cardW,
|
||||
px: 12,
|
||||
py: 8,
|
||||
radius: 8,
|
||||
fill: COLORS.cardBg,
|
||||
stroke: selected ? COLORS.cardSelectedBorder : COLORS.cardBorder,
|
||||
strokeWidth: selected ? 2 : 1,
|
||||
});
|
||||
node.clipsContent = true;
|
||||
node.setSharedPluginData(NAMESPACE, "card_state", stateKey);
|
||||
const innerW = cardW - 24;
|
||||
const content = append(node, frame(ctx, "Content", { dir: "HORIZONTAL", width: innerW, gap: 6, align: "CENTER" }), true);
|
||||
text(ctx, content, "Number", "#458", { hug: true, size: 12, color: COLORS.dim });
|
||||
text(ctx, content, "Title", "✨ 桌面端: 接真实执行链路, 登录即设备...", { size: 12, color: COLORS.cardFg, singleLine: true });
|
||||
if (readOnly) {
|
||||
const avatar = frame(ctx, "OwnerBadge", { width: 16, height: 16, radius: 8, align: "CENTER", justify: "CENTER", fill: COLORS.borderStrong });
|
||||
text(ctx, avatar, "Letter", "C", { hug: true, size: 8, color: COLORS.fgStrong });
|
||||
append(content, avatar);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 底部左侧 Tab 按钮组件:未激活 / 激活 */
|
||||
function createRailTabComponent(ctx, active) {
|
||||
const node = frame(ctx, `Active=${active}`, {
|
||||
component: true,
|
||||
width: RAIL_W,
|
||||
height: 36,
|
||||
});
|
||||
node.setSharedPluginData(NAMESPACE, "tab_active", String(active));
|
||||
node.layoutMode = "NONE";
|
||||
if (active) {
|
||||
const bar = frame(ctx, "ActiveBar", { width: 2, height: 36, fill: COLORS.focus });
|
||||
node.appendChild(bar);
|
||||
bar.x = 0;
|
||||
bar.y = 0;
|
||||
}
|
||||
const glyph = icon(ctx, "message-circle", active ? COLORS.activityOn : COLORS.activityIdle, 16);
|
||||
node.appendChild(glyph);
|
||||
glyph.x = 10;
|
||||
glyph.y = 10;
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 讨论消息组件:系统评论气泡 / 同事发言 / 自己发言 */
|
||||
function createChatMessageComponent(ctx, kind) {
|
||||
const node = frame(ctx, `Kind=${kind}`, {
|
||||
component: true,
|
||||
width: DETAIL_W,
|
||||
px: 24,
|
||||
py: 4,
|
||||
dir: "HORIZONTAL",
|
||||
align: "CENTER",
|
||||
justify: kind === "mine" ? "MAX" : kind === "system" ? "CENTER" : "MIN",
|
||||
});
|
||||
node.setSharedPluginData(NAMESPACE, "message_kind", kind);
|
||||
|
||||
if (kind === "system") {
|
||||
const pill = append(node, frame(ctx, "SystemPill", {
|
||||
dir: "HORIZONTAL",
|
||||
px: 12,
|
||||
py: 4,
|
||||
radius: 999,
|
||||
gap: 8,
|
||||
align: "CENTER",
|
||||
fill: COLORS.systemPill,
|
||||
stroke: COLORS.systemBorder,
|
||||
}));
|
||||
text(ctx, pill, "Body", "系统更新了工单状态", { hug: true, size: 11, color: COLORS.systemFg });
|
||||
text(ctx, pill, "Time", "09:34", { hug: true, size: 10, color: COLORS.dim });
|
||||
return node;
|
||||
}
|
||||
|
||||
if (kind === "theirs") {
|
||||
const avatar = frame(ctx, "Avatar", { width: 24, height: 24, radius: 12, align: "CENTER", justify: "CENTER", fill: COLORS.borderStrong });
|
||||
text(ctx, avatar, "Letter", "C", { hug: true, size: 10, color: COLORS.fgStrong });
|
||||
append(node, avatar);
|
||||
const box = append(node, frame(ctx, "MessageCard", {
|
||||
dir: "VERTICAL",
|
||||
width: 560,
|
||||
gap: 3,
|
||||
px: 12,
|
||||
py: 8,
|
||||
radius: 6,
|
||||
fill: COLORS.theirBubble,
|
||||
stroke: COLORS.theirBorder,
|
||||
}));
|
||||
const meta = append(box, frame(ctx, "Meta", { dir: "HORIZONTAL", gap: 8, align: "CENTER" }), true);
|
||||
text(ctx, meta, "Author", "chehongweo", { hug: true, medium: true, size: 11, color: COLORS.desc });
|
||||
text(ctx, meta, "Time", "14:12", { hug: true, size: 10, color: COLORS.dim });
|
||||
text(ctx, box, "Body", "这是同事评论内容", { hug: true, size: 12, color: COLORS.fgStrong });
|
||||
return node;
|
||||
}
|
||||
|
||||
// kind === "mine"
|
||||
const box = append(node, frame(ctx, "MessageCard", {
|
||||
dir: "VERTICAL",
|
||||
width: 560,
|
||||
gap: 3,
|
||||
px: 12,
|
||||
py: 8,
|
||||
radius: 6,
|
||||
fill: COLORS.myBubble,
|
||||
stroke: COLORS.myBorder,
|
||||
}));
|
||||
const meta = append(box, frame(ctx, "Meta", { dir: "HORIZONTAL", gap: 8, align: "CENTER" }), true);
|
||||
text(ctx, meta, "Author", "cruldra (我)", { hug: true, medium: true, size: 11, color: COLORS.fgStrong });
|
||||
text(ctx, meta, "Time", "18:20", { hug: true, size: 10, color: COLORS.dim });
|
||||
text(ctx, box, "Body", "这是我的评论内容", { hug: true, size: 12, color: COLORS.fgStrong });
|
||||
const avatar = append(node, frame(ctx, "Avatar", { width: 24, height: 24, radius: 12, align: "CENTER", justify: "CENTER", fill: COLORS.focus }));
|
||||
text(ctx, avatar, "Letter", "我", { hug: true, size: 10, color: "#ffffff" });
|
||||
return node;
|
||||
}
|
||||
|
||||
function createComponents(ctx, page) {
|
||||
const cardStates = ["normal", "selected", "readonly"];
|
||||
const cards = new Map(cardStates.map((s) => [s, createCardComponent(ctx, s)]));
|
||||
const cardSet = componentSet(ctx, page, "Kanban / Card", cardStates.map((s) => cards.get(s)), 3);
|
||||
|
||||
const tabs = new Map(["false", "true"].map((active) => [active, createRailTabComponent(ctx, active === "true")]));
|
||||
const tabSet = componentSet(ctx, page, "Rail / TabButton", ["false", "true"].map((k) => tabs.get(k)), 2);
|
||||
|
||||
const msgKinds = ["system", "theirs", "mine"];
|
||||
const messages = new Map(msgKinds.map((k) => [k, createChatMessageComponent(ctx, k)]));
|
||||
const msgSet = componentSet(ctx, page, "Chat / MessageRow", msgKinds.map((k) => messages.get(k)), 1);
|
||||
|
||||
return { cards, tabs, messages, sets: [cardSet, tabSet, msgSet] };
|
||||
}
|
||||
|
||||
// ─── 页面拼装:顶部操作栏 ──────────────────────────────────────────────────
|
||||
|
||||
function buildPanelHeader(ctx, parent) {
|
||||
const bar = append(parent, frame(ctx, "PanelHeader", {
|
||||
dir: "HORIZONTAL",
|
||||
height: HEADER_H,
|
||||
px: 8,
|
||||
align: "CENTER",
|
||||
justify: "SPACE_BETWEEN",
|
||||
fill: COLORS.header,
|
||||
stroke: COLORS.border,
|
||||
}), true);
|
||||
|
||||
// 搜索框
|
||||
const search = append(bar, frame(ctx, "SearchBox", {
|
||||
dir: "HORIZONTAL",
|
||||
width: 220,
|
||||
height: 24,
|
||||
px: 8,
|
||||
gap: 6,
|
||||
radius: 4,
|
||||
align: "CENTER",
|
||||
fill: COLORS.input,
|
||||
stroke: COLORS.inputBorder,
|
||||
}));
|
||||
append(search, icon(ctx, "search", COLORS.desc, 12));
|
||||
text(ctx, search, "Placeholder", "筛选:标题 / 工单号 / PR 号", { hug: true, size: 11, color: COLORS.dim });
|
||||
|
||||
// 右侧快捷按钮
|
||||
const tools = append(bar, frame(ctx, "Tools", { dir: "HORIZONTAL", gap: 4, align: "CENTER" }));
|
||||
const rocket = append(tools, frame(ctx, "BtnRocket", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||
append(rocket, icon(ctx, "rocket", COLORS.fg, 14));
|
||||
const lock = append(tools, frame(ctx, "BtnLock", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||
append(lock, icon(ctx, "lock", COLORS.fg, 14));
|
||||
const refresh = append(tools, frame(ctx, "BtnRefresh", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||
append(refresh, icon(ctx, "refresh-cw", COLORS.fg, 14));
|
||||
const scope = append(tools, frame(ctx, "BtnScope", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||
append(scope, icon(ctx, "user", COLORS.fg, 14));
|
||||
const settings = append(tools, frame(ctx, "BtnSettings", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||
append(settings, icon(ctx, "settings", COLORS.fg, 14));
|
||||
return bar;
|
||||
}
|
||||
|
||||
// ─── 页面拼装:看板区域 ────────────────────────────────────────────────────
|
||||
|
||||
function buildKanbanBoard(ctx, parent, components, activeIssueNumber = 458) {
|
||||
const board = append(parent, frame(ctx, "KanbanBoard", {
|
||||
dir: "HORIZONTAL",
|
||||
height: KANBAN_H,
|
||||
px: 8,
|
||||
py: 8,
|
||||
gap: 8,
|
||||
fill: COLORS.editor,
|
||||
}), true);
|
||||
|
||||
for (const col of COLUMNS) {
|
||||
const colFrame = append(board, frame(ctx, `Col_${col.id}`, {
|
||||
dir: "VERTICAL",
|
||||
width: COL_W,
|
||||
height: KANBAN_H - 16,
|
||||
px: 8,
|
||||
py: 8,
|
||||
gap: 6,
|
||||
radius: 8,
|
||||
fill: COLORS.side,
|
||||
}));
|
||||
|
||||
// 列标题栏
|
||||
const colHeader = append(colFrame, frame(ctx, "Header", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 24,
|
||||
px: 4,
|
||||
align: "CENTER",
|
||||
justify: "SPACE_BETWEEN",
|
||||
}), true);
|
||||
const titleGroup = append(colHeader, frame(ctx, "TitleGroup", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }));
|
||||
text(ctx, titleGroup, "Label", col.label, { hug: true, medium: true, size: 13, color: COLORS.fgStrong });
|
||||
const countBadge = append(titleGroup, frame(ctx, "CountBadge", {
|
||||
dir: "HORIZONTAL",
|
||||
px: 6,
|
||||
py: 1,
|
||||
radius: 999,
|
||||
stroke: COLORS.borderStrong,
|
||||
}));
|
||||
text(ctx, countBadge, "Count", col.count, { hug: true, size: 10, color: COLORS.desc });
|
||||
if (col.hasPlus) {
|
||||
const plusBtn = append(colHeader, frame(ctx, "AddBtn", { width: 20, height: 20, align: "CENTER", justify: "CENTER" }));
|
||||
append(plusBtn, icon(ctx, "plus", COLORS.fg, 13));
|
||||
}
|
||||
|
||||
// 卡片列表
|
||||
const list = append(colFrame, frame(ctx, "CardList", { dir: "VERTICAL", gap: 6 }), true);
|
||||
const cardDefs = CARDS[col.id] || [];
|
||||
for (const item of cardDefs) {
|
||||
const isSelected = item.number === activeIssueNumber;
|
||||
const stateKey = isSelected ? "selected" : item.readOnly ? "readonly" : "normal";
|
||||
const instance = components.cards.get(stateKey).createInstance();
|
||||
instance.name = `Card_${item.number}`;
|
||||
append(list, instance, true);
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Number"), `#${item.number}`);
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Title"), item.title);
|
||||
if (item.readOnly) {
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Letter"), item.owner || "C");
|
||||
}
|
||||
}
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
// ─── 页面拼装:底部 Rail ──────────────────────────────────────────────────
|
||||
|
||||
function buildRail(ctx, parent, activeTabId) {
|
||||
const rail = append(parent, frame(ctx, "BottomRail", {
|
||||
dir: "VERTICAL",
|
||||
width: RAIL_W,
|
||||
height: BOTTOM_H,
|
||||
align: "CENTER",
|
||||
fill: COLORS.side,
|
||||
stroke: COLORS.border,
|
||||
}));
|
||||
|
||||
const tabButtons = {};
|
||||
for (const t of RAIL_TABS) {
|
||||
const on = t.id === activeTabId;
|
||||
const btn = frame(ctx, `Tab_${t.id}`, { width: RAIL_W, height: 36 });
|
||||
btn.layoutMode = "NONE";
|
||||
if (on) {
|
||||
const bar = frame(ctx, "ActiveBar", { width: 2, height: 36, fill: COLORS.focus });
|
||||
btn.appendChild(bar);
|
||||
bar.x = 0;
|
||||
bar.y = 0;
|
||||
}
|
||||
const glyph = icon(ctx, t.icon, on ? COLORS.activityOn : COLORS.activityIdle, 16);
|
||||
btn.appendChild(glyph);
|
||||
glyph.x = 10;
|
||||
glyph.y = 10;
|
||||
rail.appendChild(btn);
|
||||
tabButtons[t.id] = btn;
|
||||
}
|
||||
return { rail, tabButtons };
|
||||
}
|
||||
|
||||
// ─── 页面拼装:工单详情面板(1:1 对齐当前现状)─────────────────────────────
|
||||
|
||||
function buildIssueDetailPane(ctx, parent, issue) {
|
||||
const pane = append(parent, frame(ctx, "IssueDetailPane", {
|
||||
dir: "VERTICAL",
|
||||
width: DETAIL_W,
|
||||
height: BOTTOM_H,
|
||||
fill: COLORS.editor,
|
||||
}));
|
||||
|
||||
// 工单标题栏
|
||||
const titleBar = append(pane, frame(ctx, "TitleBar", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 40,
|
||||
px: 16,
|
||||
align: "CENTER",
|
||||
justify: "SPACE_BETWEEN",
|
||||
stroke: COLORS.border,
|
||||
}), true);
|
||||
|
||||
const left = append(titleBar, frame(ctx, "Left", { dir: "HORIZONTAL", width: DETAIL_W - 120, gap: 8, align: "CENTER" }));
|
||||
text(ctx, left, "IssueTitle", `#${issue.number} ${issue.title}`, { medium: true, size: 13, color: COLORS.fgStrong, singleLine: true });
|
||||
|
||||
const actions = append(titleBar, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }));
|
||||
const openExt = append(actions, frame(ctx, "BtnExternal", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER", stroke: COLORS.border }));
|
||||
append(openExt, icon(ctx, "external-link", COLORS.desc, 13));
|
||||
const handoff = append(actions, frame(ctx, "BtnHandoff", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER", stroke: COLORS.border }));
|
||||
append(handoff, icon(ctx, "user-plus", COLORS.desc, 13));
|
||||
|
||||
// 属性表头:STATE JSON
|
||||
const section = append(pane, frame(ctx, "SectionHeader", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 30,
|
||||
px: 16,
|
||||
align: "CENTER",
|
||||
justify: "SPACE_BETWEEN",
|
||||
stroke: COLORS.border,
|
||||
}), true);
|
||||
const secTitle = append(section, frame(ctx, "SecTitle", { dir: "HORIZONTAL", gap: 4, align: "CENTER" }));
|
||||
text(ctx, secTitle, "SecLabel", "▾ STATE JSON", { hug: true, medium: true, size: 11, color: COLORS.fgStrong });
|
||||
const terminalBtn = append(section, frame(ctx, "BtnTerminal", { width: 20, height: 20, align: "CENTER", justify: "CENTER" }));
|
||||
append(terminalBtn, icon(ctx, "terminal", COLORS.desc, 12));
|
||||
|
||||
// 属性行
|
||||
const rows = [
|
||||
{ key: "状态", value: "in-progress (进行中)", hasIcon: false },
|
||||
{ key: "头脑风暴会话id", value: "86851e67-ddbc-4b41-8b7e-aacb59ea856d", hasIcon: true, iconName: "terminal" },
|
||||
{ key: "实施会话id", value: "16906908-ae58-47c6-9510-fac16d10f929", hasIcon: true, iconName: "terminal" },
|
||||
{ key: "PR", value: "#461", hasIcon: false },
|
||||
{ key: "分支", value: "feature/desktop-runtime-link", hasIcon: false },
|
||||
{ key: "工作树", value: ".../desktop-runtime-link", hasIcon: false },
|
||||
];
|
||||
|
||||
for (const r of rows) {
|
||||
const row = append(pane, frame(ctx, `Row_${r.key}`, {
|
||||
dir: "HORIZONTAL",
|
||||
height: 32,
|
||||
stroke: COLORS.border,
|
||||
align: "CENTER",
|
||||
}), true);
|
||||
|
||||
const kCell = append(row, frame(ctx, "KeyCell", {
|
||||
dir: "HORIZONTAL",
|
||||
width: 240,
|
||||
height: 32,
|
||||
px: 16,
|
||||
align: "CENTER",
|
||||
stroke: COLORS.border,
|
||||
}));
|
||||
text(ctx, kCell, "KeyText", r.key, { hug: true, size: 12, color: COLORS.desc });
|
||||
|
||||
const vCell = append(row, frame(ctx, "ValueCell", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 32,
|
||||
px: 16,
|
||||
gap: 6,
|
||||
align: "CENTER",
|
||||
}), true);
|
||||
if (r.hasIcon && r.iconName) {
|
||||
append(vCell, icon(ctx, r.iconName, COLORS.focus, 12));
|
||||
}
|
||||
text(ctx, vCell, "ValText", r.value, { hug: true, size: 12, color: r.hasIcon ? COLORS.focus : COLORS.fgStrong });
|
||||
}
|
||||
|
||||
return pane;
|
||||
}
|
||||
|
||||
// ─── 页面拼装:工单讨论面板(新功能)───────────────────────────────────────
|
||||
|
||||
function buildIssueChatPane(ctx, parent, components, issue, thread) {
|
||||
const pane = append(parent, frame(ctx, "IssueChatPane", {
|
||||
dir: "VERTICAL",
|
||||
width: DETAIL_W,
|
||||
height: BOTTOM_H,
|
||||
fill: COLORS.editor,
|
||||
}));
|
||||
|
||||
// 标题栏:与工单详情栏结构一致,右边增加刷新评论按钮
|
||||
const titleBar = append(pane, frame(ctx, "TitleBar", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 40,
|
||||
px: 16,
|
||||
align: "CENTER",
|
||||
justify: "SPACE_BETWEEN",
|
||||
stroke: COLORS.border,
|
||||
}), true);
|
||||
|
||||
const left = append(titleBar, frame(ctx, "Left", { dir: "HORIZONTAL", width: DETAIL_W - 120, gap: 8, align: "CENTER" }));
|
||||
text(ctx, left, "IssueTitle", `#${issue.number} ${issue.title}`, { medium: true, size: 13, color: COLORS.fgStrong, singleLine: true });
|
||||
|
||||
const actions = append(titleBar, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }));
|
||||
const refreshBtn = append(actions, frame(ctx, "BtnRefreshComments", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER", stroke: COLORS.border }));
|
||||
append(refreshBtn, icon(ctx, "refresh-cw", COLORS.desc, 13));
|
||||
const openExt = append(actions, frame(ctx, "BtnExternal", { width: 24, height: 24, radius: 4, align: "CENTER", justify: "CENTER", stroke: COLORS.border }));
|
||||
append(openExt, icon(ctx, "external-link", COLORS.desc, 13));
|
||||
|
||||
// 讨论消息列表(高度占满并留给输入框空间)
|
||||
const stream = append(pane, frame(ctx, "MessageStream", {
|
||||
dir: "VERTICAL",
|
||||
height: BOTTOM_H - 40 - 52,
|
||||
py: 12,
|
||||
gap: 6,
|
||||
fill: COLORS.editor,
|
||||
}), true);
|
||||
|
||||
if (thread.length === 0) {
|
||||
const emptyBox = append(stream, frame(ctx, "EmptyBox", {
|
||||
dir: "VERTICAL",
|
||||
align: "CENTER",
|
||||
justify: "CENTER",
|
||||
gap: 8,
|
||||
height: BOTTOM_H - 40 - 52 - 24,
|
||||
}), true);
|
||||
append(emptyBox, icon(ctx, "message-circle", COLORS.dim, 28));
|
||||
text(ctx, emptyBox, "EmptyTitle", "还没有讨论", { hug: true, medium: true, size: 13, color: COLORS.desc });
|
||||
text(ctx, emptyBox, "EmptySub", "在此发起对此工单的讨论,评论将双向同步至 Gitea", { hug: true, size: 11, color: COLORS.dim });
|
||||
} else {
|
||||
for (const item of thread) {
|
||||
const instance = components.messages.get(item.kind).createInstance();
|
||||
instance.name = `Msg_${item.kind}`;
|
||||
append(stream, instance, true);
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Body"), item.body);
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Time"), item.time);
|
||||
if (item.author) {
|
||||
writeText(ctx, instance.findOne((n) => n.name === "Author"), item.kind === "mine" ? "cruldra (我)" : item.author);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部评论输入框
|
||||
const composer = append(pane, frame(ctx, "ComposerBar", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 52,
|
||||
px: 16,
|
||||
gap: 8,
|
||||
align: "CENTER",
|
||||
fill: COLORS.side,
|
||||
stroke: COLORS.border,
|
||||
}), true);
|
||||
|
||||
const inputField = append(composer, frame(ctx, "InputField", {
|
||||
dir: "HORIZONTAL",
|
||||
height: 34,
|
||||
px: 12,
|
||||
align: "CENTER",
|
||||
radius: 6,
|
||||
fill: COLORS.input,
|
||||
stroke: COLORS.inputBorder,
|
||||
}), true);
|
||||
text(ctx, inputField, "InputPlaceholder", "发表对此工单的评论…(支持 Markdown,Enter 发送)", { hug: true, size: 12, color: COLORS.dim });
|
||||
|
||||
const sendBtn = append(composer, frame(ctx, "SendBtn", {
|
||||
dir: "HORIZONTAL",
|
||||
px: 16,
|
||||
height: 32,
|
||||
radius: 4,
|
||||
gap: 6,
|
||||
align: "CENTER",
|
||||
justify: "CENTER",
|
||||
fill: COLORS.focus,
|
||||
}));
|
||||
append(sendBtn, icon(ctx, "send", "#ffffff", 12));
|
||||
text(ctx, sendBtn, "SendLabel", "发送", { hug: true, medium: true, size: 12, color: "#ffffff" });
|
||||
|
||||
return pane;
|
||||
}
|
||||
|
||||
// ─── 生成三大完整画板 ──────────────────────────────────────────────────────
|
||||
|
||||
function createBoards(ctx, page, components) {
|
||||
// 1. 现状:看板 + 工单详情
|
||||
const board1 = frame(ctx, "Prototype / 看板 · 工单详情(现状)", {
|
||||
width: WINDOW_W,
|
||||
height: WINDOW_H,
|
||||
fill: COLORS.editor,
|
||||
});
|
||||
page.appendChild(board1);
|
||||
buildPanelHeader(ctx, board1);
|
||||
buildKanbanBoard(ctx, board1, components, 458);
|
||||
const bottom1 = append(board1, frame(ctx, "BottomTabs", { dir: "HORIZONTAL", width: WINDOW_W, height: BOTTOM_H }), true);
|
||||
const rail1 = buildRail(ctx, bottom1, "issue");
|
||||
buildIssueDetailPane(ctx, bottom1, CARDS["in-progress"][0]);
|
||||
|
||||
// 2. 方案:看板 + 工单讨论(有评论)
|
||||
const board2 = frame(ctx, "Prototype / 看板 · 工单讨论(有评论)", {
|
||||
width: WINDOW_W,
|
||||
height: WINDOW_H,
|
||||
fill: COLORS.editor,
|
||||
});
|
||||
page.appendChild(board2);
|
||||
buildPanelHeader(ctx, board2);
|
||||
buildKanbanBoard(ctx, board2, components, 458);
|
||||
const bottom2 = append(board2, frame(ctx, "BottomTabs", { dir: "HORIZONTAL", width: WINDOW_W, height: BOTTOM_H }), true);
|
||||
const rail2 = buildRail(ctx, bottom2, "chat");
|
||||
buildIssueChatPane(ctx, bottom2, components, CARDS["in-progress"][0], THREAD_458);
|
||||
|
||||
// 3. 方案:看板 + 工单讨论(空状态,选中 #459)
|
||||
const board3 = frame(ctx, "Prototype / 看板 · 工单讨论(空状态)", {
|
||||
width: WINDOW_W,
|
||||
height: WINDOW_H,
|
||||
fill: COLORS.editor,
|
||||
});
|
||||
page.appendChild(board3);
|
||||
buildPanelHeader(ctx, board3);
|
||||
buildKanbanBoard(ctx, board3, components, 459);
|
||||
const bottom3 = append(board3, frame(ctx, "BottomTabs", { dir: "HORIZONTAL", width: WINDOW_W, height: BOTTOM_H }), true);
|
||||
const rail3 = buildRail(ctx, bottom3, "chat");
|
||||
buildIssueChatPane(ctx, bottom3, components, CARDS.todo[0], []);
|
||||
|
||||
return {
|
||||
board1,
|
||||
board2,
|
||||
board3,
|
||||
rail1,
|
||||
rail2,
|
||||
rail3,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 插件主流程 ────────────────────────────────────────────────────────────
|
||||
|
||||
async function build(api, stage = () => {}) {
|
||||
stage("加载中文字体");
|
||||
const ctx = { api, fonts: await chooseFonts(api) };
|
||||
|
||||
stage("新建独立页面");
|
||||
const name = nextPageName(api.root.children);
|
||||
const page = api.createPage();
|
||||
page.name = name;
|
||||
await api.setCurrentPageAsync(page);
|
||||
page.backgrounds = paint(COLORS.side);
|
||||
page.setSharedPluginData(NAMESPACE, "generator", GENERATOR);
|
||||
|
||||
stage("生成组件");
|
||||
const components = createComponents(ctx, page);
|
||||
|
||||
stage("生成演示画板");
|
||||
const { board1, board2, board3, rail1, rail2, rail3 } = createBoards(ctx, page, components);
|
||||
|
||||
stage("连接演示入口");
|
||||
// 点板 1 里的讨论 Tab → 跳到板 2(工单讨论)
|
||||
// 点板 2 里的工单 Tab → 跳回板 1(工单详情)
|
||||
// 点板 3 里的工单 Tab → 跳回板 1(工单详情)
|
||||
await Promise.all([
|
||||
connect(rail1.tabButtons.chat, board2, "NAVIGATE"),
|
||||
connect(rail2.tabButtons.issue, board1, "NAVIGATE"),
|
||||
connect(rail3.tabButtons.issue, board1, "NAVIGATE"),
|
||||
]);
|
||||
|
||||
stage("排布画板");
|
||||
const screens = [board1, board2, board3];
|
||||
let y = 0;
|
||||
for (const s of screens) {
|
||||
s.x = 0;
|
||||
s.y = y;
|
||||
y += s.height + 80;
|
||||
}
|
||||
|
||||
let setY = 0;
|
||||
const setX = WINDOW_W + 120;
|
||||
for (const set of components.sets) {
|
||||
set.x = setX;
|
||||
set.y = setY;
|
||||
setY += set.height + 64;
|
||||
}
|
||||
|
||||
page.flowStartingPoints = [{ nodeId: board2.id, name: "工单讨论演示" }];
|
||||
for (const node of page.children) node.expanded = false;
|
||||
api.viewport.scrollAndZoomIntoView([board2]);
|
||||
|
||||
return { page, components, boards: { board1, board2, board3 }, screens };
|
||||
}
|
||||
|
||||
function timingText(marks, end) {
|
||||
const seconds = (ms) => (ms / 1000).toFixed(1);
|
||||
const parts = marks.map(([label, at], index) => `${label} ${seconds((marks[index + 1] ? marks[index + 1][1] : end) - at)}`);
|
||||
return `用时 ${seconds(end - marks[0][1])} 秒(${parts.join("、")})`;
|
||||
}
|
||||
|
||||
async function run(api) {
|
||||
let currentStage = "初始化";
|
||||
const marks = [];
|
||||
try {
|
||||
await build(api, (label) => {
|
||||
currentStage = label;
|
||||
marks.push([label, Date.now()]);
|
||||
});
|
||||
api.closePlugin(`已生成 1:1 看板与讨论原型,${timingText(marks, Date.now())};选中画板后点演示。`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
api.closePlugin(`生成失败(${currentStage}):${message}。已有设计未删除;本次未完成页面保留供检查。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
build,
|
||||
run,
|
||||
nextPageName,
|
||||
PAGE_NAME,
|
||||
NAMESPACE,
|
||||
COLUMNS,
|
||||
CARDS,
|
||||
THREAD_458,
|
||||
RAIL_TABS,
|
||||
};
|
||||
}
|
||||
if (typeof figma !== "undefined") void run(figma);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "工单讨论",
|
||||
"api": "1.0.0",
|
||||
"main": "code.js",
|
||||
"editorType": [
|
||||
"figma"
|
||||
],
|
||||
"documentAccess": "dynamic-page",
|
||||
"networkAccess": {
|
||||
"allowedDomains": [
|
||||
"none"
|
||||
]
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { test } = require("node:test");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
build,
|
||||
run,
|
||||
nextPageName,
|
||||
PAGE_NAME,
|
||||
NAMESPACE,
|
||||
COLUMNS,
|
||||
CARDS,
|
||||
THREAD_458,
|
||||
} = require("./code.js");
|
||||
const { fakeFigma } = require("../../figma-lab/fake-figma");
|
||||
|
||||
function action(node) { return node.reactions[0].actions[0]; }
|
||||
function overlaps(a, b) {
|
||||
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
||||
}
|
||||
function layersOutsideInstances(page) {
|
||||
let count = 0;
|
||||
const walk = (node) => {
|
||||
for (const child of node.children) {
|
||||
count += 1;
|
||||
if (child.type !== "INSTANCE") walk(child);
|
||||
}
|
||||
};
|
||||
walk(page);
|
||||
return count;
|
||||
}
|
||||
const EXPECTED_LINKS = 3;
|
||||
|
||||
test("manifest stays local, without a fabricated plugin ID", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.main, "code.js");
|
||||
assert.equal(manifest.api, "1.0.0");
|
||||
assert.equal(manifest.documentAccess, "dynamic-page");
|
||||
assert.deepEqual(manifest.editorType, ["figma"]);
|
||||
assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]);
|
||||
assert.equal("id" in manifest, false);
|
||||
assert.match(NAMESPACE, /^[A-Za-z0-9_.]+$/);
|
||||
});
|
||||
|
||||
test("kanban data matches live state", () => {
|
||||
assert.equal(COLUMNS.length, 4);
|
||||
assert.equal(CARDS["in-progress"][0].number, 458);
|
||||
assert.equal(THREAD_458.length, 5);
|
||||
assert.equal(THREAD_458[0].kind, "system");
|
||||
assert.equal(THREAD_458[0].body, "chehongweo 把工单放到待办");
|
||||
});
|
||||
|
||||
test("build creates variant sets, reuses them as instances and wires navigation", async () => {
|
||||
const api = fakeFigma();
|
||||
const result = await build(api);
|
||||
assert.equal(result.page.name, PAGE_NAME);
|
||||
assert.equal(result.page.getSharedPluginData(NAMESPACE, "generator") !== "", true);
|
||||
assert.equal(api.connected, EXPECTED_LINKS);
|
||||
|
||||
const sets = result.page.children.filter((node) => node.type === "COMPONENT_SET");
|
||||
assert.deepEqual(sets.map((set) => [set.name, set.children.length]), [
|
||||
["Kanban / Card", 3],
|
||||
["Rail / TabButton", 2],
|
||||
["Chat / MessageRow", 3],
|
||||
]);
|
||||
|
||||
// 连线检查
|
||||
const chatTabOnBoard1 = result.boards.board1.findOne((n) => n.name === "Tab_chat");
|
||||
const issueTabOnBoard2 = result.boards.board2.findOne((n) => n.name === "Tab_issue");
|
||||
assert.equal(action(chatTabOnBoard1).destinationId, result.boards.board2.id);
|
||||
assert.equal(action(issueTabOnBoard2).destinationId, result.boards.board1.id);
|
||||
|
||||
// 画板内实例引用检查
|
||||
for (const screen of result.screens) {
|
||||
const instances = screen.findAll((node) => node.type === "INSTANCE");
|
||||
assert.ok(instances.length > 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("boards and sets sit on the page without overlapping", async () => {
|
||||
const api = fakeFigma();
|
||||
const result = await build(api);
|
||||
const blocks = result.page.children.filter((node) => node.type !== "COMPONENT");
|
||||
for (let i = 0; i < blocks.length; i += 1) {
|
||||
for (let j = i + 1; j < blocks.length; j += 1) {
|
||||
assert.equal(overlaps(blocks[i], blocks[j]), false, `${blocks[i].name} overlaps ${blocks[j].name}`);
|
||||
}
|
||||
}
|
||||
assert.equal(result.page.flowStartingPoints[0].nodeId, result.boards.board2.id);
|
||||
assert.ok(layersOutsideInstances(result.page) < 3000);
|
||||
});
|
||||
|
||||
test("running twice numbers the new page and keeps the old one intact", async () => {
|
||||
const api = fakeFigma();
|
||||
const first = await build(api);
|
||||
const cardTitle = first.boards.board1.findOne((node) => node.name === "Title");
|
||||
cardTitle.characters = "用户改过的卡片";
|
||||
const second = await build(api);
|
||||
assert.equal(second.page.name, `${PAGE_NAME} 2`);
|
||||
assert.equal(cardTitle.characters, "用户改过的卡片");
|
||||
assert.equal(nextPageName(api.root.children), `${PAGE_NAME} 3`);
|
||||
});
|
||||
|
||||
test("missing CJK font fails before any page is created", async () => {
|
||||
const api = fakeFigma({ fonts: [{ family: "Inter", style: "Regular" }] });
|
||||
const pages = api.root.children.length;
|
||||
await run(api);
|
||||
assert.equal(api.root.children.length, pages);
|
||||
assert.match(api.messages[0], /加载中文字体/);
|
||||
});
|
||||
|
||||
test("plugin source survives Figma's load-time scan", () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, "code.js"), "utf8");
|
||||
assert.equal(source.includes("import("), false);
|
||||
assert.equal(source.includes("<!--"), false);
|
||||
assert.equal(source.includes("-->"), false);
|
||||
});
|
||||
Reference in new issue
Block a user