refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, X, Check, ExternalLink, FileUp, Link2, Play, ArrowRightLeft } from 'lucide-react';
|
||||
import type { ProductPlanKind, ProductPlanReviewFailureType, ProductPlanReviewResult, VersionPlan, PlanTask } from '@/lib/version-plan';
|
||||
import {
|
||||
@@ -28,6 +28,9 @@ import type { Requirement } from '@/lib/requirement';
|
||||
import { mergeSelectedRequirementOptions } from '@/lib/requirement-selector';
|
||||
import { canEditPlanRequirementCoverage, canTogglePlanChecklist, getPlanCompletionState } from '@/lib/version-plan-workflow';
|
||||
import type { PlanResultPayload } from '@/lib/version-plan-workflow';
|
||||
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
|
||||
|
||||
type PlanMemberCandidate = { id?: string; role?: string; name: string; username?: string | null };
|
||||
|
||||
interface Props {
|
||||
plans: VersionPlan[];
|
||||
@@ -35,8 +38,10 @@ interface Props {
|
||||
version?: VersionWithContext;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
currentUserReference?: string;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionMembers: { role: string; name: string }[];
|
||||
versionMembers: PlanMemberCandidate[];
|
||||
allMembers?: PlanMemberCandidate[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onCreate: (data: Omit<VersionPlan, 'id' | 'createdAt'>) => void;
|
||||
@@ -72,7 +77,34 @@ function getFailureLabels(types?: ProductPlanReviewFailureType[]): string[] {
|
||||
.filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, planType, versionMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
||||
function buildPlanMemberCandidates(
|
||||
versionMembers: PlanMemberCandidate[],
|
||||
allMembers: PlanMemberCandidate[],
|
||||
): PlanMemberCandidate[] {
|
||||
const byName = new Map(allMembers.map((member) => [member.name, member]));
|
||||
const seen = new Set<string>();
|
||||
const out: PlanMemberCandidate[] = [];
|
||||
|
||||
const push = (member: PlanMemberCandidate) => {
|
||||
const key = member.id || member.name;
|
||||
if (!key || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(member);
|
||||
};
|
||||
|
||||
versionMembers.forEach((member) => {
|
||||
const canonical = byName.get(member.name);
|
||||
push({ ...member, id: member.id ?? canonical?.id, username: member.username ?? canonical?.username });
|
||||
});
|
||||
allMembers.forEach(push);
|
||||
return out;
|
||||
}
|
||||
|
||||
function getMemberReferenceValue(member: PlanMemberCandidate): string {
|
||||
return member.username || member.id || member.name;
|
||||
}
|
||||
|
||||
export function PlanTab({ plans, versionId, version, versionDeadline, currentUserName, currentUserReference, planType, versionMembers, allMembers, linkedRequirements, allRequirements, onCreate, onUpdate, onComplete, onDelete, readOnly = false }: Props) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState<VersionPlan | null>(null);
|
||||
const [completingPlan, setCompletingPlan] = useState<VersionPlan | null>(null);
|
||||
@@ -81,6 +113,10 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
|
||||
const typePlans = sortPlansNewestFirst(plans.filter((p) => p.versionId === versionId && p.type === planType));
|
||||
const totalDuration = calcTotalDuration(typePlans);
|
||||
const planMemberCandidates = useMemo(
|
||||
() => buildPlanMemberCandidates(versionMembers, allMembers ?? []),
|
||||
[versionMembers, allMembers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-m-5 h-[calc(100vh-98px)] min-h-[520px]">
|
||||
@@ -113,6 +149,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canToggle = canTogglePlanChecklist(plan);
|
||||
const canEditCoverage = canEditPlanRequirementCoverage(plan);
|
||||
const ownerLabel = resolveMemberDisplayName(plan.owner, planMemberCandidates);
|
||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
// 耗时用实际时间戳计算
|
||||
const dur = plan.status === 'completed' && plan.completedAt && plan.actualStartAt
|
||||
@@ -150,7 +187,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
<dl className="mt-3 grid grid-cols-1 gap-x-5 gap-y-2 border-y border-[var(--line)] py-3 text-[12px] sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||
@@ -276,7 +313,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
<FilterSelect
|
||||
value={transferTo || 'all'}
|
||||
onChange={(value) => setTransferTo(value === 'all' ? '' : value)}
|
||||
options={versionMembers.filter((m) => m.name !== plan.owner).map((m) => ({ value: m.name, label: m.name }))}
|
||||
options={planMemberCandidates.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
|
||||
allLabel="选择参与人员"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -310,7 +347,7 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
versionDeadline={versionDeadline}
|
||||
version={version}
|
||||
currentUserName={currentUserName}
|
||||
versionMembers={versionMembers}
|
||||
versionMembers={planMemberCandidates}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
transferPlanId={transferPlanId}
|
||||
@@ -333,6 +370,8 @@ export function PlanTab({ plans, versionId, version, versionDeadline, currentUse
|
||||
versionId={versionId}
|
||||
versionDeadline={versionDeadline}
|
||||
currentUserName={currentUserName}
|
||||
currentUserReference={currentUserReference}
|
||||
memberCandidates={planMemberCandidates}
|
||||
linkedRequirements={linkedRequirements}
|
||||
allRequirements={allRequirements}
|
||||
onClose={() => { setShowCreateModal(false); setEditingPlan(null); }}
|
||||
@@ -412,7 +451,7 @@ function ProductUiPlanWorkspace({
|
||||
versionDeadline?: string;
|
||||
version?: VersionWithContext;
|
||||
currentUserName: string;
|
||||
versionMembers: { role: string; name: string }[];
|
||||
versionMembers: PlanMemberCandidate[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
transferPlanId: string | null;
|
||||
@@ -427,6 +466,7 @@ function ProductUiPlanWorkspace({
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(typePlans[0]?.id ?? null);
|
||||
const autoStartedPlanIds = useRef<Set<string>>(new Set());
|
||||
const selectedPlan = typePlans.find((plan) => plan.id === selectedPlanId) ?? typePlans[0];
|
||||
const selectedPlanIdOrFirst = selectedPlan?.id;
|
||||
const allLogs = useMemo(() => getPlanLogsForPlans(typePlans), [typePlans]);
|
||||
@@ -435,7 +475,8 @@ function ProductUiPlanWorkspace({
|
||||
if (readOnly) return;
|
||||
typePlans.forEach((plan) => {
|
||||
const { autoStarted } = getPlanRuntime(plan);
|
||||
if (autoStarted && !plan.actualStartAt) {
|
||||
if (autoStarted && !plan.actualStartAt && !autoStartedPlanIds.current.has(plan.id)) {
|
||||
autoStartedPlanIds.current.add(plan.id);
|
||||
onUpdate(plan.id, { status: 'in_progress' });
|
||||
}
|
||||
});
|
||||
@@ -470,6 +511,7 @@ function ProductUiPlanWorkspace({
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{typePlans.map((plan) => {
|
||||
const { effectiveStatus } = getPlanRuntime(plan);
|
||||
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
|
||||
const summary = plan.type === 'research'
|
||||
? getResearchDirectionProgressSummary(plan)
|
||||
: getRequirementCoverageSummary(plan);
|
||||
@@ -488,7 +530,7 @@ function ProductUiPlanWorkspace({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
|
||||
<span className="truncate">{plan.owner}</span>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
{plan.type === 'product' ? (
|
||||
<span className="shrink-0">{PRODUCT_PLAN_KIND_LABEL[getProductPlanKind(plan)]}</span>
|
||||
) : (
|
||||
@@ -577,7 +619,7 @@ function ProductUiPlanDetail({
|
||||
planType: VersionPlan['type'];
|
||||
version?: VersionWithContext;
|
||||
currentUserName: string;
|
||||
versionMembers: { role: string; name: string }[];
|
||||
versionMembers: PlanMemberCandidate[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
transferPlanId: string | null;
|
||||
@@ -595,6 +637,7 @@ function ProductUiPlanDetail({
|
||||
const durText = getPlanDurationText(plan, effectiveStatus, effectiveStartAt, now);
|
||||
const completionState = getPlanCompletionState(plan);
|
||||
const canEditCoverage = !readOnly && canEditPlanRequirementCoverage(plan);
|
||||
const ownerLabel = resolveMemberDisplayName(plan.owner, versionMembers);
|
||||
const requirementOptions = mergeSelectedRequirementOptions(linkedRequirements ?? [], allRequirements ?? [], plan.linkedRequirementIds ?? []);
|
||||
const selectedRequirements = (plan.linkedRequirementIds ?? [])
|
||||
.map((rid) => requirementOptions.find((requirement) => requirement.id === rid))
|
||||
@@ -649,7 +692,7 @@ function ProductUiPlanDetail({
|
||||
<dl className="mt-4 grid grid-cols-1 gap-3 text-[12px] sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">负责人</dt>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{plan.owner}</dd>
|
||||
<dd className="mt-0.5 font-medium text-[var(--ink)]">{ownerLabel}</dd>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] px-3 py-2 sm:col-span-2">
|
||||
<dt className="text-[11px] text-[var(--ink-muted)]">计划时间</dt>
|
||||
@@ -738,7 +781,7 @@ function ProductUiPlanDetail({
|
||||
<FilterSelect
|
||||
value={transferTo || 'all'}
|
||||
onChange={(value) => onTransferToChange(value === 'all' ? '' : value)}
|
||||
options={versionMembers.filter((member) => member.name !== plan.owner).map((member) => ({ value: member.name, label: member.name }))}
|
||||
options={versionMembers.filter((member) => !isMemberReference(plan.owner, member)).map((member) => ({ value: getMemberReferenceValue(member), label: member.name }))}
|
||||
allLabel="选择参与人员"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -757,12 +800,14 @@ function ProductUiPlanDetail({
|
||||
);
|
||||
}
|
||||
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||
function PlanFormModal({ initial, planType, versionId, versionDeadline, currentUserName, currentUserReference, memberCandidates, linkedRequirements, allRequirements, onClose, onSubmit }: {
|
||||
initial: VersionPlan | null;
|
||||
planType: 'research' | 'product' | 'ui';
|
||||
versionId: string;
|
||||
versionDeadline?: string;
|
||||
currentUserName: string;
|
||||
currentUserReference?: string;
|
||||
memberCandidates: PlanMemberCandidate[];
|
||||
linkedRequirements?: Requirement[];
|
||||
allRequirements?: Requirement[];
|
||||
onClose: () => void;
|
||||
@@ -770,7 +815,8 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
}) {
|
||||
const now = new Date().toISOString().slice(0, 16);
|
||||
const [title, setTitle] = useState(initial?.title ?? '');
|
||||
const [owner] = useState(initial?.owner ?? currentUserName);
|
||||
const [owner] = useState(initial?.owner ?? currentUserReference ?? currentUserName);
|
||||
const ownerLabel = resolveMemberDisplayName(owner, memberCandidates);
|
||||
const [startTime, setStartTime] = useState(initial?.startTime?.slice(0, 16) ?? now);
|
||||
const [endTime, setEndTime] = useState(initial?.endTime?.slice(0, 16) ?? '');
|
||||
const [remark, setRemark] = useState(initial?.remark ?? '');
|
||||
@@ -801,7 +847,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
versionId,
|
||||
type: planType,
|
||||
title: title.trim(),
|
||||
owner: owner.trim() || currentUserName,
|
||||
owner: owner.trim() || currentUserReference || currentUserName,
|
||||
startTime,
|
||||
endTime,
|
||||
status: initial?.status ?? 'pending',
|
||||
@@ -828,7 +874,7 @@ function PlanFormModal({ initial, planType, versionId, versionDeadline, currentU
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block">负责人</label>
|
||||
<input value={owner} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
|
||||
<input value={ownerLabel} readOnly className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-subtle)] px-3 text-[13px] text-[var(--ink-muted)] cursor-not-allowed" />
|
||||
</div>
|
||||
{planType === 'product' && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user