✨ feat(vscode): 看板顶部加筛选框,按标题 / 工单号 / PR 号过滤卡片

工单多了以后靠肉眼在四列里翻太慢。顶部输入框按空白分词逐词 AND 匹配
标题、工单号、PR 号,`#427` 与 `427` 等价,纯数字按包含匹配,记不全
编号也能搜到。筛选只收窄渲染,拖拽与依赖锁定仍按全量列表算;列头角标
在筛选时显示「可见 / 总数」。
This commit is contained in:
cruldra committed 2026-09-20 17:24:54 +08:00
1 parent 73f2c3ec66
commit 9e255f517b
6 files changed
+167 -8

No files matched your search

@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef, useState } from 'react'
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
import { isIssueLocked } from '../lib/dependencies'
import { filterIssues } from '../lib/issueFilter'
import { compareIssuesInColumn } from '../lib/issueSort'
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
import { COLUMN_ORDER } from '../types'
@@ -27,6 +28,11 @@ import { KanbanColumn } from './KanbanColumn'
interface KanbanBoardProps {
issues: Issue[]
/**
* 顶部筛选框内容。只收窄渲染出来的卡片;拖拽、依赖锁定仍按 `issues`
* 全量算,免得筛选期间的一次拖动把被隐藏的工单从列表里抹掉。
*/
filterQuery?: string
onIssuesChange: (next: Issue[]) => void
/** Renders the + button on the todo column when provided. */
onCreateIssue?: () => void
@@ -103,6 +109,7 @@ function buildTodoTree(todoIssues: Issue[]): Array<{ issue: Issue, depth: number
export function KanbanBoard({
issues,
filterQuery = '',
onIssuesChange,
onCreateIssue,
selectedId,
@@ -127,7 +134,7 @@ export function KanbanBoard({
'review': [],
'done': [],
}
for (const issue of issues)
for (const issue of filterIssues(issues, filterQuery))
map[issue.column].push(issue)
// 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number
// 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按
@@ -135,6 +142,14 @@ export function KanbanBoard({
for (const col of Object.keys(map) as IssueColumn[])
map[col].sort((a, b) => compareIssuesInColumn(col, a, b))
return map
}, [issues, filterQuery])
// 未筛选时的每列总数,供列头角标显示「可见 / 总数」。
const totalByColumn = useMemo(() => {
const map: Record<IssueColumn, number> = { 'todo': 0, 'in-progress': 0, 'review': 0, 'done': 0 }
for (const issue of issues)
map[issue.column] += 1
return map
}, [issues])
const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null
@@ -240,6 +255,7 @@ export function KanbanBoard({
key={column}
column={column}
issues={columnIssues}
totalCount={totalByColumn[column]}
onCreate={column === 'todo' ? onCreateIssue : undefined}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -9,12 +9,15 @@ import { Badge } from './ui/badge'
interface KanbanColumnProps {
column: IssueColumn
issues: Issue[]
/** 该列未经筛选的卡片总数。与 `issues.length` 不等时角标显示「可见 / 总数」。 */
totalCount?: number
children: ReactNode
/** When provided, renders a + button in the header. Used for the todo column. */
onCreate?: () => void
}
export function KanbanColumn({ column, issues, children, onCreate }: KanbanColumnProps) {
export function KanbanColumn({ column, issues, totalCount, children, onCreate }: KanbanColumnProps) {
const filtered = totalCount !== undefined && totalCount !== issues.length
const { setNodeRef, isOver } = useDroppable({ id: column })
return (
@@ -31,7 +34,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
{COLUMN_LABELS[column]}
</span>
<div className="flex items-center gap-1.5">
<Badge variant="outline">{issues.length}</Badge>
<Badge variant="outline">{filtered ? `${issues.length} / ${totalCount}` : issues.length}</Badge>
{onCreate && (
<button
type="button"
@@ -49,7 +52,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
{issues.length === 0
? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed border-[var(--vscode-foreground)] border-opacity-20 p-4 text-xs text-muted-foreground opacity-60">
拖到这里
{filtered ? '没有匹配的工单' : '拖到这里'}
</div>
)
: (
@@ -1,8 +1,11 @@
import type { ReleaseAction, ReleaseStatus } from '../hooks/useIssues'
import { GitCommit, ListPlus, Loader2, Lock, RefreshCw, Rocket, Settings, Unlock, User, Users } from 'lucide-react'
import { GitCommit, ListPlus, Loader2, Lock, RefreshCw, Rocket, Search, Settings, Unlock, User, Users, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface Props {
/** 顶部筛选框当前内容;按标题 / 工单号 / PR 号过滤看板卡片。 */
filterQuery: string
onFilterChange: (query: string) => void
onRefresh: () => void
/** host 正在重拉看板;为 true 时刷新图标转起来。 */
refreshing: boolean
@@ -66,6 +69,8 @@ function releaseTitle(release: ReleaseStatus | undefined): string {
}
export function PanelHeader({
filterQuery,
onFilterChange,
onRefresh,
refreshing,
scope,
@@ -113,7 +118,35 @@ export function PanelHeader({
}
return (
<div className="flex h-8 shrink-0 items-center justify-end gap-1 border-b border-[var(--vscode-panel-border)] px-2">
<div className="flex h-8 shrink-0 items-center gap-1 border-b border-[var(--vscode-panel-border)] px-2">
<div className="relative mr-auto flex items-center">
<Search size={12} className="pointer-events-none absolute left-2 opacity-60" />
<input
type="text"
value={filterQuery}
onChange={e => onFilterChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onFilterChange('')
}
}}
placeholder="筛选:标题 / 工单号 / PR 号"
aria-label="筛选工单"
className="h-6 w-56 rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] pl-6 pr-6 text-xs text-[var(--vscode-input-foreground)] outline-none placeholder:text-[var(--vscode-input-placeholderForeground)] focus:border-[var(--vscode-focusBorder)]"
/>
{filterQuery !== '' && (
<button
type="button"
onClick={() => onFilterChange('')}
title="清空筛选"
aria-label="清空筛选"
className="absolute right-1 flex h-4 w-4 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
<X size={11} />
</button>
)}
</div>
<div ref={menuRef} className="relative">
<button
type="button"