refactor(data): 收口关系表运行时数据源
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:
2026-07-09 14:59:49 +08:00
parent 1e6fb0c7aa
commit 2e595c7e72
98 changed files with 2938 additions and 1441 deletions

View File

@@ -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>