eeff292
/**
* One issue, opened from the board (`?issue=42`): body, sidebar fields, comment
* thread and the activity line. Every control writes through the real endpoint
* and refreshes only this view's data — no document reload, and the board
* behind it keeps its filters.
*/
import { ErrorPanel, Markdown } from '@app/components'
import {
type BoardRef,
bindLabel,
closeIssue,
createComment,
dateApiValue,
dateInputValue,
deleteComment,
deleteIssue,
deleteIssueAttachment,
eventLabel,
getIssue,
type IssueComment,
issueAttachmentUrl,
issueLabel,
listCommentTree,
listEvents,
listIssueAttachments,
listIssueLabels,
normalizeColor,
PRIORITIES,
reopenIssue,
setIssueWatch,
type UserDto,
unbindLabel,
updateComment,
updateIssue,
type WatchMode,
} from '@app/domains/issues/model'
import { type IssuePullLink, linkIssuePull, listIssuePulls, listPulls, unlinkIssuePull } from '@app/domains/pulls/model'
import { listSpaceRepos } from '@app/domains/repo/model'
import { parseDateValue, t as tr, useT } from '@app/i18n'
import { fmtTime, formatBytes, issueKey, spaceReposPickKey, useAsync } from '@app/lib'
import { useResourceEvents } from '@app/services/realtime/topic'
import {
IconArrowLeft as ArrowLeft,
IconArrowUp as ArrowUp,
IconBell as Bell,
IconCalendar as Calendar,
IconCheck as Check,
IconChevronDown as ChevronDown,
IconFileText as FileText,
IconFlag as Flag,
IconFolderCode as FolderGit2,
IconGitPullRequest as GitPullRequest,
IconLoader2 as Loader2,
IconFlag as MilestoneIcon,
IconDots as MoreHorizontal,
IconPencil as Pencil,
IconPlus as Plus,
IconUsers as Users,
IconX as X,
} from '@tabler/icons-react'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import type { BoardData } from '../model/use-board-data'
import { AvatarStack, DangerButton, FilterMenu, LabelChip, PriorityBadge, StateBadge, UserAvatar } from './parts'
const WATCH_LABEL_KEYS: Record<WatchMode, 'issue.watch.watching' | 'issue.watch.unwatched' | 'issue.watch.ignore'> = {
watching: 'issue.watch.watching',
unwatched: 'issue.watch.unwatched',
ignore: 'issue.watch.ignore',
}
type IssueEvent = import('@app/domains/issues/model').IssueEvent
/**
* Entries of the merged activity stream: change events + comment threads.
* A run of the same change by the same person collapses into one entry, so
* "调整了里程碑 ×4" reads as one line with a count instead of four rows.
*/
type FeedEntry =
| { kind: 'events'; key: string; at: number; events: IssueEvent[] }
| { kind: 'comment'; key: string; at: number; node: import('@app/domains/issues/model').CommentNode }
/** Timeline rows shown before the "show earlier" toggle appears. */
const FEED_WINDOW = 6
/** Sort key that tolerates the `+00:00:00` offsets Rust emits. */
function feedStamp(value: string): number {
const ms = parseDateValue(value).getTime()
return Number.isNaN(ms) ? 0 : ms
}
/** `09-23` this year, `2025-09-23` otherwise — a timeline stays scannable. */
function fmtDay(value: string): string {
const date = parseDateValue(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
const day = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return date.getFullYear() === new Date().getFullYear() ? day : `${date.getFullYear()}-${day}`
}
export interface IssueDetailProps {
boardRef: BoardRef
/** Board-scoped number or uuid, straight from the route. */
issueId: string
/** Bumped by the page after a board-level change; refetches this issue. */
reloadKey: number
data: BoardData
viewerId?: number
onBack: () => void
onChanged: () => void
/** Open the edit dialog prefilled with this issue. */
onEdit: (issue: Awaited<ReturnType<typeof getIssue>>) => void
}
const ISSUE_TAGS = [
'issue-create',
'title-update',
'description-update',
'content-update',
'milestone-update',
'priority-update',
'due-update',
'issue-close',
'issue-reopen',
'comment-create',
'comment-update',
'comment-delete',
'member-create',
'member-delete',
'label-create',
'label-delete',
'dependency-create',
'dependency-delete',
'pin-create',
'pin-delete',
'repo-relation-create',
'repo-relation-delete',
'pull-link-create',
'pull-link-delete',
] as const
export function IssueDetail({
boardRef,
issueId,
reloadKey,
data,
viewerId,
onBack,
onChanged,
onEdit,
}: IssueDetailProps) {
const { t, tc } = useT()
const [retry, setRetry] = useState(0)
const [notice, setNotice] = useState<string | null>(null)
const [feedExpanded, setFeedExpanded] = useState(false)
// `retry` is this view's own refresh, `reloadKey` the page's (another tab of
// the app changed the board, or a label was renamed in the manage dialog).
const stamp = [boardRef.space, boardRef.board, issueId, retry, reloadKey]
const detail = useAsync(() => getIssue(boardRef, issueId), stamp, {
cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'detail'),
})
const labels = useAsync(() => listIssueLabels(boardRef, issueId), stamp, {
cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'labels'),
})
const comments = useAsync(() => listCommentTree(boardRef, issueId), stamp, {
cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'comments'),
})
const events = useAsync(() => listEvents(boardRef, issueId), stamp, {
cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'events'),
})
const attachments = useAsync(() => listIssueAttachments(boardRef, issueId), stamp, {
cacheKey: issueKey(boardRef.space, boardRef.board, issueId, 'attachments'),
})
const issueUuid = detail.data?.id
useResourceEvents({
topic: 'gitdata-issue',
tags: ISSUE_TAGS,
filter: issueUuid ? { issue_id: issueUuid } : undefined,
enabled: Boolean(issueUuid),
onEvent: () => {
detail.reloadSilently()
labels.reloadSilently()
comments.reloadSilently()
events.reloadSilently()
attachments.reloadSilently()
},
})
const issue = detail.data
const failure = detail.error as { code?: number; message?: string } | undefined
useEffect(() => {
if (!notice) return
const timer = setTimeout(() => setNotice(null), 4000)
return () => clearTimeout(timer)
}, [notice])
if (failure && !issue) {
return (
<div className="rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
<p className="mb-2 flex items-center gap-2 text-[13px] text-shell-ink-muted">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-shell-active"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
</button>
</p>
<ErrorPanel
status={typeof failure.code === 'number' ? failure.code : 0}
what={t('issue.board.errorIssue')}
note={failure.message}
onRetry={() => setRetry((v) => v + 1)}
/>
</div>
)
}
if (!issue) {
return (
<div className="flex items-center gap-2 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-[13px] text-shell-ink-muted">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> {t('issue.board.loadingIssue')}
</div>
)
}
/** Run a mutation, surface its failure inline, and refresh this issue + board. */
async function run(action: () => Promise<unknown>, success?: string) {
setNotice(null)
try {
await action()
if (success) setNotice(success)
setRetry((v) => v + 1)
onChanged()
return true
} catch (err) {
setNotice(
(err as Error)?.message
? t('issue.board.actionFailed', { reason: (err as Error).message })
: t('issue.board.actionFailedGeneric'),
)
return false
}
}
const boundLabels = labels.data ?? []
const attachmentRows = attachments.data ?? []
const milestoneOptions = (data.milestones.data?.items ?? []).filter((m) => !m.is_closed)
const milestoneName = issue.milestone_id
? (milestoneOptions.find((m) => m.id === issue.milestone_id)?.name ?? t('issue.board.milestoneUnknown'))
: ''
// Linear reads one issue as a single stream: comments and change events share
// a timeline. A comment's own event is dropped when its card is already in
// the stream, so the thread is never shown twice.
const rootNodes = comments.data ?? []
const commentIds = new Set<string>()
const collectCommentIds = (node: import('@app/domains/issues/model').CommentNode) => {
commentIds.add(node.comment.id)
node.replies?.forEach(collectCommentIds)
}
rootNodes.forEach(collectCommentIds)
const eventItems = (events.data?.items ?? []).filter(
(event) =>
!(
event.comment_id &&
commentIds.has(event.comment_id) &&
(event.event_type === 'comment_created' || event.event_type === 'comment_updated')
),
)
const rawFeed: FeedEntry[] = [
...rootNodes.map((node) => ({
kind: 'comment' as const,
key: `c-${node.comment.id}`,
at: feedStamp(node.comment.created_at),
node,
})),
...eventItems.map((event) => ({
kind: 'events' as const,
key: `e-${event.id}`,
at: feedStamp(event.created_at),
events: [event],
})),
].sort((a, b) => a.at - b.at)
// Collapse adjacent events of the same type by the same actor.
const feed: FeedEntry[] = []
for (const entry of rawFeed) {
const previous = feed.at(-1)
const head = entry.kind === 'events' ? entry.events[0] : null
const previousHead = previous?.kind === 'events' ? previous.events[0] : null
const sameRun =
head &&
previousHead &&
head.event_type === previousHead.event_type &&
(head.user?.user_id ?? '') === (previousHead.user?.user_id ?? '')
if (sameRun && previous?.kind === 'events') {
previous.events.push(head!)
previous.at = entry.at
continue
}
feed.push(entry)
}
const earlier = Math.max(0, feed.length - FEED_WINDOW)
// Collapsing a single row hides more than it explains: only fold real runs.
const collapsible = earlier > 1
const visibleFeed = feedExpanded || !collapsible ? feed : feed.slice(-FEED_WINDOW)
return (
<div className="issue-detail">
{notice ? (
<p role="status" className="issue-detail-notice">
{notice}
</p>
) : null}
<div className="issue-detail-topline">
<button type="button" onClick={onBack} className="issue-action">
<ArrowLeft className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.backToBoard')}
</button>
<div className="issue-detail-actions">
<button type="button" onClick={() => onEdit(issue)} className="issue-action">
<Pencil className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.edit')}
</button>
{issue.state === 'open' ? (
<button
type="button"
onClick={() => void run(() => closeIssue(boardRef, issue.id), t('issue.board.closedIssue'))}
className="issue-action issue-action-strong"
>
{t('issue.board.closeIssue')}
</button>
) : (
<button
type="button"
onClick={() => void run(() => reopenIssue(boardRef, issue.id), t('issue.board.reopenedIssue'))}
className="issue-action issue-action-strong"
>
{t('issue.board.reopen')}
</button>
)}
{/* Deleting is rare and irreversible: it lives behind ⋯ with a
two-step confirm instead of sitting next to 关闭工单. */}
<IssueMoreMenu onDelete={() => void deleteAndBack(issue.id)} />
</div>
</div>
<div className="issue-detail-layout">
<div className="issue-detail-main">
<header className="issue-detail-header">
<p className="issue-detail-key">
<span className="issue-detail-number">{issueLabel(data.board.data, issue.number)}</span>
<StateBadge state={issue.state} />
<PriorityBadge priority={issue.priority} />
</p>
<h1 className="issue-detail-title">
<Markdown inline>{issue.title}</Markdown>
</h1>
{issue.description ? <Markdown className="issue-detail-desc">{issue.description}</Markdown> : null}
{issue.content ? <Markdown className="issue-detail-desc">{issue.content}</Markdown> : null}
{!issue.description && !issue.content ? (
<p className="issue-detail-desc is-empty">{t('issue.board.noBody')}</p>
) : null}
<p className="issue-detail-byline">
{issue.author ? <UserAvatar user={issue.author} size={20} /> : null}
<span>
{t('issue.board.createdAt', {
author: issue.author?.display_name ?? t('issue.board.unknownAuthor'),
time: fmtTime(issue.created_at),
})}
</span>
{issue.updated_at && issue.updated_at !== issue.created_at ? (
<span>· {t('issue.board.updatedAt', { time: fmtTime(issue.updated_at) })}</span>
) : null}
{issue.closed_at ? <span>· {t('issue.board.closedAt', { time: fmtTime(issue.closed_at) })}</span> : null}
</p>
{attachmentRows.length > 0 ? (
<div className="issue-attachments">
{attachmentRows.map((row) => {
const url = issueAttachmentUrl(row.id)
const isImage = row.content_type.startsWith('image/')
const mine = Boolean(viewerId && row.uploader_id === viewerId)
const open = () => window.open(url, '_blank', 'noopener,noreferrer')
return (
<span key={row.id} className={`issue-attachment ${isImage ? 'is-image' : 'is-file'}`}>
{isImage ? (
<button
type="button"
className="issue-attachment-image"
onClick={open}
aria-label={t('issue.board.attachment.open', { name: row.filename })}
title={row.filename}
>
<img src={url} alt={row.filename} loading="lazy" />
</button>
) : (
<button
type="button"
className="issue-attachment-link"
onClick={open}
aria-label={t('issue.board.attachment.open', { name: row.filename })}
title={row.filename}
>
<FileText className="h-3.5 w-3.5" aria-hidden />
<span className="issue-attachment-name">{row.filename}</span>
<span className="issue-attachment-size">{formatBytes(row.size)}</span>
</button>
)}
{mine ? (
<button
type="button"
className="issue-attachment-remove"
aria-label={t('issue.board.form.removeAttachment', { name: row.filename })}
title={t('issue.board.form.removeAttachment', { name: row.filename })}
onClick={() => void removeAttachment(row.id)}
>
<X className="h-3 w-3" aria-hidden />
</button>
) : null}
</span>
)
})}
</div>
) : null}
</header>
<section className="issue-feed">
<div className="issue-feed-head">
<h2>{t('issue.board.activity')}</h2>
<span className="issue-feed-count">{tc('issue.board.activityCount', events.data?.total ?? 0)}</span>
</div>
{comments.loading && !comments.data ? (
<p className="issue-feed-note">{t('issue.board.loadingComments')}</p>
) : null}
{comments.error ? <p className="issue-feed-note">{t('issue.board.commentsFailed')}</p> : null}
{events.error ? <p className="issue-feed-note">{t('issue.board.activityFailed')}</p> : null}
{collapsible ? (
<button
type="button"
className="issue-feed-more"
aria-expanded={feedExpanded}
onClick={() => setFeedExpanded((v) => !v)}
>
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${feedExpanded ? 'rotate-180' : ''}`}
aria-hidden
/>
{feedExpanded ? t('issue.board.hideEarlier') : t('issue.board.showEarlier', { count: earlier })}
</button>
) : null}
{visibleFeed.length === 0 && !comments.loading && !events.loading ? (
<p className="issue-feed-note">{t('issue.board.noActivity')}</p>
) : null}
{visibleFeed.map((entry) =>
entry.kind === 'comment' ? (
<CommentCard
key={entry.key}
node={entry.node}
boardRef={boardRef}
issueId={issue.id}
viewerId={viewerId}
onReload={() => setRetry((v) => v + 1)}
/>
) : (
<EventRow
key={entry.key}
events={entry.events}
memberName={(id) =>
(data.members.data?.items ?? [])
.map((item) => item.user)
.find((user) => String(user?.user_id) === id)?.display_name ?? undefined
}
/>
),
)}
<CommentComposer boardRef={boardRef} issueId={issue.id} onReload={() => setRetry((v) => v + 1)} />
</section>
</div>
<aside className="issue-detail-rail">
<div className="issue-rail-group">
<p className="issue-rail-title">{t('issue.board.properties')}</p>
<div className="issue-rail-rows">
<FilterMenu
bare
label={t('issue.board.filter.priority')}
leading={
<>
<Flag className="h-3.5 w-3.5" aria-hidden />
<span>{t('issue.board.filter.priority')}</span>
</>
}
value={issue.priority}
options={PRIORITIES.map((meta) => ({ value: meta.id, label: meta.label, color: meta.color }))}
onChange={(value) => void run(() => updateIssue(boardRef, issue.id, { priority: value as never }))}
/>
<FilterMenu
bare
label={t('issue.board.filter.milestone')}
leading={
<>
<MilestoneIcon className="h-3.5 w-3.5" aria-hidden />
<span>{t('issue.board.filter.milestone')}</span>
</>
}
value={issue.milestone_id ?? ''}
active={milestoneName || undefined}
placeholder={t('issue.board.unset')}
options={milestoneOptions.map((m) => ({ value: m.id, label: m.name }))}
onChange={(value) => void run(() => updateIssue(boardRef, issue.id, { milestone_id: value || null }))}
/>
{/* DueProp 自带 .issue-prop-row:再包一层会多 8px 内边距,右对齐差一行 */}
<DueProp
value={issue.due_at}
onChange={(next) => void run(() => updateIssue(boardRef, issue.id, { due_at: next }))}
/>
<AssigneeMenu
members={data.members.data?.items ?? []}
selected={issue.assignees.map((user) => user.user_id)}
onChange={(ids) => void run(() => updateIssue(boardRef, issue.id, { assignee_ids: ids }))}
/>
{/* Subscription is an issue property, not a feed control. */}
<FilterMenu
bare
label={t('issue.board.notifyField')}
leading={
<>
<Bell className="h-3.5 w-3.5" aria-hidden />
<span>{t('issue.board.notifyField')}</span>
</>
}
value={issue.viewer_watch ?? 'unwatched'}
active={t(WATCH_LABEL_KEYS[issue.viewer_watch ?? 'unwatched'])}
options={(Object.keys(WATCH_LABEL_KEYS) as WatchMode[]).map((mode) => ({
value: mode,
label: t(WATCH_LABEL_KEYS[mode]),
}))}
onChange={(value) => void run(() => setIssueWatch(boardRef, issue.id, value as WatchMode))}
/>
</div>
</div>
<div className="issue-rail-group">
<p className="issue-rail-title">{t('issue.board.labelsField')}</p>
<div className="issue-rail-labels">
{boundLabels.map((label) => (
<LabelChip
key={label.id}
label={label}
onRemove={() => void run(() => unbindLabel(boardRef, issue.id, label.id))}
/>
))}
{boundLabels.length === 0 ? (
<span className="issue-rail-empty">
{labels.error ? t('issue.board.labelsFailed') : t('issue.board.noLabels')}
</span>
) : null}
<LabelBinder
labels={data.labels.data?.items ?? []}
bound={boundLabels.map((label) => label.id)}
onToggle={(labelId, on) =>
void run(() =>
on ? bindLabel(boardRef, issue.id, labelId) : unbindLabel(boardRef, issue.id, labelId),
)
}
/>
</div>
</div>
<div className="issue-rail-group">
<p className="issue-rail-title">{t('issue.board.linkedPulls')}</p>
<IssuePullLinks boardRef={boardRef} issueId={issue.id} onChanged={() => void onChanged()} />
</div>
</aside>
</div>
</div>
)
async function deleteAndBack(id: string) {
try {
await deleteIssue(boardRef, id)
onChanged()
onBack()
} catch (err) {
setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
}
}
async function removeAttachment(id: string) {
try {
await deleteIssueAttachment(id)
setRetry((v) => v + 1)
} catch (err) {
setNotice(t('issue.board.deleteFailed', { reason: (err as Error)?.message ?? t('common.state.unknown') }))
}
}
}
/**
* 截止日期:和优先级/里程碑/负责人一样是「点开选择器」的属性行,未设置时
* 保持灰字。`<input type="date">` 本体只用来看/收值,日历由 showPicker 打开。
*/
function DueProp({ value, onChange }: { value: string | null | undefined; onChange: (next: string | null) => void }) {
const { t } = useT()
const input = useRef<HTMLInputElement | null>(null)
const label = value ? fmtTime(value) : t('issue.board.unset')
return (
<div className="issue-prop-row issue-prop-row-action">
<button
type="button"
className="issue-prop-target"
onClick={() => {
const field = input.current
if (!field) return
if (typeof field.showPicker === 'function') {
try {
field.showPicker()
return
} catch {
/* fall through to a plain click when the picker is unavailable */
}
}
field.click()
}}
>
<Calendar className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
<span className="issue-prop-label">{t('issue.board.filter.due')}</span>
<span className={`issue-prop-value${value ? '' : ' is-empty'}`}>
<span className="issue-prop-text">{label}</span>
<ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
</span>
</button>
<input
ref={input}
type="date"
aria-label={t('issue.board.filter.due')}
value={dateInputValue(value)}
onChange={(event) => onChange(dateApiValue(event.target.value))}
className="issue-prop-date-sr"
/>
{value ? (
<button
type="button"
className="issue-prop-clear"
aria-label={t('issue.board.clearDate')}
title={t('issue.board.clearDate')}
onClick={() => onChange(null)}
>
<X className="h-3 w-3" aria-hidden />
</button>
) : null}
</div>
)
}
/**
* 工单侧的 PR 关联面板:列出关联(含 relation id,可解除)并支持新增关联。
*/
/** Top-right overflow: the low-frequency, destructive actions of one issue. */
function IssueMoreMenu({ onDelete }: { onDelete: () => void }) {
const { t } = useT()
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!open) return
const onDown = (event: MouseEvent) => {
if (!box.current?.contains(event.target as Node)) setOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
window.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div ref={box} className="issue-more">
<button
type="button"
className="issue-action"
aria-label={t('issue.board.more')}
title={t('issue.board.more')}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
<MoreHorizontal className="h-3.5 w-3.5" aria-hidden />
</button>
{open ? (
<div className="issue-more-menu" role="menu">
<DangerButton label={t('issue.board.deleteIssue')} className="issue-more-danger" onConfirm={onDelete} />
</div>
) : null}
</div>
)
}
function IssuePullLinks({
boardRef,
issueId,
onChanged,
}: {
boardRef: BoardRef
issueId: string
onChanged: () => void
}) {
const { t } = useT()
const [tick, setTick] = useState(0)
const [repoName, setRepoName] = useState('')
const [pullRef, setPullRef] = useState('')
// The picker is a response to "+ 关联", not permanent furniture: an issue
// with no links should read as one quiet row, not a three-step form.
const [adding, setAdding] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const pulls = useAsync(
() => listIssuePulls(boardRef.space, boardRef.board, issueId),
[boardRef.space, boardRef.board, issueId, tick],
)
// 仓库从这里选,不再靠手打:社区里的仓库名是唯一合法输入,列出来才能避免拼错。
const repos = useAsync(() => listSpaceRepos(boardRef.space), [boardRef.space], {
cacheKey: spaceReposPickKey(boardRef.space),
})
const rows = pulls.data ?? []
const repoOptions = (repos.data ?? []).map((repo) => ({ value: repo.name, label: repo.name }))
// The PR is picked from the chosen repo's own list for the same reason the
// repo is: `pull_ref` is matched against the repo's numbering, not typed.
// No repo chosen yet means no list to offer, so the query stays idle.
const openPulls = useAsync(
() =>
repoName
? listPulls(boardRef.space, repoName, { state: 'open', limit: 50 })
: Promise.resolve({ items: [], total: 0, limit: 0, offset: 0 }),
[boardRef.space, repoName],
)
const pullOptions = (openPulls.data?.items ?? []).map((pull) => ({
value: String(pull.number),
label: `#${pull.number} ${pull.title}`,
}))
async function link() {
if (!repoName.trim() || !pullRef.trim()) return
setBusy(true)
setError(null)
try {
await linkIssuePull(boardRef.space, boardRef.board, issueId, {
space_name: boardRef.space,
repo_name: repoName.trim(),
pull_ref: pullRef.trim(),
})
setPullRef('')
setAdding(false)
setTick((t) => t + 1)
onChanged()
} catch (f) {
setError(f instanceof Error ? f.message : t('issue.board.linkFailed'))
} finally {
setBusy(false)
}
}
return (
<div className="issue-pull-links">
{rows.map((link: IssuePullLink) => (
<div key={link.relation_id} className="issue-pull-card">
<div className="issue-pull-card-head">
<GitPullRequest className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
<span className="issue-pull-card-title" title={link.pull.title}>
#{link.pull.number} {link.pull.title}
</span>
<span className="issue-pull-state" data-state={link.pull.state}>
{link.pull.state === 'open'
? t('issue.board.stateOpen')
: link.pull.state === 'merged'
? t('pull.state.merged')
: t('pull.state.closed')}
</span>
</div>
<div className="issue-pull-card-foot">
{/* 两个分支各自截断,箭头保持不动:长分支名不会把箭头挤到中间。 */}
<span className="issue-pull-branch" title={link.pull.head.branch}>
{link.pull.head.branch}
</span>
<span className="issue-pull-arrow" aria-hidden>
→
</span>
<span className="issue-pull-branch issue-pull-branch-base" title={link.pull.base.branch}>
{link.pull.base.branch}
</span>
</div>
<button
type="button"
aria-label={t('issue.board.unlink')}
title={t('issue.board.unlink')}
className="issue-prop-clear issue-pull-card-remove"
onClick={() =>
void unlinkIssuePull(boardRef.space, boardRef.board, issueId, link.relation_id)
.then(() => {
setTick((t) => t + 1)
// 解除关联也是一条活动记录(后端会写 pull_unlinked),刷新整页数据。
onChanged()
})
.catch(() => undefined)
}
>
<X className="h-3 w-3" aria-hidden />
</button>
</div>
))}
{rows.length === 0 ? <p className="issue-rail-empty">{t('issue.board.noPullLinks')}</p> : null}
{adding ? (
<div className="issue-pull-picker">
{/*
A repo is addressed by its exact name, so it is picked from the
space's own repos; the PR then comes from that repo's numbering.
*/}
{repoOptions.length > 0 ? (
<FilterMenu
bare
label={t('issue.board.repoPlaceholder')}
placeholder={t('issue.board.pickRepo')}
leading={<FolderGit2 className="h-3.5 w-3.5" aria-hidden />}
value={repoName}
active={repoName || undefined}
options={repoOptions}
onChange={(next) => {
setRepoName(next)
// A PR number only means something inside one repo.
setPullRef('')
}}
/>
) : (
<span className="issue-rail-empty">{repos.loading ? t('ui.loading') : t('issue.board.noRepos')}</span>
)}
{repoName ? (
pullOptions.length > 0 ? (
<FilterMenu
bare
label={t('issue.board.pullPlaceholder')}
placeholder={t('issue.board.pickPull')}
leading={<GitPullRequest className="h-3.5 w-3.5" aria-hidden />}
value={pullRef}
active={pullOptions.find((option) => option.value === pullRef)?.label}
options={pullOptions}
onChange={setPullRef}
/>
) : (
<span className="issue-rail-empty">
{openPulls.loading ? t('ui.loading') : t('issue.board.noOpenPulls')}
</span>
)
) : null}
<div className="issue-pull-picker-foot">
<button
type="button"
className="issue-link-button"
disabled={busy || !repoName || !pullRef}
onClick={() => void link()}
>
{t('issue.board.link')}
</button>
<button type="button" className="issue-pull-cancel" onClick={() => setAdding(false)}>
{t('common.action.cancel')}
</button>
</div>
</div>
) : (
<button type="button" className="issue-prop-add" onClick={() => setAdding(true)}>
<Plus className="h-3.5 w-3.5" aria-hidden /> {t('issue.board.linkPull')}
</button>
)}
{error ? <p className="issue-rail-error">{error}</p> : null}
</div>
)
}
/**
* 负责人:多选。收起时是一行属性(头像 + 名字),展开后是成员勾选清单。
*/
function AssigneeMenu({
members,
selected,
onChange,
}: {
members: { user?: import('@app/domains/issues/model').UserDto | null }[]
selected: number[]
onChange: (ids: number[]) => void
}) {
const { t } = useT()
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!open) return
const onDown = (event: MouseEvent) => {
if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
window.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey)
}
}, [open])
const users = members.map((member) => member.user).filter((user): user is UserDto => Boolean(user))
const picked = users.filter((user) => selected.includes(user.user_id))
return (
<div ref={box} className="relative">
<button
type="button"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="issue-prop-row w-full"
>
<Users className="issue-prop-icon h-3.5 w-3.5" aria-hidden />
<span className="issue-prop-label">{t('issue.board.assigneeField')}</span>
<span className="issue-prop-value">
{picked.length > 0 ? (
<>
<AvatarStack users={picked} size={18} />
{picked.length === 1 ? (
<span className="issue-prop-text">{picked[0].display_name || picked[0].username}</span>
) : null}
</>
) : (
<span className="issue-prop-text is-empty">{t('issue.board.unassigned')}</span>
)}
<ChevronDown className="issue-prop-chevron h-3 w-3" aria-hidden />
</span>
</button>
{open ? (
<div
className="board-filter-popover issue-prop-popover"
role="listbox"
aria-multiselectable="true"
aria-label={t('issue.board.assigneeField')}
>
{users.length === 0 ? (
<p className="issue-rail-empty">{t('issue.board.noMembers')}</p>
) : (
users.map((user) => {
const on = selected.includes(user.user_id)
return (
<button
key={user.user_id}
type="button"
role="option"
aria-selected={on}
onClick={() =>
onChange(on ? selected.filter((id) => id !== user.user_id) : [...selected, user.user_id])
}
className="board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field"
>
<span className={`issue-check ${on ? 'is-on' : ''}`} aria-hidden>
{on ? <Check className="h-3 w-3" /> : null}
</span>
<UserAvatar user={user} size={18} />
<span className={`min-w-0 flex-1 truncate ${on ? 'font-medium' : ''}`}>
{user.display_name || user.username}
</span>
</button>
)
})
)}
</div>
) : null}
</div>
)
}
function LabelBinder({
labels,
bound,
onToggle,
}: {
labels: import('@app/domains/issues/model').IssueLabel[]
bound: string[]
onToggle: (labelId: string, on: boolean) => void
}) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!open) return
const onDown = (event: MouseEvent) => {
if (box.current && !box.current.contains(event.target as Node)) setOpen(false)
}
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
window.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey)
}
}, [open])
if (labels.length === 0) return null
const missing = labels.filter((label) => !bound.includes(label.id))
return (
<div ref={box} className="relative">
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} className="issue-prop-add">
<Plus className="h-3.5 w-3.5" aria-hidden /> {tr('issue.board.addLabel')}
</button>
{open ? (
<div className="board-filter-popover issue-prop-popover" role="listbox" aria-label={tr('issue.board.addLabel')}>
{missing.map((label) => (
<button
key={label.id}
type="button"
role="option"
aria-selected={false}
onClick={() => onToggle(label.id, true)}
className="board-filter-option flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12.5px] transition-colors hover:bg-shell-field"
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: normalizeColor(label.color) }}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate">{label.name}</span>
</button>
))}
{missing.length === 0 ? <p className="issue-rail-empty">{tr('issue.board.allLabelsBound')}</p> : null}
</div>
) : null}
</div>
)
}
/* ------------------------------------------- activity feed and comments */
/** Bottom composer: a quiet card that reads like the comment it will add. */
function CommentComposer({
boardRef,
issueId,
onReload,
}: {
boardRef: BoardRef
issueId: string
onReload: () => void
}) {
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submit() {
const content = draft.trim()
if (!content || busy) return
setBusy(true)
setError(null)
try {
await createComment(boardRef, issueId, content)
setDraft('')
onReload()
} catch (err) {
setError((err as Error)?.message ?? tr('issue.board.commentSendFailed'))
} finally {
setBusy(false)
}
}
return (
<div className="comment-card issue-composer">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.nativeEvent.isComposing) void submit()
}}
rows={2}
placeholder={tr('issue.board.commentPlaceholder')}
aria-label={tr('issue.board.newCommentAria')}
className="comment-input bg-transparent"
/>
{error ? (
<p role="alert" className="comment-error">
{error}
</p>
) : null}
<div className="comment-foot">
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
<button
type="button"
disabled={busy || draft.trim().length === 0}
onClick={() => void submit()}
aria-label={tr('issue.board.submitComment')}
title={tr('issue.board.submitComment')}
className="comment-send"
>
<ArrowUp className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
)
}
function CommentCard({
node,
boardRef,
issueId,
viewerId,
onReload,
}: {
node: import('@app/domains/issues/model').CommentNode
boardRef: BoardRef
issueId: string
viewerId?: number
onReload: () => void
}) {
const comment: IssueComment = node.comment
const mine = Boolean(viewerId && comment.poster?.user_id === viewerId)
const [reply, setReply] = useState('')
// The reply box is a response to an intent, not furniture: it appears under
// the 回复 button instead of doubling every comment's footprint.
const [replying, setReplying] = useState(false)
const [editing, setEditing] = useState(false)
const [text, setText] = useState(comment.content)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const replyBox = useRef<HTMLTextAreaElement | null>(null)
useEffect(() => {
if (!replying) return
replyBox.current?.focus()
}, [replying])
async function act(action: () => Promise<unknown>) {
setBusy(true)
setError(null)
try {
await action()
setEditing(false)
onReload()
} catch (err) {
setError((err as Error)?.message ?? tr('issue.board.actionFailedGeneric'))
} finally {
setBusy(false)
}
}
async function submitReply() {
const content = reply.trim()
if (!content || busy) return
setBusy(true)
setError(null)
try {
await createComment(boardRef, issueId, content, comment.id)
setReply('')
setReplying(false)
onReload()
} catch (err) {
setError((err as Error)?.message ?? tr('issue.board.replyFailed'))
} finally {
setBusy(false)
}
}
return (
<article className="comment-card">
<div className="comment-head">
{comment.poster ? <UserAvatar user={comment.poster} size={22} /> : null}
<span className="comment-author">{comment.poster?.display_name ?? tr('common.state.unknownUser')}</span>
<span className="comment-time">{fmtTime(comment.created_at)}</span>
{comment.updated_at && comment.updated_at !== comment.created_at ? (
<span className="comment-time">{tr('issue.board.edited')}</span>
) : null}
<span className="comment-actions">
{!editing ? (
<button type="button" className="comment-action" onClick={() => setReplying((value) => !value)}>
{tr('issue.board.reply')}
</button>
) : null}
{mine ? (
<button type="button" onClick={() => setEditing((v) => !v)} className="comment-action">
{editing ? tr('common.action.cancel') : tr('common.action.edit')}
</button>
) : null}
{mine ? (
<DangerButton
label={tr('common.action.delete')}
busy={busy}
onConfirm={() => void act(() => deleteComment(boardRef, issueId, comment.id))}
/>
) : null}
</span>
</div>
{editing ? (
<div className="comment-editor">
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={3}
aria-label={tr('issue.board.editCommentAria')}
className="comment-input bg-transparent"
/>
<div className="flex justify-end">
<button
type="button"
disabled={busy || text.trim().length === 0}
onClick={() => void act(() => updateComment(boardRef, issueId, comment.id, text.trim()))}
className="issue-action issue-action-strong"
>
{tr('common.action.save')}
</button>
</div>
</div>
) : (
<p className="comment-body">{comment.content}</p>
)}
{error ? (
<p role="alert" className="comment-error">
{error}
</p>
) : null}
{node.replies?.length ? (
<div className="comment-replies">
{node.replies.map((reply2) => (
<CommentCard
key={reply2.comment.id}
node={reply2}
boardRef={boardRef}
issueId={issueId}
viewerId={viewerId}
onReload={onReload}
/>
))}
</div>
) : null}
{!editing && replying ? (
<div className="comment-reply-row">
<textarea
ref={replyBox}
value={reply}
onChange={(event) => setReply(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setReplying(false)
return
}
if (!(event.metaKey || event.ctrlKey) || event.key !== 'Enter' || event.nativeEvent.isComposing) return
event.preventDefault()
void submitReply()
}}
rows={2}
placeholder={tr('issue.board.replyPlaceholder')}
aria-label={tr('issue.board.replyAria', {
name: comment.poster?.display_name ?? tr('issue.board.commentFallback'),
})}
className="comment-reply-input bg-transparent"
/>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin text-shell-ink-muted" aria-hidden /> : null}
<button
type="button"
disabled={busy || reply.trim().length === 0}
onClick={() => void submitReply()}
aria-label={tr('issue.board.send')}
title={tr('issue.board.send')}
className="comment-send"
>
<ArrowUp className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
) : null}
</article>
)
}
/**
* One change on the timeline — or a run of them. The line reads
* `actor 将里程碑 未设置 → v1.0 · 09-23`, with the changed values called out; a
* run keeps its first line and offers the rest behind a count.
*/
function EventRow({ events, memberName }: { events: IssueEvent[]; memberName: (id: string) => string | undefined }) {
const { t } = useT()
const [open, setOpen] = useState(false)
const first = events[0]
const last = events[events.length - 1]
const grouped = events.length > 1
const rows = grouped && !open ? [first] : events
return (
<div className="issue-event">
<span className="issue-event-icon">
{first.user ? (
<UserAvatar user={first.user} size={20} />
) : (
<span className="issue-event-dot">
<Users className="h-3 w-3" aria-hidden />
</span>
)}
</span>
<div className="issue-event-stack">
{rows.map((event) => (
<p key={event.id} className="issue-event-body">
<span className="issue-event-actor">{event.user?.display_name ?? t('issue.board.system')}</span>{' '}
{describeEvent(event, memberName, t)}
<span className="issue-event-time" title={fmtTime(event.created_at)}>
{' '}
· {fmtDay(event.created_at)}
</span>
</p>
))}
{grouped ? (
<button
type="button"
className="issue-event-toggle"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
<ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden />
{open
? t('issue.board.event.runCollapse')
: t('issue.board.event.runExpand', { count: events.length, time: fmtDay(last.created_at) })}
</button>
) : null}
</div>
</div>
)
}
/** Wrap each `value` inside the rendered sentence so changes stand out. */
function highlightValues(message: string, values: string[]): ReactNode {
let parts: ReactNode[] = [message]
values
.filter((value) => value.length > 0)
.forEach((value, index) => {
const next: ReactNode[] = []
for (const part of parts) {
if (typeof part !== 'string') {
next.push(part)
continue
}
const at = part.indexOf(value)
if (at === -1) {
next.push(part)
continue
}
next.push(
part.slice(0, at),
<span key={`${index}-${at}`} className="issue-event-value">
{value}
</span>,
part.slice(at + value.length),
)
}
parts = next
})
return parts
}
/**
* The verb plus its payload. The backend already stores what changed —
* `content` for milestones (`old -> new`), labels and members, `old_title` /
* `new_title` for renames — so the line can say what happened instead of
* repeating "调整了里程碑" four times.
*/
function describeEvent(
event: IssueEvent,
memberName: (id: string) => string | undefined,
t: ReturnType<typeof useT>['t'],
): ReactNode {
const unset = t('issue.board.unset')
const content = (event.content ?? '').trim()
switch (event.event_type) {
case 'milestone_updated': {
const [from, to] = content.split('->').map((part) => part.trim())
if (!from || !to) break
const values = [from === 'none' ? unset : from, to === 'none' ? unset : to]
return highlightValues(t('issue.event.verb.milestone_updated', { from: values[0], to: values[1] }), values)
}
case 'title_updated': {
if (!event.new_title) break
const from = event.old_title ?? unset
return highlightValues(t('issue.event.verb.title_updated', { from, to: event.new_title }), [
from,
event.new_title,
])
}
case 'label_added':
case 'label_removed': {
if (!content) break
const key = event.event_type === 'label_added' ? 'issue.event.verb.label_added' : 'issue.event.verb.label_removed'
return highlightValues(t(key, { name: content }), [content])
}
case 'member_added':
case 'member_removed': {
// The payload is `Kind <uuid>`; a name we can resolve reads far better.
const [kind, id] = content.split(' ')
const name = id ? memberName(id) : undefined
if (!kind || !name) break
const key =
event.event_type === 'member_added' ? 'issue.event.verb.member_added' : 'issue.event.verb.member_removed'
return highlightValues(t(key, { name }), [name])
}
case 'pull_linked':
case 'pull_unlinked': {
// 后端存的是 `#<编号> <标题>`;整段作为高亮值即可。
if (!content) break
const key = event.event_type === 'pull_linked' ? 'issue.event.verb.pull_linked' : 'issue.event.verb.pull_unlinked'
return highlightValues(t(key, { name: content }), [content])
}
default:
break
}
return eventLabel(event.event_type)
}