✨ 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

@@ -51,6 +51,8 @@ interface BottomTabsProps {
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
onOpenLogs: () => void
me?: string
/** Gitea login → 头像 data URI。 */
avatars?: Record<string, string>
onStartHandoff: (issueNumber: number) => void
onAcceptHandoff: (issueNumber: number) => void
isHandoffRunning: (issueNumber: number) => boolean
@@ -147,6 +149,7 @@ export function BottomTabs(props: BottomTabsProps) {
profiles={props.profiles}
onOpenLogs={props.onOpenLogs}
me={props.me}
avatars={props.avatars}
onStartHandoff={props.onStartHandoff}
onAcceptHandoff={props.onAcceptHandoff}
isHandoffRunning={props.isHandoffRunning}
+45 -15
View File
@@ -20,12 +20,39 @@ interface IssueCardProps {
locked?: boolean
/** 锁定时关联的前置工单号,用于 tooltip 文案 "等待 #N 完成"。 */
lockedPrerequisite?: number
/** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */
readOnly?: boolean
/** 归属人 Gitea login(只读卡片才显示)。 */
ownerLogin?: string
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
ownerAvatar?: string
}
/** 同事工单的归属人标记:有头像用头像,没有就用 login 首字母。 */
function OwnerBadge({ login, avatar }: { login: string, avatar?: string }) {
return avatar
? (
<img
src={avatar}
alt={login}
title={`${login} 的工单(只读)`}
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
/>
)
: (
<span
title={`${login} 的工单(只读)`}
className="grid size-4 shrink-0 place-items-center rounded-full bg-[var(--vscode-badge-background)] text-[8px] uppercase text-[var(--vscode-badge-foreground)]"
>
{login.slice(0, 1)}
</span>
)
}
export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite }, ref) => {
({ issue, selected, onSelect, depth = 0, locked, lockedPrerequisite, readOnly, ownerLogin, ownerAvatar }, ref) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging }
= useSortable({ id: issue.id, data: { column: issue.column } })
= useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly })
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
@@ -52,23 +79,26 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
onClick={() => onSelect?.(issue.id)}
className={cn(
'relative select-none px-3 py-2 text-xs',
'cursor-grab active:cursor-grabbing',
readOnly ? 'cursor-default' : 'cursor-grab active:cursor-grabbing',
locked && 'opacity-75',
selected
&& 'ring-2 ring-[var(--vscode-focusBorder)] ring-offset-1 ring-offset-[var(--vscode-sideBar-background)]',
)}
>
{locked
? (
<span
className="absolute right-1.5 top-1.5 grid place-items-center text-[var(--vscode-disabledForeground)]"
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
>
<Lock className="size-3.5" />
</span>
)
: null}
<div className="absolute right-1.5 top-1.5 flex items-center gap-1">
{locked
? (
<span
className="grid place-items-center text-[var(--vscode-disabledForeground)]"
title={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
aria-label={lockedPrerequisite != null ? `等待 #${lockedPrerequisite} 完成` : '等待前置工单完成'}
>
<Lock className="size-3.5" />
</span>
)
: null}
{readOnly && ownerLogin ? <OwnerBadge login={ownerLogin} avatar={ownerAvatar} /> : null}
</div>
{issue.handoffAttachmentId
? (
<span
@@ -79,7 +109,7 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
</span>
)
: null}
<div className={cn('truncate', locked && 'pr-5')}>
<div className={cn('truncate', (locked || (readOnly && ownerLogin)) && 'pr-5', locked && readOnly && ownerLogin && 'pr-10')}>
<span className="font-mono opacity-60">
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
</span>
@@ -4,6 +4,8 @@ import type { PropertyGroup, RowAction } from './property-grid'
import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
import { useMemo, useRef } from 'react'
import { isIssueLocked } from '../lib/dependencies'
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid'
@@ -107,6 +109,8 @@ interface IssueDetailPanelProps {
onStartHandoff: (issueNumber: number) => void
onAcceptHandoff: (issueNumber: number) => void
isHandoffRunning: (issueNumber: number) => boolean
/** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
avatars?: Record<string, string>
}
/**
@@ -149,6 +153,7 @@ export function IssueDetailPanel({
onStartHandoff,
onAcceptHandoff,
isHandoffRunning,
avatars,
}: IssueDetailPanelProps) {
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
@@ -520,6 +525,14 @@ export function IssueDetailPanel({
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onContinueBrainstorm, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
)
// 同事的工单只读:属性表统一消毒,顶部只留查看类按钮。
const readOnly = issue ? !isIssueMine(issue, me) : false
const ownerLogin = issue ? issueOwnerLogin(issue) : undefined
const effectiveSchema = useMemo(
() => readOnly ? toReadOnlySchema(schema) : schema,
[schema, readOnly],
)
const data = useMemo<Record<string, unknown> | null>(() => {
if (!issue)
return null
@@ -589,6 +602,20 @@ export function IssueDetailPanel({
)
: null}
</h3>
{readOnly && ownerLogin
? (
<span
className="inline-flex shrink-0 items-center gap-1.5 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-[11px] opacity-80"
title={`${ownerLogin} 的工单,只能查看`}
>
{avatars?.[ownerLogin]
? <img src={avatars[ownerLogin]} alt={ownerLogin} className="size-4 rounded-full" />
: null}
{ownerLogin}
{' · 只读'}
</span>
)
: null}
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
@@ -599,7 +626,7 @@ export function IssueDetailPanel({
</button>
{/* Close/delete write to gitea by number — hide for youtrack cards to
avoid acting on a gitea issue of the same number. */}
{issue.source !== 'youtrack' && (
{issue.source !== 'youtrack' && !readOnly && (
<>
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
<button
@@ -691,11 +718,13 @@ export function IssueDetailPanel({
<div className="min-h-0 flex-1">
<PropertyGrid
key={issue.number}
schema={schema}
schema={effectiveSchema}
data={data}
hideToolbar
fillHeight
onChange={(key, value) => {
if (readOnly)
return
if (key === 'autoReview' && issue && typeof value === 'boolean')
onUpdateAutoReview(issue.number, value)
if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
@@ -17,6 +17,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useEffect, useMemo, useRef, useState } from 'react'
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
import { isIssueLocked } from '../lib/dependencies'
import { compareIssuesInColumn } from '../lib/issueSort'
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
@@ -34,6 +35,10 @@ interface KanbanBoardProps {
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
/** 当前 Gitea login;用于判定哪些卡是同事的(只读)。 */
me?: string
/** Gitea login → 头像 data URI。 */
avatars?: Record<string, string>
}
const kanbanCollisionDetection: CollisionDetection = (args) => {
@@ -105,6 +110,8 @@ export function KanbanBoard({
onColumnChange,
onDependencySet,
onDependencyClear,
me,
avatars,
}: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null)
@@ -238,6 +245,8 @@ export function KanbanBoard({
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{ordered.map(({ issue, depth }) => {
const { locked, prerequisiteNumber } = isIssueLocked(issue, issues)
const readOnly = !isIssueMine(issue, me)
const ownerLogin = issueOwnerLogin(issue)
return (
<IssueCard
key={issue.id}
@@ -253,6 +262,9 @@ export function KanbanBoard({
onSelect={onSelectIssue}
locked={locked}
lockedPrerequisite={prerequisiteNumber}
readOnly={readOnly}
ownerLogin={ownerLogin}
ownerAvatar={ownerLogin ? avatars?.[ownerLogin] : undefined}
/>
)
})}