✨ feat(vscode): 看板顶部加筛选框,按标题 / 工单号 / PR 号过滤卡片
工单多了以后靠肉眼在四列里翻太慢。顶部输入框按空白分词逐词 AND 匹配 标题、工单号、PR 号,`#427` 与 `427` 等价,纯数字按包含匹配,记不全 编号也能搜到。筛选只收窄渲染,拖拽与依赖锁定仍按全量列表算;列头角标 在筛选时显示「可见 / 总数」。
This commit is contained in:
1 parent
73f2c3ec66
commit
9e255f517b
6 files changed
+167
-8
No files matched your search
@@ -13,6 +13,7 @@ import { ToastStack } from './components/ToastStack'
|
|||||||
import { useIssues } from './hooks/useIssues'
|
import { useIssues } from './hooks/useIssues'
|
||||||
import { useManagedSessions } from './hooks/useManagedSessions'
|
import { useManagedSessions } from './hooks/useManagedSessions'
|
||||||
import { useProfiles } from './hooks/useProfiles'
|
import { useProfiles } from './hooks/useProfiles'
|
||||||
|
import { filterIssues } from './lib/issueFilter'
|
||||||
import { compareIssuesInColumn } from './lib/issueSort'
|
import { compareIssuesInColumn } from './lib/issueSort'
|
||||||
import { COLUMN_ORDER } from './types'
|
import { COLUMN_ORDER } from './types'
|
||||||
|
|
||||||
@@ -127,6 +128,9 @@ export function App() {
|
|||||||
setShowNewIssueModal(false)
|
setShowNewIssueModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 顶部筛选框内容。纯前端过滤,不影响拖拽与详情面板拿到的全量列表。
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
|
||||||
// Drop selectedId if it's no longer present after issues refresh.
|
// Drop selectedId if it's no longer present after issues refresh.
|
||||||
const readyIssues = state.status === 'ready' ? state.issues : null
|
const readyIssues = state.status === 'ready' ? state.issues : null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,13 +157,15 @@ export function App() {
|
|||||||
|
|
||||||
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
|
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
|
||||||
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
|
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
|
||||||
|
// 键盘导航只在筛选后可见的卡片之间走,和看板显示保持一致。
|
||||||
const ordered = useMemo<Issue[][]>(() => {
|
const ordered = useMemo<Issue[][]>(() => {
|
||||||
if (!readyIssues)
|
if (!readyIssues)
|
||||||
return []
|
return []
|
||||||
|
const visible = filterIssues(readyIssues, filterQuery)
|
||||||
return COLUMN_ORDER.map(col =>
|
return COLUMN_ORDER.map(col =>
|
||||||
readyIssues.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)),
|
visible.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)),
|
||||||
)
|
)
|
||||||
}, [readyIssues])
|
}, [readyIssues, filterQuery])
|
||||||
|
|
||||||
// Resolved selected issue (or null) for the detail panel.
|
// Resolved selected issue (or null) for the detail panel.
|
||||||
const selectedIssue = useMemo<Issue | null>(() => {
|
const selectedIssue = useMemo<Issue | null>(() => {
|
||||||
@@ -312,6 +318,8 @@ export function App() {
|
|||||||
{state.status === 'loading' && (
|
{state.status === 'loading' && (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterChange={setFilterQuery}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
@@ -339,6 +347,8 @@ export function App() {
|
|||||||
{state.status === 'ready' && (
|
{state.status === 'ready' && (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterChange={setFilterQuery}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
@@ -361,6 +371,7 @@ export function App() {
|
|||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
issues={state.issues}
|
issues={state.issues}
|
||||||
|
filterQuery={filterQuery}
|
||||||
onIssuesChange={setIssues}
|
onIssuesChange={setIssues}
|
||||||
onCreateIssue={() => setShowNewIssueModal(true)}
|
onCreateIssue={() => setShowNewIssueModal(true)}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
@@ -445,6 +456,8 @@ export function App() {
|
|||||||
{state.status === 'error' && (
|
{state.status === 'error' && (
|
||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
|
filterQuery={filterQuery}
|
||||||
|
onFilterChange={setFilterQuery}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
|
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
|
||||||
import { isIssueLocked } from '../lib/dependencies'
|
import { isIssueLocked } from '../lib/dependencies'
|
||||||
|
import { filterIssues } from '../lib/issueFilter'
|
||||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||||
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
|
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
|
||||||
import { COLUMN_ORDER } from '../types'
|
import { COLUMN_ORDER } from '../types'
|
||||||
@@ -27,6 +28,11 @@ import { KanbanColumn } from './KanbanColumn'
|
|||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
issues: Issue[]
|
issues: Issue[]
|
||||||
|
/**
|
||||||
|
* 顶部筛选框内容。只收窄渲染出来的卡片;拖拽、依赖锁定仍按 `issues`
|
||||||
|
* 全量算,免得筛选期间的一次拖动把被隐藏的工单从列表里抹掉。
|
||||||
|
*/
|
||||||
|
filterQuery?: string
|
||||||
onIssuesChange: (next: Issue[]) => void
|
onIssuesChange: (next: Issue[]) => void
|
||||||
/** Renders the + button on the todo column when provided. */
|
/** Renders the + button on the todo column when provided. */
|
||||||
onCreateIssue?: () => void
|
onCreateIssue?: () => void
|
||||||
@@ -103,6 +109,7 @@ function buildTodoTree(todoIssues: Issue[]): Array<{ issue: Issue, depth: number
|
|||||||
|
|
||||||
export function KanbanBoard({
|
export function KanbanBoard({
|
||||||
issues,
|
issues,
|
||||||
|
filterQuery = '',
|
||||||
onIssuesChange,
|
onIssuesChange,
|
||||||
onCreateIssue,
|
onCreateIssue,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -127,7 +134,7 @@ export function KanbanBoard({
|
|||||||
'review': [],
|
'review': [],
|
||||||
'done': [],
|
'done': [],
|
||||||
}
|
}
|
||||||
for (const issue of issues)
|
for (const issue of filterIssues(issues, filterQuery))
|
||||||
map[issue.column].push(issue)
|
map[issue.column].push(issue)
|
||||||
// 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number
|
// 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number
|
||||||
// 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按
|
// 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按
|
||||||
@@ -135,6 +142,14 @@ export function KanbanBoard({
|
|||||||
for (const col of Object.keys(map) as IssueColumn[])
|
for (const col of Object.keys(map) as IssueColumn[])
|
||||||
map[col].sort((a, b) => compareIssuesInColumn(col, a, b))
|
map[col].sort((a, b) => compareIssuesInColumn(col, a, b))
|
||||||
return map
|
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])
|
}, [issues])
|
||||||
|
|
||||||
const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null
|
const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null
|
||||||
@@ -240,6 +255,7 @@ export function KanbanBoard({
|
|||||||
key={column}
|
key={column}
|
||||||
column={column}
|
column={column}
|
||||||
issues={columnIssues}
|
issues={columnIssues}
|
||||||
|
totalCount={totalByColumn[column]}
|
||||||
onCreate={column === 'todo' ? onCreateIssue : undefined}
|
onCreate={column === 'todo' ? onCreateIssue : undefined}
|
||||||
>
|
>
|
||||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { Badge } from './ui/badge'
|
|||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
column: IssueColumn
|
column: IssueColumn
|
||||||
issues: Issue[]
|
issues: Issue[]
|
||||||
|
/** 该列未经筛选的卡片总数。与 `issues.length` 不等时角标显示「可见 / 总数」。 */
|
||||||
|
totalCount?: number
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
/** When provided, renders a + button in the header. Used for the todo column. */
|
/** When provided, renders a + button in the header. Used for the todo column. */
|
||||||
onCreate?: () => void
|
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 })
|
const { setNodeRef, isOver } = useDroppable({ id: column })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,7 +34,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
|
|||||||
{COLUMN_LABELS[column]}
|
{COLUMN_LABELS[column]}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<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 && (
|
{onCreate && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -49,7 +52,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
|
|||||||
{issues.length === 0
|
{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">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { ReleaseAction, ReleaseStatus } from '../hooks/useIssues'
|
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'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** 顶部筛选框当前内容;按标题 / 工单号 / PR 号过滤看板卡片。 */
|
||||||
|
filterQuery: string
|
||||||
|
onFilterChange: (query: string) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
/** host 正在重拉看板;为 true 时刷新图标转起来。 */
|
/** host 正在重拉看板;为 true 时刷新图标转起来。 */
|
||||||
refreshing: boolean
|
refreshing: boolean
|
||||||
@@ -66,6 +69,8 @@ function releaseTitle(release: ReleaseStatus | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PanelHeader({
|
export function PanelHeader({
|
||||||
|
filterQuery,
|
||||||
|
onFilterChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
refreshing,
|
refreshing,
|
||||||
scope,
|
scope,
|
||||||
@@ -113,7 +118,35 @@ export function PanelHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div ref={menuRef} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { Issue } from '../types'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { filterIssues } from './issueFilter'
|
||||||
|
|
||||||
|
function issue(number: number, title: string, extra: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: `repo#${number}`,
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
column: 'todo',
|
||||||
|
htmlUrl: '',
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const board = [
|
||||||
|
issue(426, '编程大赛:报名与提交作品'),
|
||||||
|
issue(427, '编程大赛-作品墙分类', { pr: '318' }),
|
||||||
|
issue(419, '管理后台统计各模型用量'),
|
||||||
|
issue(42, 'auto 模型:自动为用户会话选模型'),
|
||||||
|
]
|
||||||
|
|
||||||
|
function numbers(query: string): number[] {
|
||||||
|
return filterIssues(board, query).map(i => i.number)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('filterIssues', () => {
|
||||||
|
it('空查询放行全部,且原样返回同一个数组', () => {
|
||||||
|
expect(filterIssues(board, ' ')).toBe(board)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按标题子串匹配', () => {
|
||||||
|
expect(numbers('作品')).toEqual([426, 427])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题匹配不区分大小写', () => {
|
||||||
|
expect(numbers('AUTO')).toEqual([42])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按工单号匹配,带不带 # 都行', () => {
|
||||||
|
expect(numbers('#427')).toEqual([427])
|
||||||
|
expect(numbers('427')).toEqual([427])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('数字按包含匹配,短号能带出长号', () => {
|
||||||
|
expect(numbers('42')).toEqual([426, 427, 42])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 PR 号匹配', () => {
|
||||||
|
expect(numbers('318')).toEqual([427])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多个词之间是 AND', () => {
|
||||||
|
expect(numbers('编程 分类')).toEqual([427])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有命中时返回空', () => {
|
||||||
|
expect(numbers('不存在的东西')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Issue } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板顶部筛选框的匹配规则。
|
||||||
|
*
|
||||||
|
* 查询按空白切成多个词,逐词 AND:工单要留在板上,每个词都得命中它的
|
||||||
|
* 标题、工单号或 PR 号之一。词首的 `#` 先去掉,`#427` 与 `427` 等价。
|
||||||
|
* 纯数字的词按「包含」匹配编号,`42` 既能带出 #42 也能带出 #427,
|
||||||
|
* 免得用户记不全编号就搜不到。
|
||||||
|
*/
|
||||||
|
export function parseFilterTerms(query: string): string[] {
|
||||||
|
return query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTerm(issue: Issue, term: string): boolean {
|
||||||
|
if (issue.title.toLowerCase().includes(term))
|
||||||
|
return true
|
||||||
|
const digits = term.replace(/^#/, '')
|
||||||
|
if (digits === '' || !/^\d+$/.test(digits))
|
||||||
|
return false
|
||||||
|
return String(issue.number).includes(digits) || (issue.pr ?? '').includes(digits)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesIssueFilter(issue: Issue, terms: string[]): boolean {
|
||||||
|
return terms.every(term => matchesTerm(issue, term))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空查询原样返回,调用方据此免掉一次无意义的数组拷贝。 */
|
||||||
|
export function filterIssues(issues: Issue[], query: string): Issue[] {
|
||||||
|
const terms = parseFilterTerms(query)
|
||||||
|
if (terms.length === 0)
|
||||||
|
return issues
|
||||||
|
return issues.filter(issue => matchesIssueFilter(issue, terms))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user