diff --git a/.gitignore b/.gitignore index d6a19f9..ea06a67 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ dist/ *.log .turbo/ coverage/ +.tmp/ +apps/web/.tmp-test/ .DS_Store next-env.d.ts *.tsbuildinfo diff --git a/apps/web/app/versions/[id]/page.tsx b/apps/web/app/versions/[id]/page.tsx index 7b305b3..1235f58 100644 --- a/apps/web/app/versions/[id]/page.tsx +++ b/apps/web/app/versions/[id]/page.tsx @@ -9,6 +9,7 @@ import { useOvertimeStore } from '@/stores/useOvertimeStore'; import { getVersionDetail } from '@/lib/derive'; import type { Role } from '@/lib/stage'; import { CapsuleStages } from '@/components/version/CapsuleStages'; +import { FilterSelect } from '@/components/FilterSelect'; import { calcHealthScore, getHealthLevel, calcRiskTags, HEALTH_LEVEL_LABEL, getTagStyle } from '@/lib/health'; import { CHANGE_REASON_LABEL } from '@/lib/requirement'; import { OVERTIME_REASON_LABEL } from '@/lib/overtime'; @@ -1321,13 +1322,13 @@ function MemberSettingModal({ members, allMembers, onSave, onClose }: { {list.map((member) => (
{member.name} - + onChange={(value) => handleRoleChange(member.name, value as Role)} + options={roleOptions} + showAllOption={false} + className="w-24 shrink-0" + /> diff --git a/apps/web/components/FilterSelect.tsx b/apps/web/components/FilterSelect.tsx index b3e05ce..d6d3516 100644 --- a/apps/web/components/FilterSelect.tsx +++ b/apps/web/components/FilterSelect.tsx @@ -14,10 +14,12 @@ interface FilterSelectProps { options: SelectOption[]; placeholder?: string; allLabel?: string; + showAllOption?: boolean; className?: string; + labelClassName?: string; } -export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', className }: FilterSelectProps) { +export function FilterSelect({ value, onChange, options, placeholder, allLabel = '全部', showAllOption = true, className, labelClassName }: FilterSelectProps) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -37,9 +39,9 @@ export function FilterSelect({ value, onChange, options, placeholder, allLabel = + {showAllOption && ( + + )} {options.map((opt) => ( diff --git a/apps/web/components/bug/BugTab.tsx b/apps/web/components/bug/BugTab.tsx index 3e78db2..9edb9e3 100644 --- a/apps/web/components/bug/BugTab.tsx +++ b/apps/web/components/bug/BugTab.tsx @@ -10,6 +10,7 @@ import { useMemberStore } from '@/stores/useMemberStore'; import { BugRow } from './BugRow'; import { BugDetailDrawer } from './BugDetailDrawer'; import { Pagination, usePagination } from '@/components/Pagination'; +import { FilterSelect } from '@/components/FilterSelect'; import { SearchInput, matchTitleOrNo } from '@/components/SearchInput'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, aggregateBugActualHours, bugIntervals } from '@/lib/bug'; @@ -118,19 +119,27 @@ export function BugTab({ versionId, requirementIds }: Props) {
{ setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> - - - + { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }} + options={[ + ...(currentMember ? [{ value: '__me', label: '我的Bug' }] : []), + ...assignees.filter((assignee) => assignee !== currentUserName).map((assignee) => ({ value: assignee, label: assignee })), + ]} + allLabel="全部修复人" + /> + { setFilterStatus(value === 'all' ? '' : value); setPage(1); }} + options={(Object.entries(BUG_STATUS_LABEL) as [BugStatus, string][]).map(([value, label]) => ({ value, label }))} + allLabel="全部状态" + /> + { setFilterSeverity(value === 'all' ? '' : value); setPage(1); }} + options={(Object.entries(BUG_SEVERITY_LABEL) as [BugSeverity, string][]).map(([value, label]) => ({ value, label }))} + allLabel="全部严重程度" + /> {hasFilter && } {filteredBugs.length} 条
diff --git a/apps/web/components/dev-task/DevTaskCreateModal.tsx b/apps/web/components/dev-task/DevTaskCreateModal.tsx index 2bf8dd2..122892f 100644 --- a/apps/web/components/dev-task/DevTaskCreateModal.tsx +++ b/apps/web/components/dev-task/DevTaskCreateModal.tsx @@ -7,6 +7,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { FilterSelect } from '@/components/FilterSelect'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import { calcWorkHours, formatWorkHours, isoToLocal, localToISO } from '@/lib/work-hours'; import type { Priority } from '@/lib/derive'; @@ -147,23 +148,44 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
- + { + setRequirementId(value); + const r = versionReqs.find((x) => x.id === value); + if (r && !priorityManuallySet) setPriority(r.priority); + }} + options={versionReqs.map((r) => ({ value: r.id, label: `${r.code} ${r.title}` }))} + placeholder="选择所属需求" + showAllOption={false} + className="w-full" + labelClassName="max-w-[calc(100%-20px)]" + />
- + ({ value: c.id, label: c.name }))} + placeholder="选择任务类型" + showAllOption={false} + className="w-full" + labelClassName="max-w-[calc(100%-20px)]" + />
- + ({ value: m.name, label: m.name }))} + placeholder="选择负责人" + showAllOption={false} + className="w-full" + labelClassName="max-w-[calc(100%-20px)]" + />
@@ -192,9 +214,14 @@ export function DevTaskCreateModal({ versionId, requirementIds, versionDeadline,
- + { setPriority(value as Priority); setPriorityManuallySet(true); }} + options={(['P0', 'P1', 'P2', 'P3'] as Priority[]).map((p) => ({ value: p, label: p }))} + showAllOption={false} + className="w-full" + labelClassName="max-w-[calc(100%-20px)]" + />
diff --git a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx index 1aade80..1bd1a4b 100644 --- a/apps/web/components/dev-task/DevTaskDetailDrawer.tsx +++ b/apps/web/components/dev-task/DevTaskDetailDrawer.tsx @@ -11,6 +11,7 @@ import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { useAuthStore } from '@/stores/useAuthStore'; +import { FilterSelect } from '@/components/FilterSelect'; import { WorkDateTimePicker } from '@/components/WorkDateTimePicker'; import { ALLOWED_TRANSITIONS, @@ -194,10 +195,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel {showTransfer && (
转交给: - + setTransferTo(value === 'all' ? '' : value)} + options={members.filter((m) => m.name !== task.assigneeId).map((m) => ({ value: m.name, label: m.name }))} + allLabel="选择人员" + className="flex-1" + labelClassName="max-w-[calc(100%-20px)]" + />
@@ -354,14 +359,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel placeholder="阻塞原因" className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px] focus:border-orange-400 focus:outline-none" /> - + setProgressHelperId(value === 'all' ? '' : value)} + options={members.filter((m) => m.name !== task.assigneeId).map((m) => ({ value: m.name, label: m.name }))} + allLabel="协助人" + className="w-full" + labelClassName="max-w-[calc(100%-20px)]" + />
{ setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" /> - - - - + { setFilterAssignee(value === 'all' ? '' : value); setPage(1); }} + options={[ + ...(user?.name ? [{ value: user.name, label: '我的' }] : []), + ...assignees.filter((a) => a !== user?.name).map((a) => ({ value: a, label: a })), + ]} + allLabel="负责人" + /> + { setFilterStatus(value === 'all' ? '' : value); setPage(1); }} + options={(Object.entries(DEV_TASK_STATUS_LABEL) as [DevTaskStatus, string][]).map(([value, label]) => ({ value, label }))} + allLabel="状态" + /> + { setFilterBlocked(value === 'all' ? '' : value); setPage(1); }} + options={[ + { value: 'yes', label: '阻塞中' }, + { value: 'no', label: '正常' }, + ]} + allLabel="阻塞" + /> + { setFilterCategory(value === 'all' ? '' : value); setPage(1); }} + options={categories.map((category) => ({ value: category.id, label: category.name }))} + allLabel="类型" + /> {hasFilter && }
diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 20cac0c..d0fbb7c 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,11 +1,13 @@ 'use client'; import { usePathname, useRouter } from 'next/navigation'; -import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Search, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react'; +import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert } from 'lucide-react'; import { useHasPermission } from '@/components/auth/Guard'; import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks'; +import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; +import { getWorkspacePendingCount } from '@/lib/workspace-engine'; import { getXiaobaoWarningRiskCount } from '@/lib/xiaobao-warning-view'; type NavItemConfig = { @@ -13,7 +15,7 @@ type NavItemConfig = { path: string; icon: any; permission: string | null; - badge?: 'xiaobao-risk'; + badge?: 'xiaobao-risk' | 'workspace-pending'; }; const NAV_GROUPS = [ @@ -21,7 +23,7 @@ const NAV_GROUPS = [ label: '工作区', items: [ { label: '小宝预警', path: '/xiaobao-warning', icon: TriangleAlert, permission: 'xiaobao.warning:view', badge: 'xiaobao-risk' as const }, - { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null }, + { label: '与我相关', path: '/workspace', icon: Inbox, permission: null as string | null, badge: 'workspace-pending' as const }, { label: '产品', path: '/products', icon: Package, permission: 'product:view' }, { label: '项目', path: '/projects', icon: FolderKanban, permission: 'project:view' }, { label: '版本', path: '/versions', icon: Tag, permission: 'version:view' }, @@ -42,36 +44,40 @@ const NAV_GROUPS = [ export function Sidebar() { const pathname = usePathname(); const router = useRouter(); + const { workItems } = useWorkspaceWorkItems(); + const workspacePendingCount = getWorkspacePendingCount(workItems); const isActive = (path: string) => pathname === path || (path !== '/' && pathname.startsWith(path)); return ( -