135 lines
5.0 KiB
TypeScript
135 lines
5.0 KiB
TypeScript
import type { Ref } from 'react'
|
|
import type { Issue } from '../types'
|
|
import { useSortable } from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { Lock } from 'lucide-react'
|
|
import { forwardRef } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
import { Card } from './ui/card'
|
|
|
|
interface IssueCardProps {
|
|
issue: Issue
|
|
selected?: boolean
|
|
onSelect?: (id: string) => void
|
|
/**
|
|
* Visual nesting level for prerequisite-chain rendering in the todo column.
|
|
* Each level adds 16px of left margin. Defaults to 0 (flat).
|
|
*/
|
|
depth?: number
|
|
/** 锁定态:前置工单尚未完成。显示锁图标 + tooltip。 */
|
|
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}
|
|
data-tooltip={`${login} 的工单(只读)`}
|
|
className="size-4 shrink-0 rounded-full ring-1 ring-[var(--vscode-panel-border)]"
|
|
/>
|
|
)
|
|
: (
|
|
<span
|
|
data-tooltip={`${login} 的工单(只读)`}
|
|
role="img"
|
|
aria-label={`${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, readOnly, ownerLogin, ownerAvatar }, ref) => {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging }
|
|
= useSortable({ id: issue.id, data: { column: issue.column }, disabled: readOnly })
|
|
|
|
const style: React.CSSProperties = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.4 : 1,
|
|
marginLeft: depth * 16,
|
|
}
|
|
|
|
// Merge dnd-kit's ref with the forwarded ref so the parent can scrollIntoView.
|
|
function setRefs(node: HTMLDivElement | null): void {
|
|
setNodeRef(node)
|
|
if (typeof ref === 'function')
|
|
ref(node)
|
|
else if (ref)
|
|
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
ref={setRefs as unknown as Ref<HTMLDivElement>}
|
|
style={style}
|
|
{...attributes}
|
|
{...listeners}
|
|
onClick={() => onSelect?.(issue.id)}
|
|
className={cn(
|
|
'relative select-none px-3 py-2 text-xs',
|
|
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)]',
|
|
)}
|
|
data-tooltip={issue.column === 'review'
|
|
? (issue.reviewPassed === true ? '已通过' : '审查中')
|
|
: undefined}
|
|
>
|
|
<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)]"
|
|
data-tooltip={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
|
|
className="absolute bottom-1 right-1.5 rounded border border-blue-400/50 bg-blue-400/10 px-1 text-[9px] text-blue-400"
|
|
data-tooltip={`${issue.handoffFrom ?? '?'} 已移交,待接管`}
|
|
>
|
|
待接管
|
|
</span>
|
|
)
|
|
: null}
|
|
<div className={cn(
|
|
'truncate',
|
|
(locked || (readOnly && ownerLogin)) && 'pr-5',
|
|
locked && readOnly && ownerLogin && 'pr-10',
|
|
issue.column === 'review' && (issue.reviewPassed === true ? 'text-green-400' : 'text-amber-400'),
|
|
)}>
|
|
<span className="font-mono opacity-60">
|
|
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
|
|
</span>
|
|
{' '}
|
|
<span>{issue.title}</span>
|
|
</div>
|
|
</Card>
|
|
)
|
|
},
|
|
)
|
|
|
|
IssueCard.displayName = 'IssueCard'
|