fix:开发完毕
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 / parent2;germplasm 与 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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/pedigree(parents 字段)
|
||||
</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">
|
||||
parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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/pedigree(parents 字段)
|
||||
</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">
|
||||
parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。
|
||||
</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 />;
|
||||
}
|
||||
|
||||
@@ -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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
删除杂交项目前请先清理下属 Cross、Cross 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user