✨ feat(vscode): 看板默认显示团队工单,同事的卡只读并贴头像

看板范围默认从「只看我的」改为「团队全部」,同事移交或推进的工单不用
手动切开关就能看见。同事的卡片按 Gitea 归属(assignee 优先,无 assignee
退回创建者)判定为只读:禁止拖列、开会话、实施、合并分支、删 worktree、
删改工单,卡片右上角贴归属人头像,详情面板属性表整体消毒并显示只读徽章。
看 PR 改动、勾已查看、生成文件摘要属于本机审查动作,照常放行。

扩展侧在消息分发前统一拦截需要归属人身份的消息,兜住前端快照过期或工单
刚被重新指派的竞态;webhook 单卡上板时同步归属,避免增量卡漏进门禁。
头像由扩展侧带 token 抓回转 data URI 下发,绕开 webview CSP 对外链图片
的限制,按 URL 进程内缓存。

Claude-Session: https://claude.ai/code/session_01KSgcQwf1mWrb8qeTKFvJbV
This commit is contained in:
cruldra committed 2026-09-10 15:30:02 +08:00
1 parent e8963ac2a2
commit 9167b86708
26 files changed
+550 -34

No files matched your search

+2 -2
View File
@@ -31,8 +31,8 @@ export interface GiteaIssue {
updated_at: string
body: string
html_url: string
user: { login: string } | null
assignees: Array<{ login: string }> | null
user: { login: string, avatar_url?: string } | null
assignees: Array<{ login: string, avatar_url?: string }> | null
labels: Array<{ name: string, color: string }> | null
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 头像取回:webview 的 CSP 只放行扩展自身资源与 `data:`,直接把 Gitea 的
* avatar_url 塞进 <img> 会被拦掉(私有实例的头像还可能要带 token)。所以
* 统一由扩展侧抓回来转成 data URI 再下发。
*
* 按 URL 进程内缓存:同一批工单里重复出现的同事只下载一次,刷新看板也复用。
* 任何失败都缓存成「无头像」,前端回退到首字母圆点,不重试拖慢加载。
*/
import { Buffer } from 'node:buffer'
/** 单张头像的体积上限;Gitea 头像通常 <20KB,超限的直接当无头像。 */
const MAX_BYTES = 256 * 1024
const cache = new Map<string, string | undefined>()
/** 抓单张头像并转 data URI;失败或超限返回 undefined。 */
async function fetchAvatar(url: string, token: string): Promise<string | undefined> {
try {
const res = await fetch(url, { headers: { Authorization: `token ${token}` } })
if (!res.ok)
return undefined
const buf = await res.arrayBuffer()
if (buf.byteLength === 0 || buf.byteLength > MAX_BYTES)
return undefined
const mime = res.headers.get('content-type')?.split(';')[0].trim() || 'image/png'
if (!mime.startsWith('image/'))
return undefined
return `data:${mime};base64,${Buffer.from(buf).toString('base64')}`
}
catch {
return undefined
}
}
/**
* 把 `login -> avatar_url` 解析成 `login -> data URI`。没有 URL、抓取失败
* 的 login 不出现在结果里。
*/
export async function resolveAvatars(
entries: Iterable<readonly [login: string, avatarUrl: string | undefined]>,
token: string,
): Promise<Record<string, string>> {
const wanted = new Map<string, string>()
for (const [login, url] of entries) {
if (login && url && !wanted.has(login))
wanted.set(login, url)
}
const pairs = await Promise.all([...wanted].map(async ([login, url]) => {
if (!cache.has(url))
cache.set(url, await fetchAvatar(url, token))
const dataUri = cache.get(url)
return dataUri ? [login, dataUri] as const : undefined
}))
const out: Record<string, string> = {}
for (const pair of pairs) {
if (pair)
out[pair[0]] = pair[1]
}
return out
}
+13 -2
View File
@@ -26,6 +26,7 @@ import {
PAGE_SIZE,
postIssueComment,
} from './api'
import { resolveAvatars } from './avatars'
const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
@@ -413,6 +414,7 @@ async function buildIssue(opts: {
...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}),
assignees: (issue.assignees ?? []).map(a => a.login),
...(issue.user?.login ? { poster: issue.user.login } : {}),
htmlUrl: issue.html_url,
}
}
@@ -426,7 +428,7 @@ export async function loadIssues(opts: {
workspaceRoot?: string
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
scope?: 'mine' | 'all'
}): Promise<{ issues: Issue[], me: string }> {
}): Promise<{ issues: Issue[], me: string, avatars: Record<string, string> }> {
const { host, token, owner, repo, workspaceRoot } = opts
// ① 工单列表 + 当前用户
@@ -522,7 +524,16 @@ export async function loadIssues(opts: {
}))
}
return { issues: resolved, me }
// 团队视图下卡片按人贴头像;抓不到头像不算失败,前端回退首字母。
const avatars = await resolveAvatars(
merged.flatMap(issue => [
...(issue.assignees ?? []).map(a => [a.login, a.avatar_url] as const),
...(issue.user ? [[issue.user.login, issue.user.avatar_url] as const] : []),
]),
token,
)
return { issues: resolved, me, avatars }
}
/**
+2
View File
@@ -118,6 +118,8 @@ export interface Issue {
autoReview?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
poster?: string
/** 移交会话包的 Gitea 附件 id(字符串);存在即「待接管」。 */
handoffAttachmentId?: string
/** 发起移交的 Gitea login。 */