✨ 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:
1 parent
e8963ac2a2
commit
9167b86708
26 files changed
+550
-34
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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。 */
|
||||
|
||||
Reference in new issue
Block a user