✨ 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);
});
+6
View File
@@ -44,6 +44,12 @@ export interface GiteaComment {
issue_url: string
created_at: string
updated_at: string
user?: {
id: number
login: string
full_name?: string
avatar_url?: string
}
}
export class GiteaApiError extends Error {
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { systemCommentText } from './issueComment'
describe('systemCommentText', () => {
it('列评论变成一句系统话', () => {
expect(systemCommentText('{"column":"todo"}')).toBe('把工单放到待办')
})
it('带别的状态字段时仍只说列', () => {
expect(systemCommentText('{"column":"review","color":"terminal.ansiBlue"}')).toBe('更新了工单状态,放到审查')
})
it('普通评论不是系统消息', () => {
expect(systemCommentText('这个方案可以')).toBeUndefined()
})
it('审查评论去掉标记,剩下的正文当系统消息', () => {
expect(systemCommentText('<!-- spx:review=1 -->\n看过了')).toBe('看过了')
expect(systemCommentText('<!-- spx:review=1 -->')).toBe('提交了审查')
})
})
+53
View File
@@ -0,0 +1,53 @@
const COLUMN_LABEL: Record<string, string> = {
'todo': '待办',
'in-progress': '进行中',
'review': '审查',
'done': '完成',
}
const STATE_KEYS = new Set([
'column',
'sessionId',
'implementSessionId',
'reviewSessionId',
'testSessionId',
'prototypeSessionId',
'handoffAttachmentId',
'handoffFrom',
'handoffRequestFrom',
'handoffRequestError',
'pr',
'branch',
'worktreePath',
'color',
'autoReview',
])
/** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */
export function systemCommentText(body: string): string | undefined {
const trimmed = body.trim()
if (/<!--\s*spx:review=1\s*-->/i.test(trimmed)) {
const text = trimmed.replace(/<!--\s*spx:review=1\s*-->/gi, '').trim()
return text.length > 0 ? text : '提交了审查'
}
if (!trimmed.startsWith('{'))
return undefined
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
}
catch {
return undefined
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return undefined
const record = parsed as Record<string, unknown>
if (!Object.keys(record).some(key => STATE_KEYS.has(key)))
return undefined
const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined
if (Object.keys(record).length === 1 && column)
return `把工单放到${column}`
if (column)
return `更新了工单状态,放到${column}`
return '更新了工单状态'
}
+9
View File
@@ -29,6 +29,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
import * as handoffFlow from './handlers/handoffFlow'
import * as issueChat from './handlers/issueChat'
import * as issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions'
import * as prFiles from './handlers/prFiles'
@@ -728,6 +729,14 @@ export class KanbanWebviewPanel {
}
if (msg.type === 'pr-diff-mode/set') {
void prFiles.handleSetPrDiffMode(this, msg.mode)
return
}
if (msg.type === 'issue/comments-query') {
void issueChat.handleIssueComments(this, msg.issueNumber)
return
}
if (msg.type === 'issue/comment-post') {
void issueChat.handleIssueCommentPost(this, msg.issueNumber, msg.body)
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { GiteaComment } from '../../gitea/api'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { listIssueComments, postIssueComment } from '../../gitea/api'
import { systemCommentText } from '../../gitea/issueComment'
import { logger } from '../../logging/logger'
import { resolveRepoCtx, toast } from './handoffFlow'
export interface IssueChatComment {
id: number
author: string
authorName?: string
avatarUrl?: string
createdAt: string
body: string
systemText?: string
}
function toChat(comment: GiteaComment): IssueChatComment {
const systemText = systemCommentText(comment.body)
return {
id: comment.id,
author: comment.user?.login ?? '未知',
authorName: comment.user?.full_name || undefined,
avatarUrl: comment.user?.avatar_url || undefined,
createdAt: comment.created_at,
body: comment.body,
...(systemText ? { systemText } : {}),
}
}
async function pushComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const comments = await listIssueComments({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
})
panel.postMessage({
type: 'issue/comments-result',
issueNumber,
comments: comments.map(toChat),
})
}
export async function handleIssueComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
await pushComments(panel, issueNumber)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 评论失败`, details: message })
panel.postMessage({ type: 'issue/comments-result', issueNumber, comments: [], error: message })
}
}
export async function handleIssueCommentPost(panel: KanbanWebviewPanel, issueNumber: number, body: string): Promise<void> {
const text = body.trim()
if (!text)
return
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
try {
await postIssueComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
body: text,
})
await pushComments(panel, issueNumber)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `发表工单 #${issueNumber} 评论失败`, details: message })
toast(panel, 'error', `评论没发出去:${message}`)
}
}
+5
View File
@@ -3,9 +3,11 @@ import type { Issue, IssueColumn } from '../gitea/types'
import type { LogEntry } from '../logging/logger'
import type { ProfilesData } from '../profiles/store'
import type { ManagedSession } from '../sessions/managedStore'
import type { IssueChatComment } from './handlers/issueChat'
export type { LogEntry } from '../logging/logger'
export type { ProfileRow, ProfilesData } from '../profiles/store'
export type { IssueChatComment } from './handlers/issueChat'
/**
* `managed-sessions/show` 的 payload 类型:在持久化的 ManagedSession 基础上
@@ -145,6 +147,7 @@ export type ExtensionToWebview
// profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。
| { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string }
| { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string }
| { type: 'issue/comments-result', issueNumber: number, comments: IssueChatComment[], error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -264,3 +267,5 @@ export type WebviewToExtension
// profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。
| { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string }
| { type: 'profile-asset/load', requestId: string, assetPath: string }
| { type: 'issue/comments-query', issueNumber: number }
| { type: 'issue/comment-post', issueNumber: number, body: string }
+8
View File
@@ -25,6 +25,7 @@ import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger'
import * as issueChat from '../panel/handlers/issueChat'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
@@ -1422,6 +1423,10 @@ class WebhookCoordinator {
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
details: body.slice(0, 200),
})
if (this.activePanel && typeof event.issueNumber === 'number') {
void issueChat.handleIssueComments(this.activePanel, event.issueNumber)
}
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
@@ -1492,6 +1497,9 @@ class WebhookCoordinator {
source: 'webhook',
message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`,
})
if (this.activePanel) {
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
}
}
else {
logger.add({
@@ -13,14 +13,15 @@
import type { ClaudeProfile } from '../hooks/useIssues'
import type { ProfilesData, SessionHandoffPendingItem } from '../lib/messages'
import type { Issue, ManagedSessionsData } from '../types'
import { ClipboardList, FileDiff, Layers, MessagesSquare } from 'lucide-react'
import { ClipboardList, FileDiff, Layers, MessageCircle, MessagesSquare } from 'lucide-react'
import { useState } from 'react'
import { IssueChatPanel } from './IssueChatPanel'
import { IssueDetailPanel } from './IssueDetailPanel'
import { ManagedSessionsPanel } from './ManagedSessionsPanel'
import { PrFilesPanel } from './PrFilesPanel'
import { ProfileGrid } from './ProfileGrid'
type TabKey = 'issue' | 'profile' | 'sessions' | 'changes'
type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
interface BottomTabsProps {
// Issue tab props (pass-through to IssueDetailPanel)
@@ -103,6 +104,12 @@ export function BottomTabs(props: BottomTabsProps) {
icon={<ClipboardList className="size-4" />}
title="工单"
/>
<TabButton
active={tab === 'chat'}
onClick={() => setTab('chat')}
icon={<MessageCircle className="size-4" />}
title="讨论"
/>
<TabButton
active={tab === 'profile'}
onClick={() => setTab('profile')}
@@ -177,6 +184,18 @@ export function BottomTabs(props: BottomTabsProps) {
isHandoffRunning={props.isHandoffRunning}
/>
</div>
<div
className={`h-full w-full ${tab === 'chat' ? '' : 'hidden'}`}
aria-hidden={tab !== 'chat'}
inert={tab !== 'chat' ? true : undefined}
>
<IssueChatPanel
issue={props.issue}
me={props.me}
avatars={props.avatars}
onOpenInBrowser={props.onOpenInBrowser}
/>
</div>
<div
className={`h-full w-full ${tab === 'profile' ? '' : 'hidden'}`}
aria-hidden={tab !== 'profile'}
@@ -0,0 +1,295 @@
import type { IssueChatComment } from '../lib/messages'
import type { Issue } from '../types'
import { ExternalLink, Loader2, MessageCircle, RotateCw, Send } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { onMessage, postMessage } from '../lib/vscode'
export interface IssueChatPanelProps {
issue: Issue | null
me?: string
avatars?: Record<string, string>
onOpenInBrowser: (url: string) => void
}
function formatCommentTime(isoStr: string): string {
try {
const d = new Date(isoStr)
if (Number.isNaN(d.getTime()))
return isoStr
const now = new Date()
const isToday = d.toDateString() === now.toDateString()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
if (isToday)
return time
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${time}`
}
catch {
return isoStr
}
}
function SystemMessagePill({ comment }: { comment: IssueChatComment }) {
const timeStr = formatCommentTime(comment.createdAt)
const authorPrefix = comment.author && comment.author !== '未知' ? `${comment.author} ` : ''
const displayText = `${authorPrefix}${comment.systemText}`
return (
<div className="my-1 flex justify-center">
<div
className="inline-flex items-center gap-2 rounded-full border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-badge-background,#262626)] px-3 py-1 text-[11px] text-[var(--vscode-descriptionForeground,#9d9d9d)] shadow-sm"
data-tooltip={comment.body}
>
<span>{displayText}</span>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
</div>
</div>
)
}
function ChatMessageBubble({
comment,
isMine,
avatarSrc,
}: {
comment: IssueChatComment
isMine: boolean
avatarSrc?: string
}) {
const timeStr = formatCommentTime(comment.createdAt)
if (isMine) {
return (
<div className="flex items-start justify-end gap-2 pl-8">
<div className="flex max-w-[85%] flex-col items-end">
<div className="mb-1 flex items-center gap-2">
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
<span className="text-xs font-medium text-[var(--vscode-foreground)]">
{comment.author}
{' '}
(我)
</span>
</div>
<div className="whitespace-pre-wrap break-words rounded-md border border-blue-500/50 bg-[#0e3753] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
{comment.body}
</div>
</div>
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-focusBorder,#0078d4)] text-[10px] font-medium text-white">
{avatarSrc
? (
<img src={avatarSrc} alt="" className="size-full object-cover" />
)
: (
'我'
)}
</div>
</div>
)
}
return (
<div className="flex items-start justify-start gap-2 pr-8">
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-button-secondaryBackground,#3c3c3c)] text-[10px] font-medium text-[var(--vscode-foreground)]">
{avatarSrc
? (
<img src={avatarSrc} alt="" className="size-full object-cover" />
)
: (
comment.author.slice(0, 1).toUpperCase()
)}
</div>
<div className="flex max-w-[85%] flex-col items-start">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-medium text-[var(--vscode-descriptionForeground)]">
{comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author}
</span>
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
</div>
<div className="whitespace-pre-wrap break-words rounded-md border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-sideBar-background,#262626)] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
{comment.body}
</div>
</div>
</div>
)
}
export function IssueChatPanel({
issue,
me,
avatars,
onOpenInBrowser,
}: IssueChatPanelProps) {
const [comments, setComments] = useState<IssueChatComment[]>([])
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [input, setInput] = useState('')
const streamEndRef = useRef<HTMLDivElement>(null)
const issueNumber = issue?.number
useEffect(() => {
if (issueNumber == null) {
setComments([])
setLoading(false)
return
}
setLoading(true)
postMessage({
type: 'issue/comments-query',
issueNumber,
})
}, [issueNumber])
useEffect(() => {
const dispose = onMessage((msg) => {
if (msg.type === 'issue/comments-result') {
if (issueNumber != null && msg.issueNumber === issueNumber) {
setLoading(false)
setSending(false)
setComments(msg.comments)
}
}
})
return dispose
}, [issueNumber])
useEffect(() => {
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [comments])
const handleRefresh = () => {
if (issueNumber == null || loading)
return
setLoading(true)
postMessage({
type: 'issue/comments-query',
issueNumber,
})
}
const handleSend = () => {
const text = input.trim()
if (!text || issueNumber == null || sending)
return
setSending(true)
postMessage({
type: 'issue/comment-post',
issueNumber,
body: text,
})
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.metaKey || e.ctrlKey || !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
}
if (!issue) {
return (
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
按方向键或点击卡片查看讨论
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-col border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)]">
{/* 标题栏:对齐 IssueDetailPanel */}
<div className="flex items-center gap-3 border-b border-[var(--vscode-panel-border)] p-3">
<h3 className="flex-1 truncate text-sm font-medium">
<span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span>
{issue.source === 'youtrack' && (
<span
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
data-tooltip="此工单来自 YouTrack"
>
YouTrack
</span>
)}
{' '}
{issue.title}
</h3>
<button
type="button"
data-tooltip="刷新讨论"
aria-label="刷新讨论"
onClick={handleRefresh}
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<RotateCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
data-tooltip={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
aria-label={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
</button>
</div>
{/* 评论消息流 */}
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3">
{comments.length === 0 && !loading && (
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2 p-6 text-[var(--vscode-descriptionForeground)]">
<MessageCircle className="size-8 opacity-40" />
<span className="text-sm font-medium">还没有讨论</span>
<span className="text-xs opacity-60">在此发起对此工单的讨论,评论将双向同步至 Gitea</span>
</div>
)}
{comments.map((comment) => {
if (comment.systemText) {
return <SystemMessagePill key={comment.id} comment={comment} />
}
const isMine = !!(me && comment.author === me)
const avatarSrc = avatars?.[comment.author] || comment.avatarUrl
return (
<ChatMessageBubble
key={comment.id}
comment={comment}
isMine={isMine}
avatarSrc={avatarSrc}
/>
)
})}
<div ref={streamEndRef} />
</div>
{/* 底部输入栏 */}
<div className="flex items-end gap-2 border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))] p-2.5">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="发表对此工单的评论…(支持 Markdown,Enter 发送)"
rows={1}
className="max-h-24 min-h-[32px] flex-1 resize-none rounded border border-[var(--vscode-input-border)] bg-[var(--vscode-input-background)] px-2.5 py-1.5 text-xs leading-normal text-[var(--vscode-input-foreground)] placeholder:text-[var(--vscode-input-placeholderForeground)] focus:border-[var(--vscode-focusBorder)] focus:outline-none"
/>
<button
type="button"
onClick={handleSend}
disabled={!input.trim() || sending}
className="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded bg-[var(--vscode-button-background)] px-3 text-xs font-medium text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:opacity-50"
>
{sending
? (
<Loader2 className="size-3.5 animate-spin" />
)
: (
<Send className="size-3.5" />
)}
<span>发送</span>
</button>
</div>
</div>
)
}
+13
View File
@@ -42,6 +42,16 @@ export interface ReleaseCommitGroup {
export type ToastLevel = 'info' | 'success' | 'error'
export interface IssueChatComment {
id: number
author: string
authorName?: string
avatarUrl?: string
createdAt: string
body: string
systemText?: string
}
export interface SessionHandoffPendingItem {
attachmentId: number
sid: string
@@ -176,6 +186,7 @@ export type ExtensionToWebview
// profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。
| { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string }
| { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string }
| { type: 'issue/comments-result', issueNumber: number, comments: IssueChatComment[], error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -295,3 +306,5 @@ export type WebviewToExtension
// profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。
| { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string }
| { type: 'profile-asset/load', requestId: string, assetPath: string }
| { type: 'issue/comments-query', issueNumber: number }
| { type: 'issue/comment-post', issueNumber: number, body: string }