fix:开发完毕

This commit is contained in:
彭帅
2026-05-28 17:25:32 +08:00
parent 50879a71da
commit c5d4d7a7e1
63 changed files with 4960 additions and 851 deletions

View File

@@ -9,7 +9,10 @@ import {
NONE_SELECT_VALUE,
type CrossParentFormState,
type CrossParentRow,
type CrossPollinationEventRecord,
type CrossRecord,
type CrossingProjectDetail,
type CrossingProjectQuery,
type CrossingProjectRecord,
type PedigreeEdgeFormState,
type PedigreeEdgeRow,
@@ -105,7 +108,7 @@ const buildCrossParent = (
const observationUnitDbId = optionalText(observationUnitId);
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm <EFBFBD>?observation_unit 至少一");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm observation_unit 至少一");
return {
parentType: parentTypeValue as CrossParent["parentType"],
...(germplasmDbId ? { germplasmDbId } : {}),
@@ -181,7 +184,7 @@ export function buildCrossParentFormState(
}
const crossingProjectBody = (payload: CrossingProjectPayload) => {
const programDbId = requiredText(payload.program_id, "请选择所<EFBFBD>?Program");
const programDbId = requiredText(payload.program_id, "请选择所Program");
return {
crossingProjectName: requiredText(payload.name, "请填写杂交项目名"),
crossingProjectDescription: optionalText(payload.description),
@@ -324,6 +327,7 @@ async function updatePedigreeParents(
[germplasmDbId]: buildParentPayload(germplasmDbId, parents),
}),
});
invalidateAfterMutation();
}
export async function fetchCrossPedigreeOptions(): Promise<{
@@ -345,9 +349,109 @@ export async function fetchCrossPedigreeOptions(): Promise<{
};
}
export async function fetchCrossingProjectRows(): Promise<CrossingProjectRecord[]> {
export async function fetchCrossingProjectRows(query?: CrossingProjectQuery): Promise<CrossingProjectRecord[]> {
const snapshot = await loadCrossPedigreeSnapshot();
return snapshot.crossingProjects;
return filterCrossingProjectRows(snapshot.crossingProjects, query);
}
const filterCrossingProjectRows = (rows: CrossingProjectRecord[], query?: CrossingProjectQuery) => {
const keyword = String(query?.keyword ?? "").trim().toLowerCase();
const programId = optionalText(query?.program_id);
return rows.filter((row) => {
if (programId && row.program_id !== programId) return false;
if (!keyword) return true;
const haystack = [row.name, row.description, row.program_name]
.map((value) => String(value ?? "").toLowerCase())
.join(" ");
return haystack.includes(keyword);
});
};
export async function fetchCrossingProjectDetailExtended(id: string): Promise<CrossingProjectDetail> {
const [project, pedigreeNodes] = await Promise.all([
fetchCrossingProjectDetail(id),
fetchPedigreeRows(),
]);
const snapshot = await loadCrossPedigreeSnapshot(true);
const plannedCrosses = snapshot.plannedCrosses.filter((row) => row.crossing_project_id === id);
const actualCrosses = snapshot.actualCrosses.filter((row) => row.crossing_project_id === id);
const relatedPedigreeNodes = pedigreeNodes.filter((row) => row.crossing_project_id === id);
return {
...project,
plannedCrosses,
actualCrosses,
pedigreeNodes: relatedPedigreeNodes,
};
}
export async function fetchPlannedCrossDetail(plannedCrossDbId: string): Promise<PlannedCrossRecord> {
const response = await request<BrapiListResponse<PlannedCross>>(
`/brapi/v2/plannedcrosses?crossDbId=${encodeURIComponent(plannedCrossDbId)}&page=0&pageSize=1`,
);
const cross = response.result.data[0];
if (!cross) throw new Error("计划杂交不存在");
return mapPlannedCross(cross);
}
export async function fetchCrossDetail(crossDbId: string): Promise<CrossRecord> {
const response = await request<BrapiListResponse<Cross>>(
`/brapi/v2/crosses?crossDbId=${encodeURIComponent(crossDbId)}&page=0&pageSize=1`,
);
const cross = response.result.data[0];
if (!cross) throw new Error("实际杂交不存在");
return mapCross(cross);
}
const pollinationEventBody = (event: CrossPollinationEventRecord) => ({
...(optionalText(event.pollination_number) ? { pollinationNumber: optionalText(event.pollination_number) } : {}),
...(event.pollination_successful === null || event.pollination_successful === undefined
? {}
: { pollinationSuccessful: event.pollination_successful }),
...(optionalText(event.pollination_time_stamp)
? { pollinationTimeStamp: optionalText(event.pollination_time_stamp) }
: {}),
});
export function normalizePollinationEventForm(event: CrossPollinationEventRecord) {
return {
pollination_number: event.pollination_number ?? "",
pollination_successful: event.pollination_successful === null ? "unknown" : String(event.pollination_successful),
pollination_time_stamp: event.pollination_time_stamp ?? "",
};
}
export function sortPollinationEvents(events: CrossPollinationEventRecord[]) {
return [...events].sort((left, right) => {
const leftTime = Date.parse(String(left.pollination_time_stamp ?? ""));
const rightTime = Date.parse(String(right.pollination_time_stamp ?? ""));
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) return 0;
if (Number.isNaN(leftTime)) return 1;
if (Number.isNaN(rightTime)) return -1;
return rightTime - leftTime;
});
}
export async function updateCrossPollinationEvents(
crossId: string,
events: CrossPollinationEventRecord[],
): Promise<CrossRecord> {
const numbers = events
.map((event) => optionalText(event.pollination_number))
.filter(Boolean) as string[];
if (new Set(numbers).size !== numbers.length) {
throw new Error("同一 Cross 下授粉编号不能重复");
}
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
method: "PUT",
body: JSON.stringify({
[crossId]: {
pollinationEvents: events.map(pollinationEventBody),
},
}),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
}
export async function fetchCrossingProjectDetail(id: string): Promise<CrossingProjectRecord> {
@@ -412,7 +516,24 @@ export async function createCrossRow(payload: CrossPayload): Promise<CrossRecord
body: JSON.stringify([crossBody(payload)]),
});
invalidateAfterMutation();
return mapCross(response.result.data[0]);
const created = mapCross(response.result.data[0]);
const plannedCrossId = optionalText(payload.planned_cross_id);
if (plannedCrossId) {
const snapshot = await loadCrossPedigreeSnapshot(true);
const planned = snapshot.plannedCrosses.find((item) => item.id === plannedCrossId);
if (planned && (planned.parent1 || planned.parent2)) {
await updateCrossParents(buildCrossParentFormState(
created.id,
false,
created.crossing_project_id,
created.crossing_project_name,
planned.parent1,
planned.parent2,
));
return fetchCrossDetail(created.id);
}
}
return created;
}
export async function updateCrossRow(id: string, payload: CrossPayload): Promise<CrossRecord> {
@@ -430,7 +551,7 @@ export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
}
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
const crossId = requiredText(payload.cross_id, "请选择所<EFBFBD>?Cross");
const crossId = requiredText(payload.cross_id, "请选择所Cross");
const parent1 = buildCrossParent(
payload.parent1_type,
payload.parent1_germplasm_id,
@@ -464,24 +585,37 @@ export async function updateCrossParents(payload: CrossParentFormState): Promise
invalidateAfterMutation();
}
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
const PEDIGREE_LIST_PAGE_SIZE = 500;
async function fetchAllPedigreeRows(query = ""): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=10",
`/brapi/v2/pedigree?page=0&pageSize=${PEDIGREE_LIST_PAGE_SIZE}${query}`,
);
return response.result.data.map(mapPedigree);
}
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
return fetchAllPedigreeRows();
}
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
return fetchAllPedigreeRows("&includeParents=true&includeProgeny=false&includeSiblings=true");
}
export async function fetchPedigreeNodeByGermplasm(germplasmDbId: string): Promise<PedigreeRecord | null> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=10&includeParents=true&includeProgeny=false&includeSiblings=true",
`/brapi/v2/pedigree?germplasmDbId=${encodeURIComponent(germplasmDbId)}&page=0&pageSize=1&includeParents=true&includeProgeny=true&includeSiblings=true`,
);
return response.result.data.map(mapPedigree);
const node = response.result.data[0];
return node ? mapPedigree(node) : null;
}
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
const byGermplasm = await fetchPedigreeNodeByGermplasm(id);
if (byGermplasm) return byGermplasm;
const rows = await fetchPedigreeRows();
const found = rows.find((row) => row.id === id || row.germplasm_id === id);
if (!found) throw new Error("系谱节点不存");
if (!found) throw new Error("系谱节点不存");
return found;
}
@@ -490,6 +624,7 @@ export async function createPedigreeRow(payload: PedigreePayload): Promise<Pedig
method: "POST",
body: JSON.stringify([pedigreeBody(payload, true)]),
});
invalidateAfterMutation();
return mapPedigree(response.result.data[0]);
}
@@ -504,12 +639,17 @@ export async function updatePedigreeRow(id: string, payload: PedigreePayload): P
},
}),
});
invalidateAfterMutation();
return mapPedigree(response.result.data[0]);
}
export async function fetchPedigreeEdgeRows(): Promise<PedigreeEdgeRow[]> {
export async function fetchPedigreeEdgeRows(scopeGermplasmDbId?: string): Promise<PedigreeEdgeRow[]> {
const nodes = await fetchPedigreeRowsWithRelations();
return flattenPedigreeEdges(nodes);
const rows = flattenPedigreeEdges(nodes);
if (!scopeGermplasmDbId) return rows;
return rows.filter(
(row) => row.this_node_id === scopeGermplasmDbId || row.connected_node_id === scopeGermplasmDbId,
);
}
export function buildPedigreeEdgeFormState(row?: PedigreeEdgeRow): PedigreeEdgeFormState {
@@ -526,7 +666,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
if (thisNodeId === connectedNodeId) {
throw new Error("当前材料与关联材料不能相");
throw new Error("当前材料与关联材料不能相");
}
if (edgeType === "sibling") {
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
@@ -560,7 +700,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
export async function removePedigreeEdge(edgeId: string): Promise<void> {
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
throw new Error("仅支持删<EFBFBD>?parent 关系");
throw new Error("仅支持删parent 关系");
}
const nodes = await fetchPedigreeRowsWithRelations();

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { GitFork } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
@@ -123,12 +124,43 @@ export function CrossEntityTab() {
icon={GitFork}
iconBg="bg-gradient-to-br from-emerald-500 to-green-600"
title="计划杂交"
description="cross_entity(planned=true):录入杂交计划亲本请在「杂交亲本」Tab 维护"
description="cross_entity(planned=true):录入杂交计划亲本与详情页 Parents Tab 维护"
addLabel="新增计划杂交"
useEnhancedDialog
columns={[
{ key: "plannedCrossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{
key: "plannedCrossDbId",
label: "Cross ID",
render: (value, row) => {
const id = String(row.id ?? row.plannedCrossDbId ?? value ?? "");
if (!id) return "—";
return (
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{id}
</Link>
);
},
},
{
key: "name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.plannedCrossDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{name}
</Link>
);
},
},
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },
{ key: "status", label: "状态", render: plannedStatusLabel },
@@ -156,12 +188,43 @@ export function CrossEntityTab() {
icon={GitFork}
iconBg="bg-gradient-to-br from-green-600 to-emerald-700"
title="实际杂交"
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交亲本请在「杂交亲本」Tab 维护"
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交并继承亲本;详情页维护 Parents 与授粉事件"
addLabel="新增实际杂交"
useEnhancedDialog
columns={[
{ key: "crossDbId", label: "Cross ID" },
{ key: "name", label: "名称" },
{
key: "crossDbId",
label: "Cross ID",
render: (value, row) => {
const id = String(row.id ?? row.crossDbId ?? value ?? "");
if (!id) return "—";
return (
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{id}
</Link>
);
},
},
{
key: "name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.crossDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{name}
</Link>
);
},
},
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "plannedCrossName", label: "来源计划杂交" },
{ key: "cross_type", label: "类型", render: crossTypeLabel },

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pencil, Users } from "lucide-react";
import {
@@ -273,7 +274,16 @@ export function CrossParentTab() {
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.cross_name || row.cross_id}</TableCell>
<TableCell>
<Link
href={row.planned
? `/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(row.cross_id)}`
: `/germplasm/cross-pedigree/crosses/${encodeURIComponent(row.cross_id)}?tab=parents`}
className="font-medium text-violet-600 hover:underline dark:text-violet-400"
>
{row.cross_name || row.cross_id}
</Link>
</TableCell>
<TableCell>{row.planned ? "计划杂交" : "实际杂交"}</TableCell>
<TableCell>{row.crossing_project_name || "—"}</TableCell>
<TableCell>{row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"}</TableCell>

View File

@@ -0,0 +1,231 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { PARENT_TYPE_OPTIONS, parentTypeLabel } from "../constants";
import {
buildCrossParentFormState,
fetchCrossPedigreeOptions,
updateCrossParents,
} from "../api";
import { NONE_SELECT_VALUE, type CrossParentFormState, type SelectOption } from "../types";
import type { CrossParent } from "@/lib/api/types.gen";
interface CrossParentsPanelProps {
crossId: string;
planned: boolean;
crossName: string | null;
crossingProjectId: string | null;
crossingProjectName: string | null;
parent1: CrossParent | null;
parent2: CrossParent | null;
onChanged?: () => void;
}
function ParentSlotEditor({
title,
slot,
parentType,
germplasmId,
observationUnitId,
germplasmOptions,
observationUnitOptions,
onChange,
}: {
title: string;
slot: "parent1" | "parent2";
parentType: string;
germplasmId: string;
observationUnitId: string;
germplasmOptions: SelectOption[];
observationUnitOptions: SelectOption[];
onChange: (patch: Partial<CrossParentFormState>) => void;
}) {
const prefix = slot;
return (
<div className="rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<h4 className="mb-3 text-sm font-medium text-slate-800 dark:text-slate-100">{title}</h4>
<div className="grid gap-3 md:grid-cols-3">
<div>
<Label className="mb-1.5 block text-xs">parent_type</Label>
<Select
value={parentType || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_type`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择亲本角色" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs">germplasm_id</Label>
<Select
value={germplasmId || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_germplasm_id`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择种质" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{germplasmOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-xs">observation_unit_id</Label>
<Select
value={observationUnitId || NONE_SELECT_VALUE}
onValueChange={(value) => onChange({ [`${prefix}_observation_unit_id`]: value } as Partial<CrossParentFormState>)}
>
<SelectTrigger><SelectValue placeholder="选择观测单元" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{observationUnitOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
export function CrossParentsPanel({
crossId,
planned,
crossName,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
onChanged,
}: CrossParentsPanelProps) {
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [observationUnitOptions, setObservationUnitOptions] = useState<SelectOption[]>([]);
const [loadingOptions, setLoadingOptions] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const savingRef = useRef(false);
const [form, setForm] = useState<CrossParentFormState>(() => buildCrossParentFormState(
crossId,
planned,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
));
useEffect(() => {
setForm(buildCrossParentFormState(
crossId,
planned,
crossingProjectId,
crossingProjectName,
parent1,
parent2,
));
}, [crossId, planned, crossingProjectId, crossingProjectName, parent1, parent2]);
useEffect(() => {
let mounted = true;
setLoadingOptions(true);
fetchCrossPedigreeOptions()
.then((options) => {
if (!mounted) return;
setGermplasmOptions(options.germplasm);
setObservationUnitOptions(options.observationUnits);
})
.finally(() => {
if (mounted) setLoadingOptions(false);
});
return () => { mounted = false; };
}, []);
const handleSave = useCallback(async () => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
await updateCrossParents(form);
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
savingRef.current = false;
setSaving(false);
}
}, [form, onChanged]);
if (loadingOptions) {
return <Skeleton className="h-64 w-full rounded-xl" />;
}
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4 text-violet-500" />
(cross_parent)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{crossName || crossId} parent1 / parent2germplasm observation_unit
</p>
<div>
<Label className="mb-1.5 block text-sm">crossing_project_id</Label>
<Input readOnly value={crossingProjectName || crossingProjectId || "—"} />
</div>
<ParentSlotEditor
title="Parent 1"
slot="parent1"
parentType={form.parent1_type}
germplasmId={form.parent1_germplasm_id}
observationUnitId={form.parent1_observation_unit_id}
germplasmOptions={germplasmOptions}
observationUnitOptions={observationUnitOptions}
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
/>
<ParentSlotEditor
title="Parent 2"
slot="parent2"
parentType={form.parent2_type}
germplasmId={form.parent2_germplasm_id}
observationUnitId={form.parent2_observation_unit_id}
germplasmOptions={germplasmOptions}
observationUnitOptions={observationUnitOptions}
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
/>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-xs text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
Parent1 {parentTypeLabel(form.parent1_type)} /
Parent2 {parentTypeLabel(form.parent2_type)}
</div>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存亲本"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Flower2, Pencil, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
sortPollinationEvents,
updateCrossPollinationEvents,
} from "../api";
import type { CrossPollinationEventRecord } from "../types";
interface CrossPollinationEventPanelProps {
crossId: string;
crossName: string | null;
events: CrossPollinationEventRecord[];
onChanged?: () => void;
}
const SUCCESS_OPTIONS = [
{ value: "unknown", label: "未指定" },
{ value: "true", label: "成功" },
{ value: "false", label: "失败" },
];
const emptyForm = () => ({
pollination_number: "",
pollination_successful: "unknown",
pollination_time_stamp: "",
});
const toLocalDateTime = (value: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60_000);
return local.toISOString().slice(0, 16);
};
const fromLocalDateTime = (value: string) => {
const normalized = value.trim();
if (!normalized) return null;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) throw new Error("请输入有效的授粉时间");
return date.toISOString();
};
const buildEventId = (event: Omit<CrossPollinationEventRecord, "id">, index: number) => {
const number = String(event.pollination_number ?? "").trim();
return number ? `num:${number}` : `idx:${index}`;
};
export function CrossPollinationEventPanel({
crossId,
crossName,
events,
onChanged,
}: CrossPollinationEventPanelProps) {
const [rows, setRows] = useState<CrossPollinationEventRecord[]>(() => sortPollinationEvents(events));
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CrossPollinationEventRecord | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const savingRef = useRef(false);
const sortedRows = useMemo(() => sortPollinationEvents(rows), [rows]);
useEffect(() => {
setRows(sortPollinationEvents(events));
}, [events]);
const persistEvents = useCallback(async (nextEvents: CrossPollinationEventRecord[]) => {
if (savingRef.current) return;
savingRef.current = true;
setSaving(true);
setError(null);
try {
const saved = await updateCrossPollinationEvents(crossId, nextEvents);
setRows(sortPollinationEvents(saved.pollination_events));
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
throw event;
} finally {
savingRef.current = false;
setSaving(false);
}
}, [crossId, onChanged]);
const openCreate = () => {
setEditingId(null);
setForm(emptyForm());
setDialogOpen(true);
};
const openEdit = (row: CrossPollinationEventRecord) => {
setEditingId(row.id);
setForm({
pollination_number: row.pollination_number ?? "",
pollination_successful: row.pollination_successful === null
? "unknown"
: String(row.pollination_successful),
pollination_time_stamp: toLocalDateTime(row.pollination_time_stamp),
});
setDialogOpen(true);
};
const handleSave = async () => {
const nextEvent = {
pollination_number: form.pollination_number.trim() || null,
pollination_successful: form.pollination_successful === "unknown"
? null
: form.pollination_successful === "true",
pollination_time_stamp: fromLocalDateTime(form.pollination_time_stamp),
};
const withoutCurrent = editingId
? rows.filter((row) => row.id !== editingId)
: rows;
const duplicateNumber = nextEvent.pollination_number
&& withoutCurrent.some((row) => row.pollination_number === nextEvent.pollination_number);
if (duplicateNumber) {
setError("同一 Cross 下授粉编号不能重复");
return;
}
const nextRows = [
...withoutCurrent,
{
id: buildEventId(nextEvent, withoutCurrent.length),
...nextEvent,
},
];
try {
await persistEvents(nextRows);
setDialogOpen(false);
} catch {
// error already set
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
const nextRows = rows.filter((row) => row.id !== deleteTarget.id);
try {
await persistEvents(nextRows);
setDeleteTarget(null);
} catch {
// error already set
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Flower2 className="h-4 w-4 text-pink-500" />
(cross_pollination_event)
</CardTitle>
<Button size="sm" className="gap-1" onClick={openCreate} disabled={saving}>
<Plus className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-slate-500 dark:text-slate-400">
{crossName || crossId} Cross
</p>
{error ? <p className="text-sm text-red-500">{error}</p> : null}
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
sortedRows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.pollination_number || "—"}</TableCell>
<TableCell>{row.pollination_time_stamp || "—"}</TableCell>
<TableCell>
{row.pollination_successful === null ? (
"—"
) : (
<Badge variant={row.pollination_successful ? "default" : "secondary"}>
{row.pollination_successful ? "成功" : "失败"}
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)} disabled={saving}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="gap-1 text-destructive" onClick={() => setDeleteTarget(row)} disabled={saving}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg" title={editingId ? "编辑授粉事件" : "新增授粉事件"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Input
value={form.pollination_number}
onChange={(event) => setForm((current) => ({ ...current, pollination_number: event.target.value }))}
placeholder="同一 Cross 下建议唯一"
/>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Input
type="datetime-local"
value={form.pollination_time_stamp}
onChange={(event) => setForm((current) => ({ ...current, pollination_time_stamp: event.target.value }))}
/>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form.pollination_successful}
onValueChange={(value) => setForm((current) => ({ ...current, pollination_successful: value }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{SUCCESS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "保存中..." : "保存"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.pollination_number || "未命名"}Cross
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={saving}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}

View File

@@ -1,25 +1,39 @@
"use client";
import { useCallback, useMemo } from "react";
import { Network } from "lucide-react";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { Network, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
createCrossingProjectRow,
fetchCrossingProjectDetail,
fetchCrossingProjectRows,
normalizeCrossingProjectForm,
updateCrossingProjectRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
import { NONE_SELECT_VALUE, type CrossingProjectQuery } from "../types";
const emptyQuery = (): CrossingProjectQuery => ({
keyword: "",
program_id: NONE_SELECT_VALUE,
});
export function CrossingProjectTab() {
const { snapshot, refresh } = useCrossPedigree();
const programOptions = snapshot?.programs ?? [];
const [draftQuery, setDraftQuery] = useState<CrossingProjectQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<CrossingProjectQuery>(emptyQuery);
const loadRows = useCallback(async () => {
const data = await refresh(false);
return data.crossingProjects as unknown as Record<string, unknown>[];
}, [refresh]);
await refresh(false);
const rows = await fetchCrossingProjectRows(appliedQuery);
return rows as unknown as Record<string, unknown>[];
}, [appliedQuery, refresh]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchCrossingProjectDetail(id);
@@ -50,18 +64,79 @@ export function CrossingProjectTab() {
},
], [programOptions]);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.keyword ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, keyword: event.target.value }))}
placeholder="项目名称 / 说明模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Program</Label>
<Select
value={draftQuery.program_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部 Program" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}> Program</SelectItem>
{programOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, programOptions]);
return (
<BrapiEntityPage
icon={Network}
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
title="CrossingProject 杂交项目"
description="crossing_project某 Program 下的一组杂交任务集合(杂交工作台)。ID 由系统自动生成。"
description="crossing_project某 Program 下的一组杂交任务集合(杂交工作台)。点击进入详情可查看下属 Cross 与 Pedigree Node。"
addLabel="新增杂交项目"
useEnhancedDialog
fetchRecord={fetchRecord}
renderQueryForm={renderQueryForm}
columns={[
{ key: "crossingProjectDbId", label: "项目 ID" },
{ key: "name", label: "项目名称" },
{
key: "name",
label: "项目名称",
render: (value, row) => {
const id = String(row.id ?? row.crossingProjectDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(id)}`}
className="font-medium text-lime-600 hover:underline dark:text-lime-400"
>
{name}
</Link>
);
},
},
{ key: "program_name", label: "Program" },
{
key: "description",

View File

@@ -0,0 +1,399 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GitBranch, Pencil, Plus, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { EDGE_TYPE_OPTIONS, PARENT_TYPE_OPTIONS, edgeTypeLabel, parentTypeLabel } from "../constants";
import {
buildPedigreeEdgeFormState,
fetchPedigreeEdgeRows,
fetchPedigreeRows,
removePedigreeEdge,
upsertPedigreeEdge,
} from "../api";
import { NONE_SELECT_VALUE, type PedigreeEdgeFormState, type PedigreeEdgeRow, type SelectOption } from "../types";
interface PedigreeEdgePanelProps {
scopeGermplasmDbId?: string;
compact?: boolean;
onChanged?: () => void;
}
export function PedigreeEdgePanel({ scopeGermplasmDbId, compact = false, onChanged }: PedigreeEdgePanelProps) {
const [rows, setRows] = useState<PedigreeEdgeRow[]>([]);
const [nodeOptions, setNodeOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<PedigreeEdgeFormState | null>(null);
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
const [deletingEdge, setDeletingEdge] = useState<PedigreeEdgeRow | null>(null);
const [deleting, setDeleting] = useState(false);
const saveLockRef = useRef(false);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [edgeRows, nodes] = await Promise.all([
fetchPedigreeEdgeRows(scopeGermplasmDbId),
fetchPedigreeRows(),
]);
setRows(edgeRows);
setNodeOptions(
nodes
.filter((node) => node.germplasm_id)
.map((node) => ({
value: node.germplasm_id as string,
label: node.germplasm_name || node.germplasm_id || "",
})),
);
} catch (event) {
setError(event instanceof Error ? event.message : "加载失败");
} finally {
setLoading(false);
}
}, [scopeGermplasmDbId]);
useEffect(() => {
loadRows();
}, [loadRows]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
[
row.this_node_name,
row.connected_node_name,
row.edge_type,
row.parent_type,
row.this_node_id,
row.connected_node_id,
].some((value) => String(value ?? "").toLowerCase().includes(keyword)),
);
}, [rows, search]);
const openCreate = () => {
if (nodeOptions.length < 2) {
setError("请先在「Pedigree Node 系谱节点」创建至少两个节点");
return;
}
setEditingEdgeId(null);
const nextForm = buildPedigreeEdgeFormState();
if (scopeGermplasmDbId) {
nextForm.this_node_id = scopeGermplasmDbId;
}
setForm(nextForm);
setDialogOpen(true);
};
const openEdit = (row: PedigreeEdgeRow) => {
if (row.read_only || row.edge_type === "sibling") {
setError("同胞关系为只读展示,请通过 parent 关系维护");
return;
}
setEditingEdgeId(row.id);
setForm(buildPedigreeEdgeFormState(row));
setDialogOpen(true);
};
const handleSave = async () => {
if (!form || saveLockRef.current) return;
saveLockRef.current = true;
setSaving(true);
setError(null);
try {
await upsertPedigreeEdge(form, editingEdgeId ?? undefined);
setDialogOpen(false);
await loadRows();
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
saveLockRef.current = false;
}
};
const handleDelete = async () => {
if (!deletingEdge || saveLockRef.current) return;
saveLockRef.current = true;
setDeleting(true);
setError(null);
try {
await removePedigreeEdge(deletingEdge.id);
setDeletingEdge(null);
await loadRows();
onChanged?.();
} catch (event) {
setError(event instanceof Error ? event.message : "删除失败");
} finally {
setDeleting(false);
saveLockRef.current = false;
}
};
const showParentType = form?.edge_type === "parent" || form?.edge_type === "child";
const lockThisNode = Boolean(scopeGermplasmDbId);
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-cyan-500 to-teal-600")}>
<GitBranch className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
{compact ? "系谱边" : "Pedigree Edge 系谱边"}
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
{scopeGermplasmDbId
? "维护当前种质节点的 parent / child 关系sibling 由 BrAPI 自动推断(只读)。"
: "pedigree_edge维护节点之间的 parent / child 关系sibling 由 BrAPI 根据共享亲本自动推断(只读展示)"}
</p>
</div>
</div>
<Button onClick={openCreate} className="shrink-0 gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
{!compact ? (
<div className="mb-1">
<span className="rounded-full bg-cyan-50 px-3 py-1 text-xs text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200">
BrAPI PUT /brapi/v2/pedigreeparents
</span>
</div>
) : null}
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索当前材料 / 关联材料 / 关系类型"
className="max-w-sm"
/>
</div>
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
{loading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>parent_type</TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-slate-400">
parent / child
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>
{edgeTypeLabel(row.edge_type)}
{row.read_only ? (
<span className="ml-2 text-xs text-slate-400"></span>
) : null}
</TableCell>
<TableCell>{row.this_node_name || row.this_node_id}</TableCell>
<TableCell>{row.connected_node_name || row.connected_node_id}</TableCell>
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
<TableCell className="text-right">
{!row.read_only ? (
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
className="gap-1 text-red-600 hover:text-red-600"
onClick={() => setDeletingEdge(row)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<span className="text-xs text-slate-400"></span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl" title={editingEdgeId ? "编辑系谱边" : "新增系谱边"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm">edge_type </Label>
<Select
value={form?.edge_type || "parent"}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, edge_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关系类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{EDGE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.this_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form || lockThisNode) return;
setForm({ ...form, this_node_id: value });
}}
disabled={lockThisNode}
>
<SelectTrigger><SelectValue placeholder="选择当前材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.connected_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, connected_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关联材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions
.filter((option) => option.value !== scopeGermplasmDbId)
.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showParentType ? (
<div>
<Label className="mb-1.5 block text-sm">parent_type </Label>
<Select
value={form?.parent_type || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, parent_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择亲本类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
<SelectItem value={NONE_SELECT_VALUE}> parent_type</SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<p className="text-xs text-slate-500 dark:text-slate-400">
parentchild
</p>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button
onClick={handleSave}
disabled={
saving
|| !form
|| form.this_node_id === NONE_SELECT_VALUE
|| form.connected_node_id === NONE_SELECT_VALUE
|| (showParentType && form.parent_type === NONE_SELECT_VALUE)
}
>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deletingEdge)} onOpenChange={(open) => !open && setDeletingEdge(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{" "}
{deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""}
{" "}
{deletingEdge?.this_node_name || deletingEdge?.this_node_id}
{" → "}
{deletingEdge?.connected_node_name || deletingEdge?.connected_node_id}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,369 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitBranch, Pencil, Plus, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { EDGE_TYPE_OPTIONS, PARENT_TYPE_OPTIONS, edgeTypeLabel, parentTypeLabel } from "../constants";
import {
buildPedigreeEdgeFormState,
fetchPedigreeEdgeRows,
fetchPedigreeRows,
removePedigreeEdge,
upsertPedigreeEdge,
} from "../api";
import { NONE_SELECT_VALUE, type PedigreeEdgeFormState, type PedigreeEdgeRow, type SelectOption } from "../types";
import { PedigreeEdgePanel } from "./PedigreeEdgePanel";
export function PedigreeEdgeTab() {
const [rows, setRows] = useState<PedigreeEdgeRow[]>([]);
const [nodeOptions, setNodeOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<PedigreeEdgeFormState | null>(null);
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
const [deletingEdge, setDeletingEdge] = useState<PedigreeEdgeRow | null>(null);
const [deleting, setDeleting] = useState(false);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [edgeRows, nodes] = await Promise.all([fetchPedigreeEdgeRows(), fetchPedigreeRows()]);
setRows(edgeRows);
setNodeOptions(
nodes
.filter((node) => node.germplasm_id)
.map((node) => ({
value: node.germplasm_id as string,
label: node.germplasm_name || node.germplasm_id || "",
})),
);
} catch (event) {
setError(event instanceof Error ? event.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadRows();
}, [loadRows]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
[
row.this_node_name,
row.connected_node_name,
row.edge_type,
row.parent_type,
row.this_node_id,
row.connected_node_id,
].some((value) => String(value ?? "").toLowerCase().includes(keyword)),
);
}, [rows, search]);
const openCreate = () => {
if (nodeOptions.length < 2) {
setError("请先在「Pedigree Node 系谱节点」Tab 创建至少两个节点");
return;
}
setEditingEdgeId(null);
setForm(buildPedigreeEdgeFormState());
setDialogOpen(true);
};
const openEdit = (row: PedigreeEdgeRow) => {
if (row.read_only || row.edge_type === "sibling") {
setError("同胞关系为只读展示,请通过 parent 关系维护");
return;
}
setEditingEdgeId(row.id);
setForm(buildPedigreeEdgeFormState(row));
setDialogOpen(true);
};
const handleSave = async () => {
if (!form) return;
setSaving(true);
setError(null);
try {
await upsertPedigreeEdge(form, editingEdgeId ?? undefined);
setDialogOpen(false);
await loadRows();
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deletingEdge) return;
setDeleting(true);
setError(null);
try {
await removePedigreeEdge(deletingEdge.id);
setDeletingEdge(null);
await loadRows();
} catch (event) {
setError(event instanceof Error ? event.message : "删除失败");
} finally {
setDeleting(false);
}
};
const showParentType = form?.edge_type === "parent" || form?.edge_type === "child";
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-cyan-500 to-teal-600")}>
<GitBranch className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Pedigree Edge </h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
pedigree_edge parent / child sibling BrAPI
</p>
</div>
</div>
<Button onClick={openCreate} className="shrink-0 gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="mb-1">
<span className="rounded-full bg-cyan-50 px-3 py-1 text-xs text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200">
BrAPI PUT /brapi/v2/pedigreeparents
</span>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索当前材料 / 关联材料 / 关系类型"
className="max-w-sm"
/>
</div>
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
{loading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>parent_type</TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-slate-400">
parent / child
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>
{edgeTypeLabel(row.edge_type)}
{row.read_only ? (
<span className="ml-2 text-xs text-slate-400"></span>
) : null}
</TableCell>
<TableCell>{row.this_node_name || row.this_node_id}</TableCell>
<TableCell>{row.connected_node_name || row.connected_node_id}</TableCell>
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
<TableCell className="text-right">
{!row.read_only ? (
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
className="gap-1 text-red-600 hover:text-red-600"
onClick={() => setDeletingEdge(row)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<span className="text-xs text-slate-400"></span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl" title={editingEdgeId ? "编辑系谱边" : "新增系谱边"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm">edge_type </Label>
<Select
value={form?.edge_type || "parent"}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, edge_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关系类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{EDGE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.this_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, this_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择当前材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.connected_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, connected_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关联材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showParentType ? (
<div>
<Label className="mb-1.5 block text-sm">parent_type </Label>
<Select
value={form?.parent_type || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, parent_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择亲本类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
<SelectItem value={NONE_SELECT_VALUE}> parent_type</SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<p className="text-xs text-slate-500 dark:text-slate-400">
parentchild
</p>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button
onClick={handleSave}
disabled={
saving
|| !form
|| form.this_node_id === NONE_SELECT_VALUE
|| form.connected_node_id === NONE_SELECT_VALUE
|| (showParentType && form.parent_type === NONE_SELECT_VALUE)
}
>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deletingEdge)} onOpenChange={(open) => !open && setDeletingEdge(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{" "}
{deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""}
{" "}
{deletingEdge?.this_node_name || deletingEdge?.this_node_id}
{" → "}
{deletingEdge?.connected_node_name || deletingEdge?.connected_node_id}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
return <PedigreeEdgePanel />;
}

View File

@@ -0,0 +1,185 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo } from "react";
import { Share2 } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createPedigreeRow,
fetchPedigreeDetail,
fetchPedigreeNodeByGermplasm,
fetchPedigreeRows,
normalizePedigreeForm,
updatePedigreeRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
interface PedigreeNodeFormPanelProps {
scopeGermplasmDbId?: string;
scopeGermplasmName?: string | null;
compact?: boolean;
onChanged?: () => void;
}
export function PedigreeNodeFormPanel({
scopeGermplasmDbId,
scopeGermplasmName,
compact = false,
onChanged,
}: PedigreeNodeFormPanelProps) {
const { snapshot } = useCrossPedigree();
const germplasmOptions = snapshot?.germplasm ?? [];
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
const loadRows = useCallback(async () => {
if (scopeGermplasmDbId) {
const node = await fetchPedigreeNodeByGermplasm(scopeGermplasmDbId);
if (!node) return [];
const projectNameById = new Map(
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
);
return [{
...node,
germplasm_name: node.germplasm_name || scopeGermplasmName,
crossing_project_name:
node.crossing_project_name
|| (node.crossing_project_id ? projectNameById.get(node.crossing_project_id) : null),
}] as unknown as Record<string, unknown>[];
}
const rows = await fetchPedigreeRows();
const projectNameById = new Map(
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
);
return rows.map((row) => ({
...row,
crossing_project_name:
row.crossing_project_name || (row.crossing_project_id ? projectNameById.get(row.crossing_project_id) : null),
})) as unknown as Record<string, unknown>[];
}, [scopeGermplasmDbId, scopeGermplasmName, snapshot?.crossingProjects]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchPedigreeDetail(id);
return normalizePedigreeForm(detail);
}, []);
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
const result = await action();
onChanged?.();
return result;
}, [onChanged]);
const scopedGermplasmOptions = useMemo(() => {
if (!scopeGermplasmDbId) return germplasmOptions;
const existing = germplasmOptions.find((item) => item.value === scopeGermplasmDbId);
if (existing) return [existing];
return [{
value: scopeGermplasmDbId,
label: scopeGermplasmName || scopeGermplasmDbId,
}];
}, [germplasmOptions, scopeGermplasmDbId, scopeGermplasmName]);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "Germplasm 材料",
type: "select",
required: true,
readOnly: Boolean(scopeGermplasmDbId),
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Germplasm" }, ...scopedGermplasmOptions],
},
{
key: "crossing_project_id",
label: "CrossingProject 杂交项目",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不指定杂交项目" }, ...crossingProjectOptions],
},
{
key: "crossing_year",
label: "crossing_year 杂交年份",
type: "year",
placeholder: "四位年份",
},
{
key: "family_code",
label: "family_code 家系编号",
type: "text",
placeholder: "同一 crossing_project 下建议唯一",
},
{
key: "pedigree_string",
label: "pedigree_string 系谱字符串",
type: "text",
placeholder: "如 A/B//C支持 Purdy notation",
colSpan: 2,
},
], [crossingProjectOptions, scopedGermplasmOptions, scopeGermplasmDbId]);
const columns = useMemo(() => [
{
key: "germplasm_name",
label: "材料",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.germplasm_id ?? row.id ?? "");
const name = String(value ?? row.germplasm_id ?? "—");
if (!id || scopeGermplasmDbId) return name;
return (
<Link
href={`/germplasm/cross-pedigree/pedigree-nodes/${encodeURIComponent(id)}`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{name}
</Link>
);
},
},
...(scopeGermplasmDbId ? [] : [{ key: "germplasm_id", label: "Germplasm ID" }]),
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "crossing_year", label: "杂交年份" },
{ key: "family_code", label: "家系编号" },
{
key: "pedigree_string",
label: "系谱字符串",
render: (value: unknown) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 40 ? `${text.slice(0, 40)}` : text;
},
},
], [scopeGermplasmDbId]);
const defaultFormValues = useMemo(() => (
scopeGermplasmDbId
? { germplasm_id: scopeGermplasmDbId }
: undefined
), [scopeGermplasmDbId]);
return (
<BrapiEntityPage
icon={Share2}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title={compact ? "系谱节点" : "Pedigree Node 系谱节点"}
description={
scopeGermplasmDbId
? "维护当前种质对应的 pedigree_node同一 germplasm 通常仅一条节点记录。"
: "pedigree_node系谱树中的节点通常对应一个 germplasm。BrAPI 以 germplasmDbId 作为更新主键。"
}
addLabel={scopeGermplasmDbId ? "创建系谱节点" : "新增系谱节点"}
useEnhancedDialog
fetchRecord={fetchRecord}
columns={columns}
fields={fields}
data={[]}
defaultFormValues={defaultFormValues}
loadData={loadRows}
createRecord={(payload) => wrapMutation(() => {
const body = scopeGermplasmDbId
? { ...payload, germplasm_id: scopeGermplasmDbId }
: payload;
return createPedigreeRow(body);
})() as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => wrapMutation(() => updatePedigreeRow(id, payload))() as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -1,112 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import { Share2 } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createPedigreeRow,
fetchPedigreeDetail,
fetchPedigreeRows,
normalizePedigreeForm,
updatePedigreeRow,
} from "../api";
import { useCrossPedigree } from "../CrossPedigreeContext";
import { NONE_SELECT_VALUE } from "../types";
import { PedigreeNodeFormPanel } from "./PedigreeNodeFormPanel";
export function PedigreeNodeTab() {
const { snapshot } = useCrossPedigree();
const germplasmOptions = snapshot?.germplasm ?? [];
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
const loadRows = useCallback(async () => {
const rows = await fetchPedigreeRows();
const projectNameById = new Map(
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
);
return rows.map((row) => ({
...row,
crossing_project_name:
row.crossing_project_name || (row.crossing_project_id ? projectNameById.get(row.crossing_project_id) : null),
})) as unknown as Record<string, unknown>[];
}, [snapshot?.crossingProjects]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchPedigreeDetail(id);
return normalizePedigreeForm(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "Germplasm 材料",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Germplasm" }, ...germplasmOptions],
},
{
key: "crossing_project_id",
label: "CrossingProject 杂交项目",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不指定杂交项目" }, ...crossingProjectOptions],
},
{
key: "crossing_year",
label: "crossing_year 杂交年份",
type: "year",
placeholder: "四位年份",
},
{
key: "family_code",
label: "family_code 家系编号",
type: "text",
placeholder: "同一 crossing_project 下建议唯一",
},
{
key: "pedigree_string",
label: "pedigree_string 系谱字符串",
type: "text",
placeholder: "如 A/B//C支持 Purdy notation",
colSpan: 2,
},
], [crossingProjectOptions, germplasmOptions]);
return (
<BrapiEntityPage
icon={Share2}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="Pedigree Node 系谱节点"
description="pedigree_node系谱树中的节点通常对应一个 germplasm。BrAPI 以 germplasmDbId 作为更新主键。"
addLabel="新增系谱节点"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "germplasm_name", label: "材料" },
{ key: "germplasm_id", label: "Germplasm ID" },
{ key: "crossing_project_name", label: "杂交项目" },
{ key: "crossing_year", label: "杂交年份" },
{ key: "family_code", label: "家系编号" },
{
key: "pedigree_string",
label: "系谱字符串",
render: (value) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 40 ? `${text.slice(0, 40)}` : text;
},
},
]}
fields={fields}
data={[]}
stats={[
{
label: "/brapi/v2/pedigree",
value: "BrAPI",
className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200",
},
]}
loadData={loadRows}
createRecord={(payload) => createPedigreeRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updatePedigreeRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
return <PedigreeNodeFormPanel />;
}

View File

@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { ArrowLeft, Flower2, GitFork, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { crossTypeLabel } from "../../constants";
import { fetchCrossDetail } from "../../api";
import { CrossParentsPanel } from "../../components/CrossParentsPanel";
import { CrossPollinationEventPanel } from "../../components/CrossPollinationEventPanel";
import type { CrossRecord } from "../../types";
type CrossDetailTab = "parents" | "pollination";
function isCrossDetailTab(value: string | null): value is CrossDetailTab {
return value === "parents" || value === "pollination";
}
function CrossDetailPageContent() {
const params = useParams<{ crossDbId: string }>();
const searchParams = useSearchParams();
const crossDbId = decodeURIComponent(params.crossDbId);
const initialTab = isCrossDetailTab(searchParams.get("tab")) ? searchParams.get("tab") as CrossDetailTab : "parents";
const [activeTab, setActiveTab] = useState<CrossDetailTab>(initialTab);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<CrossRecord | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchCrossDetail(crossDbId);
setDetail(record);
}, [crossDbId]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载杂交详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "实际杂交不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /> Cross </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<GitFork className="h-5 w-5 text-green-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Cross ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.crossing_project_name || detail.crossing_project_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.plannedCrossName || "—"}</div>
<div><span className="text-slate-500"></span>{crossTypeLabel(detail.cross_type)}</div>
<div><span className="text-slate-500"></span>{detail.pollination_events.length}</div>
</CardContent>
</Card>
{detail.crossing_project_id ? (
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(detail.crossing_project_id)}`}>
</Link>
</Button>
) : null}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as CrossDetailTab)} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="parents" className="gap-2"><Users className="h-4 w-4" />Parents </TabsTrigger>
<TabsTrigger value="pollination" className="gap-2"><Flower2 className="h-4 w-4" />Pollination Events</TabsTrigger>
</TabsList>
{activeTab === "parents" ? (
<TabsContent value="parents" className="mt-0">
<CrossParentsPanel
crossId={detail.id}
planned={false}
crossName={detail.name}
crossingProjectId={detail.crossing_project_id}
crossingProjectName={detail.crossing_project_name}
parent1={detail.parent1}
parent2={detail.parent2}
onChanged={loadDetail}
/>
</TabsContent>
) : null}
{activeTab === "pollination" ? (
<TabsContent value="pollination" className="mt-0">
<CrossPollinationEventPanel
crossId={detail.id}
crossName={detail.name}
events={detail.pollination_events}
onChanged={loadDetail}
/>
</TabsContent>
) : null}
</Tabs>
</div>
);
}
export default function CrossDetailPage() {
return (
<Suspense fallback={null}>
<CrossDetailPageContent />
</Suspense>
);
}

View File

@@ -1,5 +1,20 @@
import type { Cross, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
import type { CrossRecord, CrossingProjectRecord, PlannedCrossRecord } from "./types";
import type { Cross, CrossingProject, CrossPollinationEvents, PlannedCross } from "@/lib/api/types.gen";
import type {
CrossPollinationEventRecord,
CrossRecord,
CrossingProjectRecord,
PlannedCrossRecord,
} from "./types";
const mapPollinationEvent = (event: CrossPollinationEvents, index: number): CrossPollinationEventRecord => {
const number = event.pollinationNumber ?? null;
return {
id: number ? `num:${number}` : `idx:${index}`,
pollination_number: number,
pollination_successful: event.pollinationSuccessful ?? null,
pollination_time_stamp: event.pollinationTimeStamp ? String(event.pollinationTimeStamp).slice(0, 19) : null,
};
};
export const mapCrossingProject = (project: CrossingProject): CrossingProjectRecord => ({
id: project.crossingProjectDbId || "",
@@ -48,4 +63,5 @@ export const mapCross = (cross: Cross): CrossRecord => ({
planned: false,
parent1: cross.parent1 ?? null,
parent2: cross.parent2 ?? null,
pollination_events: (cross.pollinationEvents ?? []).map(mapPollinationEvent),
});

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { GitBranch, GitFork, Network, Share2, Users } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CrossPedigreeProvider } from "./CrossPedigreeContext";
@@ -11,8 +12,22 @@ import { PedigreeEdgeTab } from "./components/PedigreeEdgeTab";
import { PedigreeNodeTab } from "./components/PedigreeNodeTab";
function CrossPedigreePageContent() {
const searchParams = useSearchParams();
const [tab, setTab] = useState("projects");
useEffect(() => {
const nextTab = searchParams.get("tab");
if (
nextTab === "projects"
|| nextTab === "crosses"
|| nextTab === "parents"
|| nextTab === "pedigree-nodes"
|| nextTab === "pedigree-edges"
) {
setTab(nextTab);
}
}, [searchParams]);
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
@@ -74,7 +89,9 @@ function CrossPedigreePageContent() {
export default function CrossPedigreePage() {
return (
<CrossPedigreeProvider>
<CrossPedigreePageContent />
<Suspense fallback={null}>
<CrossPedigreePageContent />
</Suspense>
</CrossPedigreeProvider>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitBranch, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CrossPedigreeProvider } from "../../CrossPedigreeContext";
import { fetchPedigreeNodeByGermplasm } from "../../api";
import { PedigreeEdgePanel } from "../../components/PedigreeEdgePanel";
import { PedigreeNodeFormPanel } from "../../components/PedigreeNodeFormPanel";
import type { PedigreeRecord } from "../../types";
function PedigreeNodeDetailContent() {
const params = useParams<{ germplasmDbId: string }>();
const germplasmDbId = decodeURIComponent(params.germplasmDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [node, setNode] = useState<PedigreeRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const loadDetail = useCallback(async () => {
const record = await fetchPedigreeNodeByGermplasm(germplasmDbId);
setNode(record);
}, [germplasmDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载系谱节点失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail, refreshKey]);
const bumpRefresh = () => setRefreshKey((value) => value + 1);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=pedigree-nodes">
<ArrowLeft className="mr-2 h-4 w-4" /> Pedigree
</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/germplasm/cross-pedigree?tab=pedigree-nodes">
<ArrowLeft className="mr-2 h-4 w-4" /> Pedigree Node
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/germplasm/germplasm/${encodeURIComponent(germplasmDbId)}?tab=pedigree`}>
<Share2 className="mr-2 h-4 w-4" /> Pedigree Tab
</Link>
</Button>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Share2 className="h-5 w-5 text-sky-500" />
{node?.germplasm_name || germplasmDbId}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div><span className="text-slate-500">Germplasm ID</span>{germplasmDbId}</div>
<div><span className="text-slate-500"></span>{node?.crossing_project_name || "—"}</div>
<div><span className="text-slate-500"></span>{node?.crossing_year ?? "—"}</div>
<div><span className="text-slate-500"></span>{node?.family_code || "—"}</div>
<div className="sm:col-span-2"><span className="text-slate-500"></span>{node?.pedigree_string || "—"}</div>
<div><span className="text-slate-500"></span>{node?.parents?.length ?? 0}</div>
<div><span className="text-slate-500"></span>{node?.siblings?.length ?? 0}</div>
</CardContent>
</Card>
<Tabs defaultValue="node" className="space-y-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="node" className="gap-2">
<Share2 className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="edges" className="gap-2">
<GitBranch className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="node">
<PedigreeNodeFormPanel
key={`node-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
scopeGermplasmName={node?.germplasm_name}
onChanged={() => {
bumpRefresh();
loadDetail().catch(() => undefined);
}}
/>
</TabsContent>
<TabsContent value="edges">
<PedigreeEdgePanel
key={`edge-${refreshKey}`}
scopeGermplasmDbId={germplasmDbId}
onChanged={bumpRefresh}
/>
</TabsContent>
</Tabs>
</div>
);
}
export default function PedigreeNodeDetailPage() {
return (
<Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<CrossPedigreeProvider>
<PedigreeNodeDetailContent />
</CrossPedigreeProvider>
</Suspense>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitFork, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { crossTypeLabel, plannedStatusLabel } from "../../constants";
import { fetchPlannedCrossDetail } from "../../api";
import { CrossParentsPanel } from "../../components/CrossParentsPanel";
import type { PlannedCrossRecord } from "../../types";
export default function PlannedCrossDetailPage() {
const params = useParams<{ plannedCrossDbId: string }>();
const plannedCrossDbId = decodeURIComponent(params.plannedCrossDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<PlannedCrossRecord | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchPlannedCrossDetail(plannedCrossDbId);
setDetail(record);
}, [plannedCrossDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载计划杂交详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "计划杂交不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree?tab=crosses"><ArrowLeft className="mr-2 h-4 w-4" /> Cross </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<GitFork className="h-5 w-5 text-emerald-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Cross ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.crossing_project_name || detail.crossing_project_id || "—"}</div>
<div><span className="text-slate-500"></span>{crossTypeLabel(detail.cross_type)}</div>
<div><span className="text-slate-500"></span>{plannedStatusLabel(detail.status)}</div>
</CardContent>
</Card>
{detail.crossing_project_id ? (
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={`/germplasm/cross-pedigree/projects/${encodeURIComponent(detail.crossing_project_id)}`}>
</Link>
</Button>
) : null}
<div className="flex items-center gap-2 text-sm text-slate-500">
<Users className="h-4 w-4" />
Parents Tab
</div>
<CrossParentsPanel
crossId={detail.id}
planned
crossName={detail.name}
crossingProjectId={detail.crossing_project_id}
crossingProjectName={detail.crossing_project_name}
parent1={detail.parent1}
parent2={detail.parent2}
onChanged={loadDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,227 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, GitFork, Network, Share2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { crossTypeLabel, plannedStatusLabel } from "../../constants";
import { fetchCrossingProjectDetailExtended } from "../../api";
import type { CrossingProjectDetail } from "../../types";
export default function CrossingProjectDetailPage() {
const params = useParams<{ crossingProjectDbId: string }>();
const crossingProjectDbId = decodeURIComponent(params.crossingProjectDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<CrossingProjectDetail | null>(null);
const loadDetail = useCallback(async () => {
const record = await fetchCrossingProjectDetailExtended(crossingProjectDbId);
setDetail(record);
}, [crossingProjectDbId]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
loadDetail()
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载杂交项目详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [loadDetail]);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "杂交项目不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/germplasm/cross-pedigree"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
const hasDependencies = detail.plannedCrosses.length > 0
|| detail.actualCrosses.length > 0
|| detail.pedigreeNodes.length > 0;
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/germplasm/cross-pedigree"><ArrowLeft className="mr-2 h-4 w-4" /> CrossingProject </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Network className="h-5 w-5 text-lime-500" />
{detail.name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500"> ID</span>{detail.id}</div>
<div><span className="text-slate-500">Program</span>{detail.program_name || detail.program_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.plannedCrosses.length}</div>
<div><span className="text-slate-500"></span>{detail.actualCrosses.length}</div>
<div className="sm:col-span-2 lg:col-span-4"><span className="text-slate-500"></span>{detail.description || "—"}</div>
</CardContent>
</Card>
{hasDependencies ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
CrossCross Parent Pedigree Node
</p>
) : null}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.plannedCrosses.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.plannedCrosses.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/germplasm/cross-pedigree/planned-crosses/${encodeURIComponent(row.id)}`}
className="font-medium text-emerald-600 hover:underline dark:text-emerald-400"
>
{row.name || row.id}
</Link>
</TableCell>
<TableCell>{crossTypeLabel(row.cross_type)}</TableCell>
<TableCell>{plannedStatusLabel(row.status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.actualCrosses.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.actualCrosses.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/germplasm/cross-pedigree/crosses/${encodeURIComponent(row.id)}`}
className="font-medium text-green-600 hover:underline dark:text-green-400"
>
{row.name || row.id}
</Link>
</TableCell>
<TableCell>{row.plannedCrossName || "—"}</TableCell>
<TableCell>{crossTypeLabel(row.cross_type)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Share2 className="h-4 w-4" />
Pedigree Node
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detail.pedigreeNodes.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-6 text-center text-sm text-slate-400"></TableCell>
</TableRow>
) : detail.pedigreeNodes.map((row) => {
const germplasmId = String(row.germplasm_id ?? row.id ?? "");
return (
<TableRow key={row.id}>
<TableCell>
{germplasmId ? (
<Link
href={`/germplasm/cross-pedigree/pedigree-nodes/${encodeURIComponent(germplasmId)}`}
className="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
{row.germplasm_name || germplasmId}
</Link>
) : (row.germplasm_name || "—")}
</TableCell>
<TableCell>{row.crossing_year ?? "—"}</TableCell>
<TableCell>{row.family_code || "—"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/germplasm/cross-pedigree?tab=crosses`}>
<GitFork className="mr-2 h-4 w-4" />
Cross
</Link>
</Button>
</div>
</div>
);
}

View File

@@ -37,6 +37,13 @@ export interface PlannedCrossRecord {
parent2: CrossParent | null;
}
export interface CrossPollinationEventRecord {
id: string;
pollination_number: string | null;
pollination_successful: boolean | null;
pollination_time_stamp: string | null;
}
export interface CrossRecord {
id: string;
crossDbId: string;
@@ -54,6 +61,18 @@ export interface CrossRecord {
planned: false;
parent1: CrossParent | null;
parent2: CrossParent | null;
pollination_events: CrossPollinationEventRecord[];
}
export interface CrossingProjectQuery {
keyword?: string;
program_id?: string;
}
export interface CrossingProjectDetail extends CrossingProjectRecord {
plannedCrosses: PlannedCrossRecord[];
actualCrosses: CrossRecord[];
pedigreeNodes: PedigreeRecord[];
}
export interface CrossParentRow {