From df1782140898933125b2812293b30ebfc5d0660f Mon Sep 17 00:00:00 2001 From: cruldra Date: Mon, 28 Sep 2026 20:42:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E5=BA=95=E6=A0=8F=E6=96=B0=E5=A2=9E=E5=B7=A5=E5=8D=95=E8=AE=A8?= =?UTF-8?q?=E8=AE=BA=20Tab=20=E5=B9=B6=E6=94=AF=E6=8C=81=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=B0=94=E6=B3=A1=E4=B8=8E=20Gitea=20=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issue-chat/figma-plugin/README.md | 36 + .../prototype/issue-chat/figma-plugin/code.js | 874 ++++++++++++++++++ .../issue-chat/figma-plugin/manifest.json | 14 + .../prototype/issue-chat/figma-plugin/test.js | 117 +++ vscode/src/gitea/api.ts | 6 + vscode/src/gitea/issueComment.test.ts | 21 + vscode/src/gitea/issueComment.ts | 53 ++ vscode/src/panel/KanbanPanel.ts | 9 + vscode/src/panel/handlers/issueChat.ts | 83 ++ vscode/src/panel/messages.ts | 5 + vscode/src/webhook/coordinator.ts | 8 + .../webview-ui/src/components/BottomTabs.tsx | 23 +- .../src/components/IssueChatPanel.tsx | 295 ++++++ vscode/webview-ui/src/lib/messages.ts | 13 + 14 files changed, 1555 insertions(+), 2 deletions(-) create mode 100644 docs/prototype/issue-chat/figma-plugin/README.md create mode 100644 docs/prototype/issue-chat/figma-plugin/code.js create mode 100644 docs/prototype/issue-chat/figma-plugin/manifest.json create mode 100644 docs/prototype/issue-chat/figma-plugin/test.js create mode 100644 vscode/src/gitea/issueComment.test.ts create mode 100644 vscode/src/gitea/issueComment.ts create mode 100644 vscode/src/panel/handlers/issueChat.ts create mode 100644 vscode/webview-ui/src/components/IssueChatPanel.tsx diff --git a/docs/prototype/issue-chat/figma-plugin/README.md b/docs/prototype/issue-chat/figma-plugin/README.md new file mode 100644 index 0000000..0b7a2e5 --- /dev/null +++ b/docs/prototype/issue-chat/figma-plugin/README.md @@ -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 +``` diff --git a/docs/prototype/issue-chat/figma-plugin/code.js b/docs/prototype/issue-chat/figma-plugin/code.js new file mode 100644 index 0000000..dd220a4 --- /dev/null +++ b/docs/prototype/issue-chat/figma-plugin/code.js @@ -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 = `${body}`; + 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); diff --git a/docs/prototype/issue-chat/figma-plugin/manifest.json b/docs/prototype/issue-chat/figma-plugin/manifest.json new file mode 100644 index 0000000..5525fd5 --- /dev/null +++ b/docs/prototype/issue-chat/figma-plugin/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "工单讨论", + "api": "1.0.0", + "main": "code.js", + "editorType": [ + "figma" + ], + "documentAccess": "dynamic-page", + "networkAccess": { + "allowedDomains": [ + "none" + ] + } +} \ No newline at end of file diff --git a/docs/prototype/issue-chat/figma-plugin/test.js b/docs/prototype/issue-chat/figma-plugin/test.js new file mode 100644 index 0000000..22cfa68 --- /dev/null +++ b/docs/prototype/issue-chat/figma-plugin/test.js @@ -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); +}); diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 2b7bb9b..90ed0d2 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -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 { diff --git a/vscode/src/gitea/issueComment.test.ts b/vscode/src/gitea/issueComment.test.ts new file mode 100644 index 0000000..a8d708e --- /dev/null +++ b/vscode/src/gitea/issueComment.test.ts @@ -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('\n看过了')).toBe('看过了') + expect(systemCommentText('')).toBe('提交了审查') + }) +}) diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/gitea/issueComment.ts new file mode 100644 index 0000000..9504bb3 --- /dev/null +++ b/vscode/src/gitea/issueComment.ts @@ -0,0 +1,53 @@ +const COLUMN_LABEL: Record = { + '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 (//i.test(trimmed)) { + const text = trimmed.replace(//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 + 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 '更新了工单状态' +} diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index 71611e3..f627e12 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -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) } } diff --git a/vscode/src/panel/handlers/issueChat.ts b/vscode/src/panel/handlers/issueChat.ts new file mode 100644 index 0000000..5298db7 --- /dev/null +++ b/vscode/src/panel/handlers/issueChat.ts @@ -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 { + 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 { + 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 { + 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}`) + } +} diff --git a/vscode/src/panel/messages.ts b/vscode/src/panel/messages.ts index ea4acb2..661baf7 100644 --- a/vscode/src/panel/messages.ts +++ b/vscode/src/panel/messages.ts @@ -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 } diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 9d22feb..16bf51b 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -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({ diff --git a/vscode/webview-ui/src/components/BottomTabs.tsx b/vscode/webview-ui/src/components/BottomTabs.tsx index 3f02247..5115c4a 100644 --- a/vscode/webview-ui/src/components/BottomTabs.tsx +++ b/vscode/webview-ui/src/components/BottomTabs.tsx @@ -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={} title="工单" /> + setTab('chat')} + icon={} + title="讨论" + /> setTab('profile')} @@ -177,6 +184,18 @@ export function BottomTabs(props: BottomTabsProps) { isHandoffRunning={props.isHandoffRunning} /> +
+ +
+ 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 ( +
+
+ {displayText} + {timeStr} +
+
+ ) +} + +function ChatMessageBubble({ + comment, + isMine, + avatarSrc, +}: { + comment: IssueChatComment + isMine: boolean + avatarSrc?: string +}) { + const timeStr = formatCommentTime(comment.createdAt) + + if (isMine) { + return ( +
+
+
+ {timeStr} + + {comment.author} + {' '} + (我) + +
+
+ {comment.body} +
+
+
+ {avatarSrc + ? ( + + ) + : ( + '我' + )} +
+
+ ) + } + + return ( +
+
+ {avatarSrc + ? ( + + ) + : ( + comment.author.slice(0, 1).toUpperCase() + )} +
+
+
+ + {comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author} + + {timeStr} +
+
+ {comment.body} +
+
+
+ ) +} + +export function IssueChatPanel({ + issue, + me, + avatars, + onOpenInBrowser, +}: IssueChatPanelProps) { + const [comments, setComments] = useState([]) + const [loading, setLoading] = useState(false) + const [sending, setSending] = useState(false) + const [input, setInput] = useState('') + const streamEndRef = useRef(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) => { + if (e.key === 'Enter') { + if (e.metaKey || e.ctrlKey || !e.shiftKey) { + e.preventDefault() + handleSend() + } + } + } + + if (!issue) { + return ( +
+ 按方向键或点击卡片查看讨论 +
+ ) + } + + return ( +
+ {/* 标题栏:对齐 IssueDetailPanel */} +
+

+ + {issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`} + + {issue.source === 'youtrack' && ( + + YouTrack + + )} + {' '} + {issue.title} +

+ + +
+ + {/* 评论消息流 */} +
+ {comments.length === 0 && !loading && ( +
+ + 还没有讨论 + 在此发起对此工单的讨论,评论将双向同步至 Gitea +
+ )} + {comments.map((comment) => { + if (comment.systemText) { + return + } + const isMine = !!(me && comment.author === me) + const avatarSrc = avatars?.[comment.author] || comment.avatarUrl + return ( + + ) + })} +
+
+ + {/* 底部输入栏 */} +
+