fix:sample/plate 之前的开发
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getCrossPedigreeSnapshot,
|
||||
invalidateCrossPedigreeCache,
|
||||
loadCrossPedigreeSnapshot,
|
||||
type CrossPedigreeSnapshot,
|
||||
} from "./crossPedigreeCache";
|
||||
|
||||
interface CrossPedigreeContextValue {
|
||||
snapshot: CrossPedigreeSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: (force?: boolean) => Promise<CrossPedigreeSnapshot>;
|
||||
invalidate: () => void;
|
||||
}
|
||||
|
||||
const CrossPedigreeContext = createContext<CrossPedigreeContextValue | null>(null);
|
||||
|
||||
export function CrossPedigreeProvider({ children }: { children: ReactNode }) {
|
||||
const [snapshot, setSnapshot] = useState<CrossPedigreeSnapshot | null>(() => getCrossPedigreeSnapshot());
|
||||
const [loading, setLoading] = useState(() => !getCrossPedigreeSnapshot());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async (force = false) => {
|
||||
const cached = getCrossPedigreeSnapshot();
|
||||
if (!force && cached) {
|
||||
setSnapshot(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadCrossPedigreeSnapshot(force);
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} catch (event) {
|
||||
const message = event instanceof Error ? event.message : "数据加载失败";
|
||||
setError(message);
|
||||
throw event;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
invalidateCrossPedigreeCache();
|
||||
setSnapshot(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (getCrossPedigreeSnapshot()) return;
|
||||
refresh(false).catch(() => {});
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ snapshot, loading, error, refresh, invalidate }),
|
||||
[snapshot, loading, error, refresh, invalidate],
|
||||
);
|
||||
|
||||
return (
|
||||
<CrossPedigreeContext.Provider value={value}>
|
||||
{children}
|
||||
</CrossPedigreeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCrossPedigree() {
|
||||
const context = useContext(CrossPedigreeContext);
|
||||
if (!context) {
|
||||
throw new Error("useCrossPedigree must be used within CrossPedigreeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
576
frontend/src/app/(app)/germplasm/cross-pedigree/api.ts
Normal file
576
frontend/src/app/(app)/germplasm/cross-pedigree/api.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import type { Cross, CrossParent, CrossingProject, PedigreeNode, PlannedCross } from "@/lib/api/types.gen";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
invalidateCrossPedigreeCache,
|
||||
loadCrossPedigreeSnapshot,
|
||||
} from "./crossPedigreeCache";
|
||||
import { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type CrossParentFormState,
|
||||
type CrossParentRow,
|
||||
type CrossRecord,
|
||||
type CrossingProjectRecord,
|
||||
type PedigreeEdgeFormState,
|
||||
type PedigreeEdgeRow,
|
||||
type PedigreeRecord,
|
||||
type PlannedCrossRecord,
|
||||
type SelectOption,
|
||||
} from "./types";
|
||||
|
||||
interface BrapiPagination {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface BrapiListResponse<T> {
|
||||
metadata: {
|
||||
pagination: BrapiPagination;
|
||||
status: Array<Record<string, unknown>>;
|
||||
datafiles: Array<Record<string, unknown>>;
|
||||
};
|
||||
result: {
|
||||
data: T[];
|
||||
};
|
||||
}
|
||||
|
||||
interface BrapiSingleResponse<T> {
|
||||
metadata: {
|
||||
pagination: BrapiPagination;
|
||||
status: Array<Record<string, unknown>>;
|
||||
datafiles: Array<Record<string, unknown>>;
|
||||
};
|
||||
result: T;
|
||||
}
|
||||
|
||||
type CrossingProjectPayload = Partial<Record<"name" | "description" | "program_id", unknown>>;
|
||||
type PlannedCrossPayload = Partial<Record<"name" | "cross_type" | "status" | "crossing_project_id", unknown>>;
|
||||
type CrossPayload = Partial<Record<"name" | "cross_type" | "crossing_project_id" | "planned_cross_id", unknown>>;
|
||||
type PedigreePayload = Partial<Record<
|
||||
"germplasm_id" | "crossing_project_id" | "crossing_year" | "family_code" | "pedigree_string",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
const apiBase = () => {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `请求失败:${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const optionalText = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const optionalNumber = (value: unknown) => {
|
||||
const normalized = optionalText(value);
|
||||
if (normalized === null) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const requiredText = (value: unknown, message: string) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) throw new Error(message);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const buildCrossParent = (
|
||||
parentType: unknown,
|
||||
germplasmId: unknown,
|
||||
observationUnitId: unknown,
|
||||
): CrossParent | null => {
|
||||
const parentTypeValue = optionalText(parentType);
|
||||
const germplasmDbId = optionalText(germplasmId);
|
||||
const observationUnitDbId = optionalText(observationUnitId);
|
||||
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
|
||||
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
|
||||
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm 或 observation_unit 至少一项");
|
||||
return {
|
||||
parentType: parentTypeValue as CrossParent["parentType"],
|
||||
...(germplasmDbId ? { germplasmDbId } : {}),
|
||||
...(observationUnitDbId ? { observationUnitDbId } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
|
||||
export { invalidateCrossPedigreeCache, loadCrossPedigreeSnapshot } from "./crossPedigreeCache";
|
||||
|
||||
const invalidateAfterMutation = () => {
|
||||
invalidateCrossPedigreeCache();
|
||||
};
|
||||
|
||||
export function normalizeCrossingProjectForm(record: CrossingProjectRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
description: record.description ?? "",
|
||||
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePlannedCrossForm(record: PlannedCrossRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
|
||||
status: record.status && record.status !== NONE_SELECT_VALUE ? record.status : "TODO",
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCrossForm(record: CrossRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
planned_cross_id:
|
||||
record.planned_cross_id && record.planned_cross_id !== NONE_SELECT_VALUE
|
||||
? record.planned_cross_id
|
||||
: NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCrossParentFormState(
|
||||
crossId: string,
|
||||
planned: boolean,
|
||||
crossingProjectId: string | null,
|
||||
crossingProjectName: string | null,
|
||||
parent1: CrossParent | null | undefined,
|
||||
parent2: CrossParent | null | undefined,
|
||||
): CrossParentFormState {
|
||||
return {
|
||||
cross_id: crossId,
|
||||
planned,
|
||||
crossing_project_id: crossingProjectId,
|
||||
crossing_project_name: crossingProjectName,
|
||||
parent1_type: parent1?.parentType ?? NONE_SELECT_VALUE,
|
||||
parent1_germplasm_id: parent1?.germplasmDbId ?? NONE_SELECT_VALUE,
|
||||
parent1_observation_unit_id: parent1?.observationUnitDbId ?? NONE_SELECT_VALUE,
|
||||
parent2_type: parent2?.parentType ?? NONE_SELECT_VALUE,
|
||||
parent2_germplasm_id: parent2?.germplasmDbId ?? NONE_SELECT_VALUE,
|
||||
parent2_observation_unit_id: parent2?.observationUnitDbId ?? NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
const crossingProjectBody = (payload: CrossingProjectPayload) => {
|
||||
const programDbId = requiredText(payload.program_id, "请选择所属 Program");
|
||||
return {
|
||||
crossingProjectName: requiredText(payload.name, "请填写杂交项目名称"),
|
||||
crossingProjectDescription: optionalText(payload.description),
|
||||
programDbId,
|
||||
};
|
||||
};
|
||||
|
||||
const plannedCrossBody = (payload: PlannedCrossPayload) => ({
|
||||
plannedCrossName: requiredText(payload.name, "请填写计划杂交名称"),
|
||||
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
|
||||
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
|
||||
...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }),
|
||||
});
|
||||
|
||||
const crossBody = (payload: CrossPayload) => ({
|
||||
crossName: requiredText(payload.name, "请填写实际杂交名称"),
|
||||
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
|
||||
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
|
||||
...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}),
|
||||
});
|
||||
|
||||
const mapPedigree = (node: PedigreeRecord & PedigreeNode): PedigreeRecord => {
|
||||
const germplasmId = node.germplasmDbId || node.germplasm_id || null;
|
||||
return {
|
||||
...node,
|
||||
id: germplasmId || node.pedigreeNodeDbId || node.id,
|
||||
germplasmDbId: germplasmId,
|
||||
germplasm_id: germplasmId,
|
||||
germplasm_name: node.germplasm_name || node.germplasmName || null,
|
||||
crossing_project_id: node.crossing_project_id || node.crossingProjectDbId || null,
|
||||
crossing_project_name: node.crossing_project_name || node.crossingProjectName || null,
|
||||
crossing_year: node.crossing_year ?? node.crossingYear ?? null,
|
||||
family_code: node.family_code || node.familyCode || null,
|
||||
pedigree_string: node.pedigree_string || node.pedigreeString || null,
|
||||
parents: node.parents?.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
germplasmName: parent.germplasmName,
|
||||
parentType: parent.parentType,
|
||||
})),
|
||||
siblings: node.siblings?.map((sibling) => ({
|
||||
germplasmDbId: sibling.germplasmDbId,
|
||||
germplasmName: sibling.germplasmName,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const pedigreeBody = (payload: PedigreePayload, requireGermplasm = false) => ({
|
||||
germplasmDbId: requireGermplasm
|
||||
? requiredText(payload.germplasm_id, "请选择 Germplasm")
|
||||
: optionalText(payload.germplasm_id),
|
||||
crossingProjectDbId: optionalText(payload.crossing_project_id),
|
||||
crossingYear: optionalNumber(payload.crossing_year),
|
||||
familyCode: optionalText(payload.family_code),
|
||||
pedigreeString: optionalText(payload.pedigree_string),
|
||||
});
|
||||
|
||||
export function normalizePedigreeForm(record: PedigreeRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
germplasm_id:
|
||||
record.germplasm_id && record.germplasm_id !== NONE_SELECT_VALUE ? record.germplasm_id : NONE_SELECT_VALUE,
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
crossing_year: record.crossing_year ?? "",
|
||||
family_code: record.family_code ?? "",
|
||||
pedigree_string: record.pedigree_string ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenPedigreeEdges(nodes: PedigreeRecord[]): PedigreeEdgeRow[] {
|
||||
const rows: PedigreeEdgeRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const thisNodeId = node.germplasm_id;
|
||||
if (!thisNodeId) continue;
|
||||
|
||||
for (const parent of node.parents ?? []) {
|
||||
const connectedNodeId = parent.germplasmDbId;
|
||||
if (!connectedNodeId) continue;
|
||||
const key = `parent:${thisNodeId}:${connectedNodeId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push({
|
||||
id: key,
|
||||
edge_type: "parent",
|
||||
parent_type: parent.parentType ?? null,
|
||||
this_node_id: thisNodeId,
|
||||
this_node_name: node.germplasm_name,
|
||||
connected_node_id: connectedNodeId,
|
||||
connected_node_name: parent.germplasmName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sibling of node.siblings ?? []) {
|
||||
const connectedNodeId = sibling.germplasmDbId;
|
||||
if (!connectedNodeId) continue;
|
||||
const [left, right] = [thisNodeId, connectedNodeId].sort();
|
||||
const key = `sibling:${left}:${right}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push({
|
||||
id: key,
|
||||
edge_type: "sibling",
|
||||
parent_type: null,
|
||||
this_node_id: thisNodeId,
|
||||
this_node_name: node.germplasm_name,
|
||||
connected_node_id: connectedNodeId,
|
||||
connected_node_name: sibling.germplasmName ?? null,
|
||||
read_only: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const buildParentPayload = (
|
||||
germplasmDbId: string,
|
||||
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
|
||||
) => ({
|
||||
germplasmDbId,
|
||||
parents: parents
|
||||
.filter((parent) => parent.germplasmDbId)
|
||||
.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
})),
|
||||
});
|
||||
|
||||
async function updatePedigreeParents(
|
||||
germplasmDbId: string,
|
||||
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
|
||||
): Promise<void> {
|
||||
await request<BrapiListResponse<PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
[germplasmDbId]: buildParentPayload(germplasmDbId, parents),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCrossPedigreeOptions(): Promise<{
|
||||
programs: SelectOption[];
|
||||
germplasm: SelectOption[];
|
||||
crossingProjects: SelectOption[];
|
||||
plannedCrosses: SelectOption[];
|
||||
observationUnits: SelectOption[];
|
||||
crosses: SelectOption[];
|
||||
}> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return {
|
||||
programs: snapshot.programs,
|
||||
germplasm: snapshot.germplasm,
|
||||
crossingProjects: snapshot.crossingProjectOptions,
|
||||
plannedCrosses: snapshot.plannedCrossOptions,
|
||||
observationUnits: snapshot.observationUnits,
|
||||
crosses: snapshot.crossOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCrossingProjectRows(): Promise<CrossingProjectRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.crossingProjects;
|
||||
}
|
||||
|
||||
export async function fetchCrossingProjectDetail(id: string): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiSingleResponse<CrossingProject>>(
|
||||
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
|
||||
);
|
||||
return mapCrossingProject(response.result);
|
||||
}
|
||||
|
||||
export async function createCrossingProjectRow(payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([crossingProjectBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCrossingProject(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateCrossingProjectRow(id: string, payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiSingleResponse<CrossingProject>>(
|
||||
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(crossingProjectBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateAfterMutation();
|
||||
return mapCrossingProject(response.result);
|
||||
}
|
||||
|
||||
export async function fetchPlannedCrossRows(): Promise<PlannedCrossRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.plannedCrosses;
|
||||
}
|
||||
|
||||
export async function createPlannedCrossRow(payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
|
||||
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([plannedCrossBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapPlannedCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updatePlannedCrossRow(id: string, payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
|
||||
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [id]: plannedCrossBody(payload) }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapPlannedCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchCrossRows(): Promise<CrossRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.actualCrosses;
|
||||
}
|
||||
|
||||
export async function createCrossRow(payload: CrossPayload): Promise<CrossRecord> {
|
||||
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([crossBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateCrossRow(id: string, payload: CrossPayload): Promise<CrossRecord> {
|
||||
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [id]: crossBody(payload) }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.parentRows;
|
||||
}
|
||||
|
||||
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
|
||||
const crossId = requiredText(payload.cross_id, "请选择所属 Cross");
|
||||
const parent1 = buildCrossParent(
|
||||
payload.parent1_type,
|
||||
payload.parent1_germplasm_id,
|
||||
payload.parent1_observation_unit_id,
|
||||
);
|
||||
const parent2 = buildCrossParent(
|
||||
payload.parent2_type,
|
||||
payload.parent2_germplasm_id,
|
||||
payload.parent2_observation_unit_id,
|
||||
);
|
||||
|
||||
const body = {
|
||||
crossingProjectDbId: payload.crossing_project_id ?? undefined,
|
||||
...(parent1 ? { parent1 } : {}),
|
||||
...(parent2 ? { parent2 } : {}),
|
||||
};
|
||||
|
||||
if (payload.planned) {
|
||||
await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [crossId]: body }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return;
|
||||
}
|
||||
|
||||
await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [crossId]: body }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
}
|
||||
|
||||
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
|
||||
"/brapi/v2/pedigree?page=0&pageSize=1000",
|
||||
);
|
||||
return response.result.data.map(mapPedigree);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
|
||||
"/brapi/v2/pedigree?page=0&pageSize=1000&includeParents=true&includeProgeny=false&includeSiblings=true",
|
||||
);
|
||||
return response.result.data.map(mapPedigree);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
|
||||
const rows = await fetchPedigreeRows();
|
||||
const found = rows.find((row) => row.id === id || row.germplasm_id === id);
|
||||
if (!found) throw new Error("系谱节点不存在");
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function createPedigreeRow(payload: PedigreePayload): Promise<PedigreeRecord> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([pedigreeBody(payload, true)]),
|
||||
});
|
||||
return mapPedigree(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updatePedigreeRow(id: string, payload: PedigreePayload): Promise<PedigreeRecord> {
|
||||
const germplasmDbId = optionalText(payload.germplasm_id) || id;
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
[germplasmDbId]: {
|
||||
...pedigreeBody({ ...payload, germplasm_id: germplasmDbId }),
|
||||
germplasmDbId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return mapPedigree(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeEdgeRows(): Promise<PedigreeEdgeRow[]> {
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
return flattenPedigreeEdges(nodes);
|
||||
}
|
||||
|
||||
export function buildPedigreeEdgeFormState(row?: PedigreeEdgeRow): PedigreeEdgeFormState {
|
||||
return {
|
||||
edge_type: row?.edge_type ?? "parent",
|
||||
parent_type: row?.parent_type && row.parent_type !== NONE_SELECT_VALUE ? row.parent_type : NONE_SELECT_VALUE,
|
||||
this_node_id: row?.this_node_id ?? NONE_SELECT_VALUE,
|
||||
connected_node_id: row?.connected_node_id ?? NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, originalId?: string): Promise<void> {
|
||||
const edgeType = requiredText(payload.edge_type, "请选择关系类型");
|
||||
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
|
||||
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
|
||||
if (thisNodeId === connectedNodeId) {
|
||||
throw new Error("当前材料与关联材料不能相同");
|
||||
}
|
||||
if (edgeType === "sibling") {
|
||||
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
|
||||
}
|
||||
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
const childGermplasmId = edgeType === "parent" ? thisNodeId : connectedNodeId;
|
||||
const parentGermplasmId = edgeType === "parent" ? connectedNodeId : thisNodeId;
|
||||
const parentType = requiredText(payload.parent_type, "parent/child 关系请选择 parent_type");
|
||||
|
||||
const childNode = nodes.find((node) => node.germplasm_id === childGermplasmId);
|
||||
const existingParents = childNode?.parents ?? [];
|
||||
let nextParents = existingParents.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
}));
|
||||
|
||||
if (originalId) {
|
||||
const [, , oldConnectedNodeId] = originalId.split(":");
|
||||
if (oldConnectedNodeId) {
|
||||
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== oldConnectedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== parentGermplasmId);
|
||||
nextParents.push({ germplasmDbId: parentGermplasmId, parentType });
|
||||
|
||||
await updatePedigreeParents(childGermplasmId, nextParents);
|
||||
}
|
||||
|
||||
export async function removePedigreeEdge(edgeId: string): Promise<void> {
|
||||
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
|
||||
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
|
||||
throw new Error("仅支持删除 parent 关系");
|
||||
}
|
||||
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
const childNode = nodes.find((node) => node.germplasm_id === thisNodeId);
|
||||
const nextParents = (childNode?.parents ?? [])
|
||||
.filter((parent) => parent.germplasmDbId !== connectedNodeId)
|
||||
.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
}));
|
||||
|
||||
await updatePedigreeParents(thisNodeId, nextParents);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { GitFork } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
CROSS_TYPE_OPTIONS,
|
||||
PLANNED_CROSS_STATUS_OPTIONS,
|
||||
crossTypeLabel,
|
||||
plannedStatusLabel,
|
||||
} from "../constants";
|
||||
import {
|
||||
createCrossRow,
|
||||
createPlannedCrossRow,
|
||||
normalizeCrossForm,
|
||||
normalizePlannedCrossForm,
|
||||
updateCrossRow,
|
||||
updatePlannedCrossRow,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
export function CrossEntityTab() {
|
||||
const { snapshot, refresh } = useCrossPedigree();
|
||||
const [subTab, setSubTab] = useState("planned");
|
||||
|
||||
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
|
||||
const plannedCrossOptions = snapshot?.plannedCrossOptions ?? [];
|
||||
|
||||
const loadPlannedRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.plannedCrosses as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const loadActualRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.actualCrosses as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const fetchPlannedRecord = useCallback(async (id: string) => {
|
||||
const data = await refresh(false);
|
||||
const row = data.plannedCrosses.find((item) => item.id === id);
|
||||
if (!row) throw new Error("计划杂交不存在");
|
||||
return normalizePlannedCrossForm(row);
|
||||
}, [refresh]);
|
||||
|
||||
const fetchActualRecord = useCallback(async (id: string) => {
|
||||
const data = await refresh(false);
|
||||
const row = data.actualCrosses.find((item) => item.id === id);
|
||||
if (!row) throw new Error("实际杂交不存在");
|
||||
return normalizeCrossForm(row);
|
||||
}, [refresh]);
|
||||
|
||||
const plannedFields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "计划杂交名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 B73 x Mo17 / Cross-2026-001",
|
||||
},
|
||||
{
|
||||
key: "crossing_project_id",
|
||||
label: "杂交项目",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
|
||||
},
|
||||
{
|
||||
key: "cross_type",
|
||||
label: "杂交类型",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
type: "select",
|
||||
options: PLANNED_CROSS_STATUS_OPTIONS,
|
||||
},
|
||||
], [crossingProjectOptions]);
|
||||
|
||||
const actualFields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "实际杂交名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 B73 x Mo17 实际杂交",
|
||||
},
|
||||
{
|
||||
key: "crossing_project_id",
|
||||
label: "杂交项目",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
|
||||
},
|
||||
{
|
||||
key: "cross_type",
|
||||
label: "杂交类型",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
|
||||
},
|
||||
{
|
||||
key: "planned_cross_id",
|
||||
label: "来源计划杂交",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不关联计划杂交" }, ...plannedCrossOptions],
|
||||
},
|
||||
], [crossingProjectOptions, plannedCrossOptions]);
|
||||
|
||||
return (
|
||||
<Tabs value={subTab} onValueChange={setSubTab} 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="planned">计划杂交 (planned=true)</TabsTrigger>
|
||||
<TabsTrigger value="actual">实际杂交 (planned=false)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{subTab === "planned" ? (
|
||||
<TabsContent value="planned" className="mt-0 min-h-0 flex-1">
|
||||
<BrapiEntityPage
|
||||
icon={GitFork}
|
||||
iconBg="bg-gradient-to-br from-emerald-500 to-green-600"
|
||||
title="计划杂交"
|
||||
description="cross_entity(planned=true):录入杂交计划,亲本请在「杂交亲本」Tab 维护"
|
||||
addLabel="新增计划杂交"
|
||||
useEnhancedDialog
|
||||
columns={[
|
||||
{ key: "plannedCrossDbId", label: "Cross ID" },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "crossing_project_name", label: "杂交项目" },
|
||||
{ key: "cross_type", label: "类型", render: crossTypeLabel },
|
||||
{ key: "status", label: "状态", render: plannedStatusLabel },
|
||||
]}
|
||||
fields={plannedFields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/plannedcrosses",
|
||||
value: "BrAPI",
|
||||
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadPlannedRows}
|
||||
fetchRecord={fetchPlannedRecord}
|
||||
createRecord={(payload) => createPlannedCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updatePlannedCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{subTab === "actual" ? (
|
||||
<TabsContent value="actual" className="mt-0 min-h-0 flex-1">
|
||||
<BrapiEntityPage
|
||||
icon={GitFork}
|
||||
iconBg="bg-gradient-to-br from-green-600 to-emerald-700"
|
||||
title="实际杂交"
|
||||
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交;亲本请在「杂交亲本」Tab 维护"
|
||||
addLabel="新增实际杂交"
|
||||
useEnhancedDialog
|
||||
columns={[
|
||||
{ key: "crossDbId", label: "Cross ID" },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "crossing_project_name", label: "杂交项目" },
|
||||
{ key: "plannedCrossName", label: "来源计划杂交" },
|
||||
{ key: "cross_type", label: "类型", render: crossTypeLabel },
|
||||
]}
|
||||
fields={actualFields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/crosses",
|
||||
value: "BrAPI",
|
||||
className: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadActualRows}
|
||||
fetchRecord={fetchActualRecord}
|
||||
createRecord={(payload) => createCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Users } from "lucide-react";
|
||||
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 { PARENT_TYPE_OPTIONS, parentTypeLabel } from "../constants";
|
||||
import {
|
||||
buildCrossParentFormState,
|
||||
updateCrossParents,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE, type CrossParentFormState, type CrossParentRow, type SelectOption } from "../types";
|
||||
|
||||
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 CrossParentTab() {
|
||||
const { snapshot, loading: pageLoading, refresh } = useCrossPedigree();
|
||||
const [rows, setRows] = useState<CrossParentRow[]>([]);
|
||||
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<CrossParentFormState | null>(null);
|
||||
|
||||
const germplasmOptions = snapshot?.germplasm ?? [];
|
||||
const observationUnitOptions = snapshot?.observationUnits ?? [];
|
||||
const crossOptions = snapshot?.crossOptions ?? [];
|
||||
|
||||
const syncRowsFromSnapshot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await refresh(false);
|
||||
setRows(data.parentRows);
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
syncRowsFromSnapshot();
|
||||
}, [syncRowsFromSnapshot]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return rows;
|
||||
return rows.filter((row) =>
|
||||
[row.cross_name, row.germplasm_name, row.observation_unit_name, row.crossing_project_name, row.parent_type]
|
||||
.some((value) => String(value ?? "").toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [rows, search]);
|
||||
|
||||
const openEditorForCross = useCallback((crossId: string) => {
|
||||
if (!snapshot) throw new Error("数据尚未加载");
|
||||
const plannedCross = snapshot.plannedCrosses.find((item) => item.id === crossId);
|
||||
if (plannedCross) {
|
||||
setForm(buildCrossParentFormState(
|
||||
plannedCross.id,
|
||||
true,
|
||||
plannedCross.crossing_project_id,
|
||||
plannedCross.crossing_project_name,
|
||||
plannedCross.parent1,
|
||||
plannedCross.parent2,
|
||||
));
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
const actualCross = snapshot.actualCrosses.find((item) => item.id === crossId);
|
||||
if (!actualCross) throw new Error("Cross 不存在");
|
||||
setForm(buildCrossParentFormState(
|
||||
actualCross.id,
|
||||
false,
|
||||
actualCross.crossing_project_id,
|
||||
actualCross.crossing_project_name,
|
||||
actualCross.parent1,
|
||||
actualCross.parent2,
|
||||
));
|
||||
setDialogOpen(true);
|
||||
}, [snapshot]);
|
||||
|
||||
const openCreate = () => {
|
||||
if (crossOptions.length === 0) {
|
||||
setError("请先在「Cross 杂交」Tab 创建计划或实际杂交");
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
cross_id: NONE_SELECT_VALUE,
|
||||
planned: false,
|
||||
crossing_project_id: null,
|
||||
crossing_project_name: null,
|
||||
parent1_type: NONE_SELECT_VALUE,
|
||||
parent1_germplasm_id: NONE_SELECT_VALUE,
|
||||
parent1_observation_unit_id: NONE_SELECT_VALUE,
|
||||
parent2_type: NONE_SELECT_VALUE,
|
||||
parent2_germplasm_id: NONE_SELECT_VALUE,
|
||||
parent2_observation_unit_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCrossChange = (crossId: string) => {
|
||||
if (crossId === NONE_SELECT_VALUE || !form) return;
|
||||
try {
|
||||
openEditorForCross(crossId);
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "加载 Cross 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateCrossParents(form);
|
||||
setDialogOpen(false);
|
||||
await syncRowsFromSnapshot();
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const crossIds = useMemo(() => Array.from(new Set(rows.map((row) => row.cross_id))), [rows]);
|
||||
const showLoading = loading || pageLoading;
|
||||
|
||||
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-violet-500 to-purple-600")}>
|
||||
<Users className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Cross Parent 杂交亲本</h2>
|
||||
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
cross_parent:通过 Cross 的 parent1/parent2 维护亲本(parent_type + germplasm / observation_unit)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="shrink-0">维护亲本</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<span className="rounded-full bg-violet-50 px-3 py-1 text-xs text-violet-700 dark:bg-violet-400/10 dark:text-violet-200">
|
||||
BrAPI /brapi/v2/crosses · /brapi/v2/plannedcrosses (PUT parent1/parent2)
|
||||
</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="搜索 Cross / 种质 / 观测单元 / 项目"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
{showLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-slate-900">
|
||||
<TableHead>Cross</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>杂交项目</TableHead>
|
||||
<TableHead>亲本位</TableHead>
|
||||
<TableHead>parent_type</TableHead>
|
||||
<TableHead>种质</TableHead>
|
||||
<TableHead>观测单元</TableHead>
|
||||
<TableHead className="w-24 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-sm text-slate-400">
|
||||
暂无亲本记录。请先在 Cross Tab 创建杂交,再点击「维护亲本」录入 parent1/parent2。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.cross_name || row.cross_id}</TableCell>
|
||||
<TableCell>{row.planned ? "计划杂交" : "实际杂交"}</TableCell>
|
||||
<TableCell>{row.crossing_project_name || "—"}</TableCell>
|
||||
<TableCell>{row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"}</TableCell>
|
||||
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
|
||||
<TableCell>{row.germplasm_name || row.germplasm_id || "—"}</TableCell>
|
||||
<TableCell>{row.observation_unit_name || row.observation_unit_id || "—"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEditorForCross(row.cross_id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{!showLoading && crossIds.length > 0 ? (
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
共 {crossIds.length} 个 Cross 已录入亲本信息(BrAPI 每个 Cross 支持 parent1 / parent2 两个亲本槽位)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-3xl" title="维护杂交亲本 (cross_parent)">
|
||||
<DialogBody className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">所属 Cross</Label>
|
||||
<Select
|
||||
value={form?.cross_id || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, cross_id: value });
|
||||
handleCrossChange(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择 Cross" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>请选择 Cross</SelectItem>
|
||||
{crossOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">crossing_project_id(只读)</Label>
|
||||
<Input readOnly value={form?.crossing_project_name || form?.crossing_project_id || "—"} />
|
||||
</div>
|
||||
|
||||
{form ? (
|
||||
<>
|
||||
<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({ ...form, ...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({ ...form, ...patch })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form || form.cross_id === NONE_SELECT_VALUE}>
|
||||
{saving ? "保存中..." : "保存亲本"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import {
|
||||
createCrossingProjectRow,
|
||||
fetchCrossingProjectDetail,
|
||||
normalizeCrossingProjectForm,
|
||||
updateCrossingProjectRow,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
export function CrossingProjectTab() {
|
||||
const { snapshot, refresh } = useCrossPedigree();
|
||||
const programOptions = snapshot?.programs ?? [];
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.crossingProjects as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const fetchRecord = useCallback(async (id: string) => {
|
||||
const detail = await fetchCrossingProjectDetail(id);
|
||||
return normalizeCrossingProjectForm(detail);
|
||||
}, []);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "杂交项目名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 2026 抗倒伏杂交项目",
|
||||
},
|
||||
{
|
||||
key: "program_id",
|
||||
label: "所属 Program",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Program" }, ...programOptions],
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "项目说明",
|
||||
type: "textarea",
|
||||
placeholder: "杂交项目目标、范围、批次安排等",
|
||||
colSpan: 2,
|
||||
},
|
||||
], [programOptions]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
icon={Network}
|
||||
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
|
||||
title="CrossingProject 杂交项目"
|
||||
description="crossing_project:某 Program 下的一组杂交任务集合(杂交工作台)。ID 由系统自动生成。"
|
||||
addLabel="新增杂交项目"
|
||||
useEnhancedDialog
|
||||
fetchRecord={fetchRecord}
|
||||
columns={[
|
||||
{ key: "crossingProjectDbId", label: "项目 ID" },
|
||||
{ key: "name", label: "项目名称" },
|
||||
{ key: "program_name", label: "Program" },
|
||||
{
|
||||
key: "description",
|
||||
label: "说明",
|
||||
render: (value) => {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "—";
|
||||
return text.length > 48 ? `${text.slice(0, 48)}…` : text;
|
||||
},
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/crossingprojects",
|
||||
value: "BrAPI",
|
||||
className: "bg-lime-50 text-lime-700 dark:bg-lime-400/10 dark:text-lime-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadRows}
|
||||
createRecord={(payload) => createCrossingProjectRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCrossingProjectRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"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";
|
||||
|
||||
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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
frontend/src/app/(app)/germplasm/cross-pedigree/constants.ts
Normal file
47
frontend/src/app/(app)/germplasm/cross-pedigree/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SelectOption } from "./types";
|
||||
|
||||
export const CROSS_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "BIPARENTAL", label: "BIPARENTAL 双亲杂交" },
|
||||
{ value: "SELF", label: "SELF 自交" },
|
||||
{ value: "OPEN_POLLINATED", label: "OPEN_POLLINATED 开放授粉" },
|
||||
{ value: "BULK", label: "BULK bulk" },
|
||||
{ value: "BULK_SELFED", label: "BULK_SELFED" },
|
||||
{ value: "BULK_OPEN_POLLINATED", label: "BULK_OPEN_POLLINATED" },
|
||||
{ value: "DOUBLE_HAPLOID", label: "DOUBLE_HAPLOID 双单倍体" },
|
||||
];
|
||||
|
||||
export const PLANNED_CROSS_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: "TODO", label: "TODO 待执行" },
|
||||
{ value: "DONE", label: "DONE 已完成" },
|
||||
{ value: "SKIPPED", label: "SKIPPED 已跳过" },
|
||||
];
|
||||
|
||||
export const PARENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "FEMALE", label: "FEMALE 母本" },
|
||||
{ value: "MALE", label: "MALE 父本" },
|
||||
{ value: "SELF", label: "SELF 自交" },
|
||||
{ value: "POPULATION", label: "POPULATION 群体" },
|
||||
{ value: "CLONAL", label: "CLONAL 克隆" },
|
||||
];
|
||||
|
||||
export const crossTypeLabel = (value: unknown) =>
|
||||
CROSS_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const plannedStatusLabel = (value: unknown) =>
|
||||
PLANNED_CROSS_STATUS_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const parentTypeLabel = (value: unknown) =>
|
||||
PARENT_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const EDGE_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "parent", label: "parent 亲本" },
|
||||
{ value: "child", label: "child 后代" },
|
||||
];
|
||||
|
||||
export const edgeTypeLabel = (value: unknown) => {
|
||||
const text = String(value ?? "");
|
||||
if (text === "parent") return "parent 亲本";
|
||||
if (text === "child") return "child 后代";
|
||||
if (text === "sibling") return "sibling 同胞";
|
||||
return text || "—";
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Cross, CrossParent, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
|
||||
import {
|
||||
loadGermplasmOptions,
|
||||
loadObservationUnitOptions,
|
||||
loadProgramOptions,
|
||||
} from "@/services/dropdownCache";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
mapCross,
|
||||
mapCrossingProject,
|
||||
mapPlannedCross,
|
||||
} from "./mappers";
|
||||
import type {
|
||||
CrossParentRow,
|
||||
CrossRecord,
|
||||
CrossingProjectRecord,
|
||||
PlannedCrossRecord,
|
||||
SelectOption,
|
||||
} from "./types";
|
||||
|
||||
interface BrapiListResponse<T> {
|
||||
result: { data: T[] };
|
||||
}
|
||||
|
||||
export interface CrossPedigreeSnapshot {
|
||||
programs: SelectOption[];
|
||||
germplasm: SelectOption[];
|
||||
observationUnits: SelectOption[];
|
||||
crossingProjectOptions: SelectOption[];
|
||||
plannedCrossOptions: SelectOption[];
|
||||
crossOptions: SelectOption[];
|
||||
crossingProjects: CrossingProjectRecord[];
|
||||
plannedCrosses: PlannedCrossRecord[];
|
||||
actualCrosses: CrossRecord[];
|
||||
parentRows: CrossParentRow[];
|
||||
}
|
||||
|
||||
const apiBase = () => {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `请求失败:${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const flattenCrossParents = (
|
||||
crossId: string,
|
||||
crossName: string | null,
|
||||
planned: boolean,
|
||||
crossingProjectId: string | null,
|
||||
crossingProjectName: string | null,
|
||||
parent: CrossParent | null | undefined,
|
||||
slot: "parent1" | "parent2",
|
||||
): CrossParentRow | null => {
|
||||
if (!parent) return null;
|
||||
const hasData = parent.parentType || parent.germplasmDbId || parent.observationUnitDbId;
|
||||
if (!hasData) return null;
|
||||
return {
|
||||
id: `${crossId}:${slot}`,
|
||||
cross_id: crossId,
|
||||
cross_name: crossName,
|
||||
planned,
|
||||
parent_slot: slot,
|
||||
parent_type: parent.parentType ?? null,
|
||||
germplasm_id: parent.germplasmDbId ?? null,
|
||||
germplasm_name: parent.germplasmName ?? null,
|
||||
observation_unit_id: parent.observationUnitDbId ?? null,
|
||||
observation_unit_name: parent.observationUnitName ?? null,
|
||||
crossing_project_id: crossingProjectId,
|
||||
crossing_project_name: crossingProjectName,
|
||||
};
|
||||
};
|
||||
|
||||
function buildParentRows(planned: PlannedCrossRecord[], actual: CrossRecord[]): CrossParentRow[] {
|
||||
const rows: CrossParentRow[] = [];
|
||||
|
||||
for (const cross of planned) {
|
||||
const p1 = flattenCrossParents(
|
||||
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
|
||||
);
|
||||
const p2 = flattenCrossParents(
|
||||
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
|
||||
);
|
||||
if (p1) rows.push(p1);
|
||||
if (p2) rows.push(p2);
|
||||
}
|
||||
|
||||
for (const cross of actual) {
|
||||
const p1 = flattenCrossParents(
|
||||
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
|
||||
);
|
||||
const p2 = flattenCrossParents(
|
||||
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
|
||||
);
|
||||
if (p1) rows.push(p1);
|
||||
if (p2) rows.push(p2);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function fetchSnapshotFromNetwork(): Promise<CrossPedigreeSnapshot> {
|
||||
const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([
|
||||
loadProgramOptions(),
|
||||
loadGermplasmOptions(),
|
||||
request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects?page=0&pageSize=1000"),
|
||||
request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses?page=0&pageSize=1000"),
|
||||
request<BrapiListResponse<Cross>>("/brapi/v2/crosses?page=0&pageSize=1000"),
|
||||
loadObservationUnitOptions().catch(() => [] as SelectOption[]),
|
||||
]);
|
||||
|
||||
const crossingProjectRows = (crossingProjects.result?.data ?? []).map(mapCrossingProject);
|
||||
const plannedRows = (plannedCrosses.result?.data ?? []).map(mapPlannedCross);
|
||||
const actualRows = (actualCrosses.result?.data ?? []).map(mapCross);
|
||||
|
||||
return {
|
||||
programs,
|
||||
germplasm,
|
||||
observationUnits,
|
||||
crossingProjectOptions: crossingProjectRows.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name || project.id,
|
||||
})),
|
||||
plannedCrossOptions: plannedRows.map((cross) => ({
|
||||
value: cross.id,
|
||||
label: cross.name || cross.id,
|
||||
})),
|
||||
crossOptions: [...plannedRows, ...actualRows].map((cross) => ({
|
||||
value: cross.id,
|
||||
label: `${cross.planned ? "[计划] " : "[实际] "}${cross.name || cross.id}`,
|
||||
})),
|
||||
crossingProjects: crossingProjectRows,
|
||||
plannedCrosses: plannedRows,
|
||||
actualCrosses: actualRows,
|
||||
parentRows: buildParentRows(plannedRows, actualRows),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedSnapshot: CrossPedigreeSnapshot | null = null;
|
||||
let inflightSnapshot: Promise<CrossPedigreeSnapshot> | null = null;
|
||||
|
||||
export function invalidateCrossPedigreeCache() {
|
||||
cachedSnapshot = null;
|
||||
inflightSnapshot = null;
|
||||
}
|
||||
|
||||
export async function loadCrossPedigreeSnapshot(force = false): Promise<CrossPedigreeSnapshot> {
|
||||
if (!force && cachedSnapshot) return cachedSnapshot;
|
||||
if (!force && inflightSnapshot) return inflightSnapshot;
|
||||
|
||||
inflightSnapshot = fetchSnapshotFromNetwork()
|
||||
.then((snapshot) => {
|
||||
cachedSnapshot = snapshot;
|
||||
inflightSnapshot = null;
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
inflightSnapshot = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return inflightSnapshot;
|
||||
}
|
||||
|
||||
export function getCrossPedigreeSnapshot(): CrossPedigreeSnapshot | null {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
51
frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts
Normal file
51
frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Cross, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
|
||||
import type { CrossRecord, CrossingProjectRecord, PlannedCrossRecord } from "./types";
|
||||
|
||||
export const mapCrossingProject = (project: CrossingProject): CrossingProjectRecord => ({
|
||||
id: project.crossingProjectDbId || "",
|
||||
crossingProjectDbId: project.crossingProjectDbId || "",
|
||||
crossingProjectName: project.crossingProjectName ?? null,
|
||||
name: project.crossingProjectName ?? null,
|
||||
crossingProjectDescription: project.crossingProjectDescription ?? null,
|
||||
description: project.crossingProjectDescription ?? null,
|
||||
programDbId: project.programDbId ?? null,
|
||||
program_id: project.programDbId ?? null,
|
||||
programName: project.programName ?? null,
|
||||
program_name: project.programName ?? null,
|
||||
});
|
||||
|
||||
export const mapPlannedCross = (cross: PlannedCross): PlannedCrossRecord => ({
|
||||
id: cross.plannedCrossDbId || "",
|
||||
plannedCrossDbId: cross.plannedCrossDbId || "",
|
||||
plannedCrossName: cross.plannedCrossName ?? null,
|
||||
name: cross.plannedCrossName ?? null,
|
||||
crossType: cross.crossType ?? null,
|
||||
cross_type: cross.crossType ?? null,
|
||||
status: cross.status ?? null,
|
||||
crossingProjectDbId: cross.crossingProjectDbId ?? null,
|
||||
crossing_project_id: cross.crossingProjectDbId ?? null,
|
||||
crossingProjectName: cross.crossingProjectName ?? null,
|
||||
crossing_project_name: cross.crossingProjectName ?? null,
|
||||
planned: true,
|
||||
parent1: cross.parent1 ?? null,
|
||||
parent2: cross.parent2 ?? null,
|
||||
});
|
||||
|
||||
export const mapCross = (cross: Cross): CrossRecord => ({
|
||||
id: cross.crossDbId || "",
|
||||
crossDbId: cross.crossDbId || "",
|
||||
crossName: cross.crossName ?? null,
|
||||
name: cross.crossName ?? null,
|
||||
crossType: cross.crossType ?? null,
|
||||
cross_type: cross.crossType ?? null,
|
||||
crossingProjectDbId: cross.crossingProjectDbId ?? null,
|
||||
crossing_project_id: cross.crossingProjectDbId ?? null,
|
||||
crossingProjectName: cross.crossingProjectName ?? null,
|
||||
crossing_project_name: cross.crossingProjectName ?? null,
|
||||
plannedCrossDbId: cross.plannedCrossDbId ?? null,
|
||||
planned_cross_id: cross.plannedCrossDbId ?? null,
|
||||
plannedCrossName: cross.plannedCrossName ?? null,
|
||||
planned: false,
|
||||
parent1: cross.parent1 ?? null,
|
||||
parent2: cross.parent2 ?? null,
|
||||
});
|
||||
80
frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx
Normal file
80
frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GitBranch, GitFork, Network, Share2, Users } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CrossPedigreeProvider } from "./CrossPedigreeContext";
|
||||
import { CrossingProjectTab } from "./components/CrossingProjectTab";
|
||||
import { CrossEntityTab } from "./components/CrossEntityTab";
|
||||
import { CrossParentTab } from "./components/CrossParentTab";
|
||||
import { PedigreeEdgeTab } from "./components/PedigreeEdgeTab";
|
||||
import { PedigreeNodeTab } from "./components/PedigreeNodeTab";
|
||||
|
||||
function CrossPedigreePageContent() {
|
||||
const [tab, setTab] = useState("projects");
|
||||
|
||||
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">
|
||||
<TabsTrigger value="projects" className="gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
CrossingProject
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="crosses" className="gap-2">
|
||||
<GitFork className="h-4 w-4" />
|
||||
Cross 杂交
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="parents" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Cross Parent 亲本
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pedigree-nodes" className="gap-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Pedigree Node
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pedigree-edges" className="gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Pedigree Edge
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{tab === "projects" ? (
|
||||
<TabsContent value="projects" className="mt-0 min-h-0 flex-1">
|
||||
<CrossingProjectTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "crosses" ? (
|
||||
<TabsContent value="crosses" className="mt-0 min-h-0 flex-1">
|
||||
<CrossEntityTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "parents" ? (
|
||||
<TabsContent value="parents" className="mt-0 min-h-0 flex-1">
|
||||
<CrossParentTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "pedigree-nodes" ? (
|
||||
<TabsContent value="pedigree-nodes" className="mt-0 min-h-0 flex-1">
|
||||
<PedigreeNodeTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "pedigree-edges" ? (
|
||||
<TabsContent value="pedigree-edges" className="mt-0 min-h-0 flex-1">
|
||||
<PedigreeEdgeTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrossPedigreePage() {
|
||||
return (
|
||||
<CrossPedigreeProvider>
|
||||
<CrossPedigreePageContent />
|
||||
</CrossPedigreeProvider>
|
||||
);
|
||||
}
|
||||
137
frontend/src/app/(app)/germplasm/cross-pedigree/types.ts
Normal file
137
frontend/src/app/(app)/germplasm/cross-pedigree/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { CrossParent } from "@/lib/api/types.gen";
|
||||
|
||||
export const NONE_SELECT_VALUE = "__none__";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CrossingProjectRecord {
|
||||
id: string;
|
||||
crossingProjectDbId: string;
|
||||
crossingProjectName: string | null;
|
||||
name: string | null;
|
||||
crossingProjectDescription: string | null;
|
||||
description: string | null;
|
||||
programDbId: string | null;
|
||||
program_id: string | null;
|
||||
programName: string | null;
|
||||
program_name: string | null;
|
||||
}
|
||||
|
||||
export interface PlannedCrossRecord {
|
||||
id: string;
|
||||
plannedCrossDbId: string;
|
||||
plannedCrossName: string | null;
|
||||
name: string | null;
|
||||
crossType: string | null;
|
||||
cross_type: string | null;
|
||||
status: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
planned: true;
|
||||
parent1: CrossParent | null;
|
||||
parent2: CrossParent | null;
|
||||
}
|
||||
|
||||
export interface CrossRecord {
|
||||
id: string;
|
||||
crossDbId: string;
|
||||
crossName: string | null;
|
||||
name: string | null;
|
||||
crossType: string | null;
|
||||
cross_type: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
plannedCrossDbId: string | null;
|
||||
planned_cross_id: string | null;
|
||||
plannedCrossName: string | null;
|
||||
planned: false;
|
||||
parent1: CrossParent | null;
|
||||
parent2: CrossParent | null;
|
||||
}
|
||||
|
||||
export interface CrossParentRow {
|
||||
id: string;
|
||||
cross_id: string;
|
||||
cross_name: string | null;
|
||||
planned: boolean;
|
||||
parent_slot: "parent1" | "parent2";
|
||||
parent_type: string | null;
|
||||
germplasm_id: string | null;
|
||||
germplasm_name: string | null;
|
||||
observation_unit_id: string | null;
|
||||
observation_unit_name: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossing_project_name: string | null;
|
||||
}
|
||||
|
||||
export interface CrossParentFormState {
|
||||
cross_id: string;
|
||||
planned: boolean;
|
||||
crossing_project_id: string | null;
|
||||
crossing_project_name: string | null;
|
||||
parent1_type: string;
|
||||
parent1_germplasm_id: string;
|
||||
parent1_observation_unit_id: string;
|
||||
parent2_type: string;
|
||||
parent2_germplasm_id: string;
|
||||
parent2_observation_unit_id: string;
|
||||
}
|
||||
|
||||
export interface PedigreeNodeParentRef {
|
||||
germplasmDbId?: string;
|
||||
germplasmName?: string;
|
||||
parentType?: string;
|
||||
}
|
||||
|
||||
export interface PedigreeNodeSiblingRef {
|
||||
germplasmDbId?: string;
|
||||
germplasmName?: string;
|
||||
}
|
||||
|
||||
export interface PedigreeRecord {
|
||||
id: string;
|
||||
pedigreeNodeDbId?: string;
|
||||
germplasmDbId: string | null;
|
||||
germplasm_id: string | null;
|
||||
germplasmName: string | null;
|
||||
germplasm_name: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
crossingYear: number | null;
|
||||
crossing_year: number | null;
|
||||
familyCode: string | null;
|
||||
family_code: string | null;
|
||||
pedigreeString: string | null;
|
||||
pedigree_string: string | null;
|
||||
parents?: PedigreeNodeParentRef[];
|
||||
siblings?: PedigreeNodeSiblingRef[];
|
||||
}
|
||||
|
||||
export type PedigreeEdgeType = "parent" | "child" | "sibling";
|
||||
|
||||
export interface PedigreeEdgeRow {
|
||||
id: string;
|
||||
edge_type: PedigreeEdgeType;
|
||||
parent_type: string | null;
|
||||
this_node_id: string;
|
||||
this_node_name: string | null;
|
||||
connected_node_id: string;
|
||||
connected_node_name: string | null;
|
||||
read_only?: boolean;
|
||||
}
|
||||
|
||||
export interface PedigreeEdgeFormState {
|
||||
edge_type: string;
|
||||
parent_type: string;
|
||||
this_node_id: string;
|
||||
connected_node_id: string;
|
||||
}
|
||||
Reference in New Issue
Block a user