✨ feat(vscode): 工单详情头部按钮改用自绘悬停提示

那排图标按钮只靠原生 title 提示:延迟长、样式是系统的,暗色主题下很突兀,
「在 Gitea 打开」更是完全没有说明。

新增 Tooltip 组件(portal 到 body,优先弹在上方,空间不够翻到下方,左右钳在
视口内,300ms 延迟),工单详情头部的打开/接管/移交/待接管/打回待办/关闭/删除
全部改用它,文案顺带写清楚每个动作的实际后果。

Claude-Session: https://claude.ai/code/session_01REdhpbaNyqWASF2HWsnTFv
This commit is contained in:
cruldra committed 2026-09-13 09:18:45 +08:00
1 parent 9cd5ab7e9d
commit 9fadcac12b
3 files changed
+162 -56

No files matched your search

@@ -8,6 +8,7 @@ import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
import { toReadOnlySchema } from '../lib/readOnlySchema'
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
import { PropertyGrid } from './property-grid'
import { Tooltip } from './ui/tooltip'
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
function matchProfileOption(
@@ -616,77 +617,86 @@ export function IssueDetailPanel({
</span>
)
: null}
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
<Tooltip label={issue.source === 'youtrack' ? '在浏览器里打开这张 YouTrack 卡' : '在浏览器里打开这个 Gitea 工单'}>
<button
type="button"
onClick={() => onOpenInBrowser(issue.htmlUrl)}
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
>
<ExternalLink className="size-3.5" />
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
</button>
</Tooltip>
{/* 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' && !readOnly && (
<>
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
<Tooltip label={`接管 ${issue.handoffFrom ?? '?'} 移交过来的工单:拉回会话记录和 worktree`}>
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onAcceptHandoff(issue.number)}
aria-label="接管"
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
接管
</button>
</Tooltip>
)}
{!issue.handoffAttachmentId && issue.column !== 'done' && ((issue.assignees ?? []).length === 0 || (me !== undefined && (issue.assignees ?? []).includes(me))) && (
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
title="移交给同事"
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
<Tooltip label="移交给同事:把会话记录打包传过去,对方接管后继续做">
<button
type="button"
disabled={isHandoffRunning(issue.number)}
onClick={() => onStartHandoff(issue.number)}
aria-label="移交"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
>
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
</button>
</Tooltip>
)}
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
待接管
</span>
<Tooltip label={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70">
待接管
</span>
</Tooltip>
)}
{(issue.column === 'in-progress' || issue.column === 'review') && (
<Tooltip label="打回待办:卡片挪回待办列,会话和 worktree 不动">
<button
type="button"
onClick={() => onResetToTodo(issue.number)}
aria-label="重置为待办"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<RotateCcw className="size-3.5" />
</button>
</Tooltip>
)}
<Tooltip label="关闭工单:Gitea 上置为 closed,卡片从看板消失,可在 Gitea 里重开">
<button
type="button"
onClick={() => onResetToTodo(issue.number)}
title="重置为待办"
aria-label="重置为待办"
onClick={() => onCloseIssue(issue.number)}
aria-label="关闭工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<RotateCcw className="size-3.5" />
<CircleSlash className="size-3.5" />
</button>
)}
<button
type="button"
onClick={() => onCloseIssue(issue.number)}
title="关闭工单"
aria-label="关闭工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
>
<CircleSlash className="size-3.5" />
</button>
<button
type="button"
onClick={() => issue && onDeleteIssue?.(issue.number)}
title="删除工单(不可撤销)"
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</Tooltip>
<Tooltip label="删除工单:从 Gitea 彻底删掉,不可撤销">
<button
type="button"
onClick={() => issue && onDeleteIssue?.(issue.number)}
aria-label="删除工单"
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
>
<Trash2 className="size-3.5" />
</button>
</Tooltip>
</>
)}
</div>
@@ -0,0 +1,96 @@
import type { ReactElement, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
/** 悬停多久才弹,避免扫过一排按钮时闪一串气泡。 */
const SHOW_DELAY_MS = 300
/** 气泡与触发元素之间的间距。 */
const GAP_PX = 6
/** 贴边留白,气泡不顶到 webview 边缘。 */
const GUTTER_PX = 8
interface TooltipProps {
/** 气泡文案;为空则不弹。 */
label: ReactNode
children: ReactNode
className?: string
}
/**
* 悬停提示气泡。
*
* 原生 `title` 在 webview 里延迟长、样式是系统的,暗色主题下很突兀,
* 这里自己渲染:portal 到 body,避开 overflow 容器裁剪;位置优先在上方,
* 上方放不下就翻到下方,左右钳在视口内。
*/
export function Tooltip({ label, children, className }: TooltipProps): ReactElement {
const [pos, setPos] = useState<{ top: number, left: number } | null>(null)
const wrapRef = useRef<HTMLSpanElement | null>(null)
const bubbleRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const hide = useCallback((): void => {
clearTimeout(timerRef.current)
setPos(null)
}, [])
const show = useCallback((): void => {
if (!label)
return
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
const rect = wrapRef.current?.getBoundingClientRect()
if (rect)
setPos({ top: rect.top, left: rect.left + rect.width / 2 })
}, SHOW_DELAY_MS)
}, [label])
useEffect(() => () => clearTimeout(timerRef.current), [])
// 气泡渲染后才知道自身尺寸,这时才能定最终位置:默认在上方,
// 上方空间不够就翻到触发元素下方;水平居中后钳进视口。
useEffect(() => {
if (!pos)
return
const bubble = bubbleRef.current
const rect = wrapRef.current?.getBoundingClientRect()
if (!bubble || !rect)
return
const { width, height } = bubble.getBoundingClientRect()
const above = rect.top - height - GAP_PX
const top = above >= GUTTER_PX ? above : rect.bottom + GAP_PX
let left = rect.left + rect.width / 2 - width / 2
left = Math.max(GUTTER_PX, Math.min(left, window.innerWidth - GUTTER_PX - width))
bubble.style.top = `${top}px`
bubble.style.left = `${left}px`
bubble.style.visibility = 'visible'
}, [pos])
return (
<>
<span
ref={wrapRef}
className={cn('inline-flex shrink-0', className)}
onMouseEnter={show}
onMouseLeave={hide}
onMouseDown={hide}
onFocus={show}
onBlur={hide}
>
{children}
</span>
{pos && label && createPortal(
<div
ref={bubbleRef}
role="tooltip"
style={{ top: pos.top, left: pos.left, visibility: 'hidden' }}
className="pointer-events-none fixed z-50 max-w-64 rounded border border-[var(--vscode-editorHoverWidget-border,var(--vscode-panel-border))] bg-[var(--vscode-editorHoverWidget-background,var(--vscode-editorWidget-background))] px-2 py-1 text-xs leading-snug text-[var(--vscode-editorHoverWidget-foreground,var(--vscode-foreground))] shadow-md"
>
{label}
</div>,
document.body,
)}
</>
)
}