✨ feat(vscode): 看板底栏新增工单讨论 Tab 并支持系统气泡与 Gitea 评论同步

This commit is contained in:
cruldra committed 2026-09-29 04:23:57 +08:00
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
View File
@@ -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
View File
@@ -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);
});