feat: 需求池重构 + 联动引擎 + 开发状态推导
架构:新建 lib/linkage-engine.ts 联动引擎 - deriveReqDevStatus(): 从 devTasks 推导需求开发状态 - canEditRequirement(): 开发中的需求不可编辑 - checkVersionChangeRisk(): 版本变更风险检查 - 所有跨模块推导逻辑集中在此文件 功能改动: 1. 导航改名"需求"→"需求池" 2. 新建需求:所属产品/项目必填,移除产品负责人字段 3. 需求表格新增"开发状态"列: 未排期/待开发/开发中/已完成(从 devTasks 实时推导) 4. 开发中的需求:编辑按钮禁用 5. 需求设 versionId → 版本详情关联需求天然兼容 (版本详情通过 filter(r.versionId === versionId) 读取) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,11 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Search, Plus, Lightbulb, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||||
import { useProductStore } from '@/stores/useProductStore';
|
import { useProductStore } from '@/stores/useProductStore';
|
||||||
|
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||||
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
import { flattenProjects, flattenVersions } from '@/lib/derive';
|
||||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||||
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
import type { Requirement, RequirementStatus, SourceType } from '@/lib/requirement';
|
||||||
|
import { deriveReqDevStatus, canEditRequirement, REQ_DEV_STATUS_LABEL, REQ_DEV_STATUS_COLOR } from '@/lib/linkage-engine';
|
||||||
import { Pagination, usePagination } from '@/components/Pagination';
|
import { Pagination, usePagination } from '@/components/Pagination';
|
||||||
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
import { RequirementModal } from '@/components/requirement/RequirementModal';
|
||||||
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
import { RequirementDetail } from '@/components/requirement/RequirementDetail';
|
||||||
@@ -42,6 +44,7 @@ export default function RequirementsPage() {
|
|||||||
addPlatform, updatePlatform, deletePlatform,
|
addPlatform, updatePlatform, deletePlatform,
|
||||||
} = useRequirementStore();
|
} = useRequirementStore();
|
||||||
const { overview, fetchOverview } = useProductStore();
|
const { overview, fetchOverview } = useProductStore();
|
||||||
|
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||||
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
const allProjects = useMemo(() => flattenProjects(overview), [overview]);
|
||||||
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
const allVersions = useMemo(() => flattenVersions(overview), [overview]);
|
||||||
|
|
||||||
@@ -62,6 +65,7 @@ export default function RequirementsPage() {
|
|||||||
|
|
||||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||||
|
useEffect(() => { fetchDevTasks(); }, [fetchDevTasks]);
|
||||||
|
|
||||||
// Resolve version name from overview
|
// Resolve version name from overview
|
||||||
const resolveVersionName = (versionId?: string): string => {
|
const resolveVersionName = (versionId?: string): string => {
|
||||||
@@ -249,7 +253,7 @@ export default function RequirementsPage() {
|
|||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">状态</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">状态</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">优先级</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">优先级</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">所属版本</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">所属版本</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">产品负责人</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">开发状态</th>
|
||||||
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">录入人员</th>
|
<th className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)]">录入人员</th>
|
||||||
<th
|
<th
|
||||||
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
|
className="px-4 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--ink-muted)] cursor-pointer select-none hover:text-[var(--ink-soft)] transition-colors"
|
||||||
@@ -314,9 +318,16 @@ export default function RequirementsPage() {
|
|||||||
{resolveVersionName(req.versionId)}
|
{resolveVersionName(req.versionId)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 产品负责人 */}
|
{/* 开发状态 */}
|
||||||
<td className="px-4 py-3 text-[12px] text-[var(--ink-soft)]">
|
<td className="px-4 py-3">
|
||||||
{req.productOwner || '-'}
|
{(() => {
|
||||||
|
const devStatus = deriveReqDevStatus(req.id, devTasks);
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-medium ${REQ_DEV_STATUS_COLOR[devStatus]}`}>
|
||||||
|
{REQ_DEV_STATUS_LABEL[devStatus]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* 录入人员 */}
|
{/* 录入人员 */}
|
||||||
@@ -332,8 +343,8 @@ export default function RequirementsPage() {
|
|||||||
{/* 操作 */}
|
{/* 操作 */}
|
||||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{/* 编辑:待评审/已采纳/已规划 */}
|
{/* 编辑:待评审/已采纳/已规划,且开发中不可编辑 */}
|
||||||
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && (
|
{(['pending_review', 'adopted', 'planned'] as const).includes(req.status as any) && canEditRequirement(req.id, devTasks) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(req)}
|
onClick={() => handleEdit(req)}
|
||||||
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
|
className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] transition-colors"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const NAV_GROUPS = [
|
|||||||
{ label: '产品', path: '/products', icon: Package },
|
{ label: '产品', path: '/products', icon: Package },
|
||||||
{ label: '项目', path: '/projects', icon: FolderKanban },
|
{ label: '项目', path: '/projects', icon: FolderKanban },
|
||||||
{ label: '版本', path: '/versions', icon: Tag },
|
{ label: '版本', path: '/versions', icon: Tag },
|
||||||
{ label: '需求', path: '/requirements', icon: Lightbulb },
|
{ label: '需求池', path: '/requirements', icon: Lightbulb },
|
||||||
{ label: '加班记录', path: '/overtime', icon: Clock },
|
{ label: '加班记录', path: '/overtime', icon: Clock },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,19 +115,18 @@ export function RequirementModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim() || !productId || !projectId) return;
|
||||||
onSubmit({
|
onSubmit({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
sourceType,
|
sourceType,
|
||||||
sourceTarget: sourceTarget || undefined,
|
sourceTarget: sourceTarget || undefined,
|
||||||
productId: productId || undefined,
|
productId,
|
||||||
projectId: projectId || undefined,
|
projectId,
|
||||||
versionId: versionId || undefined,
|
versionId: versionId || undefined,
|
||||||
platforms: selectedPlatforms,
|
platforms: selectedPlatforms,
|
||||||
typeId: typeId || undefined,
|
typeId: typeId || undefined,
|
||||||
priority,
|
priority,
|
||||||
productOwner: productOwner.trim() || undefined,
|
|
||||||
parentId: parentId || undefined,
|
parentId: parentId || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -241,7 +240,7 @@ export function RequirementModal({
|
|||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
所属产品
|
所属产品 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={productId}
|
value={productId}
|
||||||
@@ -256,7 +255,7 @@ export function RequirementModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
所属项目
|
所属项目 <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={projectId}
|
value={projectId}
|
||||||
@@ -379,20 +378,6 @@ export function RequirementModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 9. 产品负责人 */}
|
|
||||||
<div>
|
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
|
||||||
产品负责人
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={productOwner}
|
|
||||||
onChange={(e) => setProductOwner(e.target.value)}
|
|
||||||
placeholder="请输入负责人姓名"
|
|
||||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] outline-none focus:border-[var(--accent)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 10. 关联父需求 */}
|
{/* 10. 关联父需求 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1.5 block">
|
||||||
|
|||||||
52
apps/web/lib/linkage-engine.ts
Normal file
52
apps/web/lib/linkage-engine.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { DevTask, DevTaskStatus } from './dev-task';
|
||||||
|
|
||||||
|
export type ReqDevStatus = 'unscheduled' | 'todo' | 'developing' | 'completed';
|
||||||
|
|
||||||
|
export const REQ_DEV_STATUS_LABEL: Record<ReqDevStatus, string> = {
|
||||||
|
unscheduled: '未排期',
|
||||||
|
todo: '待开发',
|
||||||
|
developing: '开发中',
|
||||||
|
completed: '已完成',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQ_DEV_STATUS_COLOR: Record<ReqDevStatus, string> = {
|
||||||
|
unscheduled: 'bg-zinc-100 text-zinc-500',
|
||||||
|
todo: 'bg-amber-50 text-amber-600',
|
||||||
|
developing: 'bg-blue-50 text-blue-600',
|
||||||
|
completed: 'bg-emerald-50 text-emerald-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推导需求的开发状态
|
||||||
|
* 规则:根据关联的 devTasks 状态聚合推导
|
||||||
|
*/
|
||||||
|
export function deriveReqDevStatus(reqId: string, devTasks: DevTask[]): ReqDevStatus {
|
||||||
|
const tasks = devTasks.filter((t) => t.requirementId === reqId);
|
||||||
|
if (tasks.length === 0) return 'unscheduled';
|
||||||
|
if (tasks.every((t) => t.status === 'submitted')) return 'completed';
|
||||||
|
if (tasks.some((t) => t.status === 'in_progress' || t.status === 'testing')) return 'developing';
|
||||||
|
return 'todo';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需求是否可编辑
|
||||||
|
* 规则:开发中(有 in_progress 或 testing 的任务)不可编辑
|
||||||
|
*/
|
||||||
|
export function canEditRequirement(reqId: string, devTasks: DevTask[]): boolean {
|
||||||
|
const status = deriveReqDevStatus(reqId, devTasks);
|
||||||
|
return status !== 'developing';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需求设置 versionId 的副作用检查
|
||||||
|
* 当需求已有关联的开发任务时,换版本是高风险操作
|
||||||
|
*/
|
||||||
|
export function checkVersionChangeRisk(reqId: string, devTasks: DevTask[]): { safe: boolean; reason?: string } {
|
||||||
|
const tasks = devTasks.filter((t) => t.requirementId === reqId);
|
||||||
|
if (tasks.length === 0) return { safe: true };
|
||||||
|
const activeTasks = tasks.filter((t) => t.status !== 'submitted');
|
||||||
|
if (activeTasks.length > 0) {
|
||||||
|
return { safe: false, reason: `该需求有 ${activeTasks.length} 个未完成的开发任务,变更版本可能导致数据不一致` };
|
||||||
|
}
|
||||||
|
return { safe: true };
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export interface Requirement {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
projectId?: string;
|
projectId: string;
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
sourceType: SourceType;
|
sourceType: SourceType;
|
||||||
sourceTarget: string;
|
sourceTarget: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user