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