fix:sample/plate 之前的开发

This commit is contained in:
彭帅
2026-05-28 11:56:17 +08:00
parent fc36bc83e3
commit 8b65de36b8
367 changed files with 57752 additions and 947 deletions

View File

@@ -0,0 +1,5 @@
import { PageHeader } from "@/components/common/PageHeader";
export default function CatalogPage() {
return <PageHeader title="资产目录" description="三级菜单页面占位,一级与二级菜单目前为示例占位结构。" />;
}

View File

@@ -0,0 +1,5 @@
import { PageHeader } from "@/components/common/PageHeader";
export default function SupplierPage() {
return <PageHeader title="供应商档案" description="三级菜单页面占位,一级与二级菜单目前为示例占位结构。" />;
}

View File

@@ -0,0 +1,31 @@
import { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import type { CropFormValues } from "./types";
interface CropFormProps {
values: CropFormValues;
submitting: boolean;
onChange: (key: keyof CropFormValues, value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export function CropForm({ values, submitting, onChange, onSubmit }: CropFormProps) {
return (
<form className="card-agriculture grid gap-5 p-5 md:p-6" onSubmit={onSubmit}>
<div className="flex items-start gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-secondary text-sm font-black text-secondary-foreground">01</span>
<div>
<h2 className="text-xl font-black tracking-tight text-card-foreground"></h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground"></p>
</div>
</div>
<label className="grid gap-2 text-sm font-black text-card-foreground">
<span></span>
<input className="input-agriculture" value={values.cropName} onChange={(event) => onChange("cropName", event.target.value)} placeholder="例如maize / rice / wheat" />
</label>
<Button variant="agriculture" size="lg" type="submit" disabled={submitting || !values.cropName.trim()}>
{submitting ? "正在写入 crop 表..." : "保存作物"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,17 @@
interface CropGuideProps {
error: string | null;
}
export function CropGuide({ error }: CropGuideProps) {
return (
<div className="card-agriculture p-5 md:p-6">
<h3 className="text-lg font-black text-card-foreground"></h3>
<ul className="mt-4 grid gap-3 text-sm leading-6 text-muted-foreground">
<li className="rounded-2xl bg-muted px-4 py-3">使</li>
<li className="rounded-2xl bg-muted px-4 py-3"> ProgramTrialStudy</li>
<li className="rounded-2xl bg-muted px-4 py-3"></li>
</ul>
{error ? <div className="mt-4 rounded-2xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm font-bold text-destructive">{error}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Button } from "@/components/ui/button";
const quickCrops = ["rice", "maize", "wheat", "soybean", "cotton"];
interface CropHeaderProps {
total: number;
loading: boolean;
onSelectQuickCrop: (name: string) => void;
}
export function CropHeader({ total, loading, onSelectQuickCrop }: CropHeaderProps) {
return (
<div className="relative grid gap-6 overflow-hidden rounded-[34px] bg-[radial-gradient(circle_at_12%_15%,rgba(187,247,208,0.94),transparent_28%),linear-gradient(135deg,var(--primary),#0f766e_48%,#84cc16)] p-6 text-primary-foreground shadow-agriculture lg:grid-cols-[1fr_240px] lg:p-8">
<div className="pointer-events-none absolute -right-16 -top-16 h-56 w-56 rounded-full bg-primary-foreground/15 blur-sm" />
<div className="relative z-10 max-w-3xl">
<div className="mb-4 inline-flex rounded-full border border-primary-foreground/25 bg-primary-foreground/15 px-3 py-1 text-xs font-black uppercase tracking-[0.2em]">
/ Crop
</div>
<h1 className="text-3xl font-black tracking-tight md:text-5xl"></h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-primary-foreground/90 md:text-base">
BrAPI <strong className="font-black">crop</strong> ProgramTrialStudyGermplasm
</p>
<div className="mt-5 flex flex-wrap gap-2">
{quickCrops.map((name) => (
<Button key={name} type="button" variant="outline" className="rounded-full border-primary-foreground/20 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25" onClick={() => onSelectQuickCrop(name)}>
{name}
</Button>
))}
</div>
</div>
<div className="relative z-10 flex min-h-44 flex-col justify-between rounded-3xl border border-primary-foreground/20 bg-primary-foreground/15 p-5 shadow-2xl shadow-emerald-950/15 backdrop-blur">
<span className="text-sm font-black text-primary-foreground/85"></span>
<strong className="text-6xl font-black leading-none">{total}</strong>
<em className="not-italic text-sm font-bold text-primary-foreground/85">{loading ? "正在同步数据库..." : "已连接 crop 表"}</em>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { Button } from "@/components/ui/button";
import type { CropItem } from "./types";
interface CropTableProps {
rows: CropItem[];
loading: boolean;
onRefresh: () => void;
}
export function CropTable({ rows, loading, onRefresh }: CropTableProps) {
return (
<div className="card-agriculture overflow-hidden">
<div className="flex flex-col gap-3 border-b border-border p-5 md:flex-row md:items-center md:justify-between md:p-6">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-accent text-sm font-black text-accent-foreground">02</span>
<h2 className="text-xl font-black tracking-tight text-card-foreground">Crop </h2>
</div>
<Button variant="agriculture" type="button" onClick={onRefresh}>{loading ? "刷新中..." : "刷新数据"}</Button>
</div>
{loading ? (
<div className="p-8 text-center text-sm font-bold text-muted-foreground"> crop ...</div>
) : rows.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-muted text-xs uppercase tracking-[0.16em] text-muted-foreground">
<tr>
<th className="px-5 py-4 font-black">ID</th>
<th className="px-5 py-4 font-black"></th>
<th className="px-5 py-4 font-black"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => (
<tr key={row.id} className="transition hover:bg-muted/70">
<td className="px-5 py-4 text-muted-foreground">{row.id}</td>
<td className="px-5 py-4"><strong className="font-black text-card-foreground">{row.crop_name || "-"}</strong></td>
<td className="px-5 py-4 text-muted-foreground">{row.user_name || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="p-8 text-center text-sm font-bold text-muted-foreground"> maize</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { createCrop, listCrops } from "@/services/dictionaryService";
import type { CropFormValues, CropItem } from "./types";
export async function fetchCropList(): Promise<CropItem[]> {
return listCrops();
}
export async function saveCrop(values: CropFormValues): Promise<CropItem> {
return createCrop({
crop_name: values.cropName.trim(),
});
}

View File

@@ -0,0 +1,28 @@
import type { CropPageState } from "./types";
export type CropPageAction =
| { type: "SET_ROWS"; payload: CropPageState["rows"] }
| { type: "SET_FORM_FIELD"; payload: { key: keyof CropPageState["form"]; value: string } }
| { type: "RESET_FORM" }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SUBMITTING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null };
export function cropReducer(state: CropPageState, action: CropPageAction): CropPageState {
switch (action.type) {
case "SET_ROWS":
return { ...state, rows: action.payload };
case "SET_FORM_FIELD":
return { ...state, form: { ...state.form, [action.payload.key]: action.payload.value } };
case "RESET_FORM":
return { ...state, form: { cropName: "" } };
case "SET_LOADING":
return { ...state, loading: action.payload };
case "SET_SUBMITTING":
return { ...state, submitting: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload };
default:
return state;
}
}

View File

@@ -0,0 +1,25 @@
import type { CropRecord } from "@/services/dictionaryService";
export type CropItem = CropRecord;
export interface CropFormValues {
cropName: string;
}
export interface CropPageState {
rows: CropItem[];
form: CropFormValues;
loading: boolean;
submitting: boolean;
error: string | null;
}
export const initialCropState: CropPageState = {
rows: [],
form: {
cropName: "",
},
loading: true,
submitting: false,
error: null,
};

View File

@@ -0,0 +1,43 @@
"use client";
import { Leaf } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { createCrop, deleteCrop, listCrops, updateCrop } from "@/services/dictionaryService";
const loadCropRows = async () => listCrops() as unknown as Record<string, unknown>[];
const createCropRow = async (payload: Record<string, unknown>) => createCrop({
crop_name: String(payload.crop_name ?? ""),
}) as unknown as Record<string, unknown>;
const updateCropRow = async (id: string, payload: Record<string, unknown>) => updateCrop(id, {
crop_name: String(payload.crop_name ?? ""),
}) as unknown as Record<string, unknown>;
const deleteCropRow = async (id: string) => {
await deleteCrop(id);
};
export default function CropDictionaryPage() {
return (
<BrapiEntityPage
icon={Leaf}
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
title="Crop 作物"
description="多作物平台入口,所有业务对象均归属于特定作物"
addLabel="新增作物"
columns={[
{ key: "crop_name", label: "作物名称" },
]}
fields={[
{ key: "crop_name", label: "作物名称 (Crop Name)", type: "text", required: true, placeholder: "如 Maize、Rice、Wheat" },
]}
data={[]}
stats={[{ label: "后端 crop 表", value: "API", className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200" }]}
loadData={loadCropRows}
createRecord={createCropRow}
updateRecord={updateCropRow}
deleteRecord={deleteCropRow}
/>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, List, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { fetchListDetail, fetchPersonOptions } from "../api";
import { ListItemPanel } from "../components/ListItemPanel";
import { ListMetaDialog } from "../components/ListMetaDialog";
import { listTypeLabel, type ListRecord, type SelectOption } from "../types";
function formatTimestamp(value: string | null | undefined) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export default function ListDetailPage() {
const params = useParams<{ listDbId: string }>();
const listDbId = decodeURIComponent(params.listDbId ?? "");
const [record, setRecord] = useState<ListRecord | null>(null);
const [personOptions, setPersonOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editOpen, setEditOpen] = useState(false);
const loadDetail = useCallback(async () => {
if (!listDbId) return;
setLoading(true);
setError(null);
try {
const [people, detail] = await Promise.all([fetchPersonOptions(), fetchListDetail(listDbId)]);
setPersonOptions(people);
setRecord(detail);
} catch (event) {
setError(event instanceof Error ? event.message : "加载失败");
setRecord(null);
} finally {
setLoading(false);
}
}, [listDbId]);
useEffect(() => {
let mounted = true;
loadDetail().catch(() => undefined);
return () => {
mounted = false;
};
}, [loadDetail]);
const handleItemsChange = useCallback((items: string[]) => {
setRecord((current) => (current ? { ...current, data: items, listSize: items.length } : current));
}, []);
if (!listDbId) {
return <div className="p-6 text-sm text-destructive"> ID</div>;
}
return (
<div className="flex min-h-full flex-col">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3">
<Button variant="outline" size="icon" className="shrink-0" asChild>
<Link href="/basic-dictionary/base/list" aria-label="返回列表">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 p-2.5">
<List className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
{record?.listName || "List 详情"}
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
list_item
</p>
</div>
</div>
</div>
{record ? (
<Button variant="outline" className="gap-2" onClick={() => setEditOpen(true)}>
<Pencil className="h-4 w-4" />
</Button>
) : null}
</div>
{error ? (
<div className="mb-4 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
) : null}
<div className="mb-5 rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
{loading ? (
<div className="grid gap-3 md:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-5 w-full" />
))}
</div>
) : record ? (
<dl className="grid gap-3 text-sm md:grid-cols-2">
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400"> ID</dt>
<dd className="mt-0.5 break-all font-medium text-slate-800 dark:text-slate-100">{record.id}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400"></dt>
<dd className="mt-0.5 text-slate-800 dark:text-slate-100">{listTypeLabel(record.listType)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400">Owner</dt>
<dd className="mt-0.5 text-slate-800 dark:text-slate-100">{record.listOwnerName || record.listOwnerPersonDbId || "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400"></dt>
<dd className="mt-0.5 text-slate-800 dark:text-slate-100">{record.listSource || "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400"></dt>
<dd className="mt-0.5 text-slate-800 dark:text-slate-100">{formatTimestamp(record.dateCreated)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500 dark:text-slate-400"></dt>
<dd className="mt-0.5 text-slate-800 dark:text-slate-100">{formatTimestamp(record.dateModified)}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs text-slate-500 dark:text-slate-400"></dt>
<dd className="mt-0.5 whitespace-pre-wrap text-slate-800 dark:text-slate-100">{record.listDescription || "—"}</dd>
</div>
</dl>
) : (
<p className="text-sm text-slate-400"></p>
)}
</div>
{record ? (
<ListItemPanel
listDbId={listDbId}
items={record.data ?? []}
onItemsChange={handleItemsChange}
/>
) : null}
{record ? (
<ListMetaDialog
open={editOpen}
record={record}
personOptions={personOptions}
onOpenChange={setEditOpen}
onSaved={setRecord}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { getAuthToken } from "@/utils/token";
import type { ListDetails, ListNewRequest, ListSummary } from "@/lib/api/types.gen";
import { NONE_SELECT_VALUE, type ListRecord, type ListType, 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;
}
interface PersonResponse {
personDbId: string;
firstName?: string | null;
lastName?: string | null;
emailAddress?: string | null;
}
type ListPayload = Partial<Record<
| "listName"
| "list_name"
| "listType"
| "list_type"
| "listDescription"
| "list_description"
| "listSource"
| "list_source"
| "listOwnerName"
| "list_owner_name"
| "listOwnerPersonDbId"
| "list_owner_person_id"
| "data",
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 undefined;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const requiredListType = (value: unknown): ListType => {
const normalized = optionalText(value);
if (!normalized) throw new Error("请选择列表类型");
return normalized as ListType;
};
export const mapListRecord = (list: ListSummary | ListDetails): ListRecord => ({
...list,
id: list.listDbId || "",
list_name: list.listName ?? null,
list_description: list.listDescription ?? null,
list_source: list.listSource ?? null,
list_owner_name: list.listOwnerName ?? null,
list_owner_person_id: list.listOwnerPersonDbId ?? null,
list_type: list.listType ?? null,
date_created: list.dateCreated ?? null,
date_modified: list.dateModified ?? null,
data: "data" in list && list.data ? list.data : [],
});
const toRequestBody = (payload: ListPayload, data?: string[]): ListNewRequest => ({
listName: requiredText(payload.listName ?? payload.list_name, "请填写列表名称"),
listType: requiredListType(payload.listType ?? payload.list_type),
listDescription: optionalText(payload.listDescription ?? payload.list_description),
listSource: optionalText(payload.listSource ?? payload.list_source),
listOwnerName: optionalText(payload.listOwnerName ?? payload.list_owner_name),
listOwnerPersonDbId: optionalText(payload.listOwnerPersonDbId ?? payload.list_owner_person_id),
...(data !== undefined ? { data } : {}),
});
export async function fetchListRows(): Promise<ListRecord[]> {
const response = await request<BrapiListResponse<ListSummary>>("/brapi/v2/lists?page=0&pageSize=1000");
return response.result.data.map(mapListRecord);
}
export async function fetchListDetail(listDbId: string): Promise<ListRecord> {
const response = await request<BrapiSingleResponse<ListDetails>>(`/brapi/v2/lists/${encodeURIComponent(listDbId)}`);
return mapListRecord(response.result);
}
export async function fetchPersonOptions(): Promise<SelectOption[]> {
const response = await request<BrapiListResponse<PersonResponse>>("/brapi/v2/people?page=0&pageSize=1000");
return response.result.data.map((person) => {
const name = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
const label = name
? `${name}${person.emailAddress ? ` / ${person.emailAddress}` : ""}`
: person.personDbId;
return { value: person.personDbId, label };
});
}
export async function createListRow(payload: ListPayload): Promise<ListRecord> {
const response = await request<BrapiListResponse<ListSummary>>("/brapi/v2/lists", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
return mapListRecord(response.result.data[0]);
}
export async function updateListRow(listDbId: string, payload: ListPayload, data?: string[]): Promise<ListRecord> {
const response = await request<BrapiSingleResponse<ListDetails>>(`/brapi/v2/lists/${encodeURIComponent(listDbId)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload, data)),
});
return mapListRecord(response.result);
}
export async function appendListItems(listDbId: string, items: string[]): Promise<ListRecord> {
const response = await request<BrapiSingleResponse<ListDetails>>(`/brapi/v2/lists/${encodeURIComponent(listDbId)}/items`, {
method: "POST",
body: JSON.stringify(items),
});
return mapListRecord(response.result);
}
export async function replaceListItems(listDbId: string, items: string[]): Promise<ListRecord> {
const detail = await fetchListDetail(listDbId);
return updateListRow(
listDbId,
{
listName: detail.listName,
listType: detail.listType,
listDescription: detail.listDescription,
listSource: detail.listSource,
listOwnerName: detail.listOwnerName,
listOwnerPersonDbId: detail.listOwnerPersonDbId,
},
items,
);
}
export function normalizeNewItems(existing: string[], incoming: string[]): string[] {
const seen = new Set(existing.map((item) => item.trim()).filter(Boolean));
const duplicates: string[] = [];
const added: string[] = [];
for (const raw of incoming) {
const item = raw.trim();
if (!item) continue;
if (seen.has(item)) {
duplicates.push(item);
continue;
}
seen.add(item);
added.push(item);
}
if (duplicates.length > 0) {
throw new Error(`以下列表项已存在:${duplicates.join("、")}`);
}
if (added.length === 0) {
throw new Error("请至少填写一个有效的列表项");
}
return added;
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useCallback, useState } from "react";
import { ArrowDown, ArrowUp, Plus, Trash2, Upload } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { appendListItems, normalizeNewItems, replaceListItems } from "../api";
type ListItemPanelProps = {
listDbId: string;
items: string[];
onItemsChange: (items: string[]) => void;
};
export function ListItemPanel({ listDbId, items, onItemsChange }: ListItemPanelProps) {
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [newItem, setNewItem] = useState("");
const [importText, setImportText] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
const runAction = useCallback(async (action: () => Promise<{ data?: string[] }>) => {
setSaving(true);
setError(null);
try {
const result = await action();
onItemsChange(result.data ?? []);
} catch (event) {
setError(event instanceof Error ? event.message : "操作失败");
} finally {
setSaving(false);
}
}, [onItemsChange]);
const handleAddItem = async () => {
const added = normalizeNewItems(items, [newItem]);
await runAction(async () => appendListItems(listDbId, added));
setNewItem("");
setAddOpen(false);
};
const handleImport = async () => {
const lines = importText.split(/\r?\n/);
const added = normalizeNewItems(items, lines);
await runAction(async () => appendListItems(listDbId, added));
setImportText("");
setImportOpen(false);
};
const handleDelete = async () => {
if (deleteIndex === null) return;
const nextItems = items.filter((_, index) => index !== deleteIndex);
await runAction(async () => replaceListItems(listDbId, nextItems));
setDeleteIndex(null);
};
const moveItem = async (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= items.length) return;
const nextItems = [...items];
[nextItems[index], nextItems[target]] = [nextItems[target], nextItems[index]];
await runAction(async () => replaceListItems(listDbId, nextItems));
};
return (
<div className="rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
<div className="flex flex-col gap-3 border-b border-slate-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50"> (list_item)</h3>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400"> PUT </p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" className="gap-1.5" onClick={() => setAddOpen(true)} disabled={saving}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="gap-1.5" onClick={() => setImportOpen(true)} disabled={saving}>
<Upload className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{error ? (
<div className="mx-4 mt-3 rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900">
<TableHead className="w-12 text-xs">#</TableHead>
<TableHead className="text-xs"> (item)</TableHead>
<TableHead className="w-36 text-right text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="py-12 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={`${item}-${index}`} className="border-slate-100 dark:border-slate-800">
<TableCell className="text-xs text-slate-400">{index + 1}</TableCell>
<TableCell className="break-all text-sm text-slate-700 dark:text-slate-200">{item}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={saving || index === 0}
onClick={() => moveItem(index, -1)}
>
<ArrowUp className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={saving || index === items.length - 1}
onClick={() => moveItem(index, 1)}
>
<ArrowDown className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-slate-400 hover:text-red-500"
disabled={saving}
onClick={() => setDeleteIndex(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent title="新增列表项" defaultWidth={520} defaultHeight={320} minHeight={280}>
<DialogBody>
<p className="mb-3 text-sm text-muted-foreground"> ID </p>
<Label htmlFor="new-list-item" className="mb-1.5 block text-sm"></Label>
<Input
id="new-list-item"
value={newItem}
onChange={(event) => setNewItem(event.target.value)}
placeholder="如 germplasm ID、study ID 或备注文本"
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)}></Button>
<Button onClick={handleAddItem} disabled={saving || !newItem.trim()}>
{saving ? "保存中..." : "确认添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={importOpen} onOpenChange={setImportOpen}>
<DialogContent title="批量导入列表项" defaultWidth={560} defaultHeight={480} minHeight={360}>
<DialogBody>
<p className="mb-3 text-sm text-muted-foreground"></p>
<Textarea
value={importText}
onChange={(event) => setImportText(event.target.value)}
rows={8}
placeholder={"GERM-001\nGERM-002\nstudy-2026-spring"}
className="resize-none font-mono text-sm"
/>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setImportOpen(false)}></Button>
<Button onClick={handleImport} disabled={saving || !importText.trim()}>
{saving ? "导入中..." : "确认导入"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteIndex !== null} onOpenChange={(open) => !open && setDeleteIndex(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">
{deleteIndex !== null ? items[deleteIndex] : ""}
</span>
{" "}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={saving} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{saving ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { useEffect, useState } from "react";
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 { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
} from "@/components/common/shadcn-enhanced";
import { updateListRow } from "../api";
import { LIST_TYPE_OPTIONS, NONE_SELECT_VALUE, type ListRecord, type SelectOption } from "../types";
type ListMetaDialogProps = {
open: boolean;
record: ListRecord;
personOptions: SelectOption[];
onOpenChange: (open: boolean) => void;
onSaved: (record: ListRecord) => void;
};
export function ListMetaDialog({ open, record, personOptions, onOpenChange, onSaved }: ListMetaDialogProps) {
const [form, setForm] = useState({
listName: "",
listType: "",
listDescription: "",
listSource: "",
listOwnerName: "",
listOwnerPersonDbId: NONE_SELECT_VALUE,
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setForm({
listName: record.listName ?? "",
listType: record.listType ?? "",
listDescription: record.listDescription ?? "",
listSource: record.listSource ?? "",
listOwnerName: record.listOwnerName ?? "",
listOwnerPersonDbId: record.listOwnerPersonDbId || NONE_SELECT_VALUE,
});
setError(null);
}, [open, record]);
const updateField = (key: keyof typeof form, value: string) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const updated = await updateListRow(record.id, form, record.data ?? []);
onSaved(updated);
onOpenChange(false);
} catch (event) {
setError(event instanceof Error ? event.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent title="编辑列表基本信息" defaultWidth={720} defaultHeight={560} minHeight={420}>
<DialogBody>
<p className="mb-3 text-sm text-muted-foreground"> list list_item</p>
{error ? (
<div className="mb-3 rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="edit-listName" className="mb-1.5 block text-sm">
<span className="ml-0.5 text-red-500">*</span>
</Label>
<Input
id="edit-listName"
value={form.listName}
onChange={(event) => updateField("listName", event.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-listType" className="mb-1.5 block text-sm">
<span className="ml-0.5 text-red-500">*</span>
</Label>
<Select value={form.listType} onValueChange={(value) => updateField("listType", value)}>
<SelectTrigger id="edit-listType">
<SelectValue placeholder="请选择列表类型" />
</SelectTrigger>
<SelectContent position="item-aligned">
{LIST_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="edit-listSource" className="mb-1.5 block text-sm"></Label>
<Input
id="edit-listSource"
value={form.listSource}
onChange={(event) => updateField("listSource", event.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-listOwnerName" className="mb-1.5 block text-sm">Owner </Label>
<Input
id="edit-listOwnerName"
value={form.listOwnerName}
onChange={(event) => updateField("listOwnerName", event.target.value)}
/>
</div>
<div>
<Label htmlFor="edit-listOwnerPersonDbId" className="mb-1.5 block text-sm">Owner </Label>
<Select
value={form.listOwnerPersonDbId}
onValueChange={(value) => updateField("listOwnerPersonDbId", value)}
>
<SelectTrigger id="edit-listOwnerPersonDbId">
<SelectValue placeholder="不绑定人员" />
</SelectTrigger>
<SelectContent position="item-aligned">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{personOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="md:col-span-2">
<Label htmlFor="edit-listDescription" className="mb-1.5 block text-sm"></Label>
<Textarea
id="edit-listDescription"
value={form.listDescription}
onChange={(event) => updateField("listDescription", event.target.value)}
rows={3}
className="resize-none"
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,24 @@
import type { BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { LIST_TYPE_OPTIONS, NONE_SELECT_VALUE, type SelectOption } from "../types";
export function buildListFormFields(personOptions: SelectOption[]): BrapiFormField[] {
return [
{ key: "listName", label: "列表名称", type: "text", required: true, placeholder: "如 2026 核心种质清单" },
{
key: "listType",
label: "列表类型",
type: "select",
required: true,
options: LIST_TYPE_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
},
{ key: "listDescription", label: "列表描述", type: "textarea", colSpan: 2, placeholder: "用途、范围或备注" },
{ key: "listSource", label: "列表来源", type: "text", placeholder: "如 田间调查、导入批次" },
{ key: "listOwnerName", label: "Owner 名称", type: "text", placeholder: "可选,选择人员后可自动带出" },
{
key: "listOwnerPersonDbId",
label: "Owner 人员",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不绑定人员" }, ...personOptions],
},
];
}

View File

@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { List } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { Button } from "@/components/ui/button";
import {
createListRow,
fetchListDetail,
fetchListRows,
fetchPersonOptions,
updateListRow,
} from "./api";
import { buildListFormFields } from "./components/listFormFields";
import { listTypeLabel, type SelectOption } from "./types";
export default function ListDictionaryPage() {
const [personOptions, setPersonOptions] = useState<SelectOption[]>([]);
const loadRows = useCallback(async () => {
const [people, rows] = await Promise.all([fetchPersonOptions(), fetchListRows()]);
setPersonOptions(people);
return rows as unknown as Record<string, unknown>[];
}, []);
const fields = useMemo(() => buildListFormFields(personOptions), [personOptions]);
const createRecord = useCallback(
async (payload: Record<string, unknown>) => createListRow(payload) as unknown as Record<string, unknown>,
[],
);
const updateRecord = useCallback(
async (id: string, payload: Record<string, unknown>) => updateListRow(id, payload) as unknown as Record<string, unknown>,
[],
);
const fetchRecord = useCallback(
async (id: string) => fetchListDetail(id) as unknown as Record<string, unknown>,
[],
);
return (
<BrapiEntityPage
icon={List}
iconBg="bg-gradient-to-br from-violet-500 to-purple-600"
title="List 通用列表"
description="维护 BrAPI 通用分组列表,可在详情页管理 list_item 明细"
addLabel="新增列表"
useEnhancedDialog
columns={[
{ key: "listName", label: "列表名称" },
{
key: "listType",
label: "类型",
render: (value) => listTypeLabel(value),
},
{ key: "listSize", label: "项数" },
{ key: "listOwnerName", label: "Owner" },
{ key: "listSource", label: "来源" },
{
key: "id",
label: "明细",
render: (_value, row) => {
const id = String(row.id ?? row.listDbId ?? "");
if (!id) return <span className="text-slate-300"></span>;
return (
<Button variant="link" className="h-auto p-0 text-violet-600 dark:text-violet-300" asChild>
<Link href={`/basic-dictionary/base/list/${encodeURIComponent(id)}`}></Link>
</Button>
);
},
},
]}
fields={fields}
data={[]}
stats={[{ label: "/brapi/v2/lists", value: "BrAPI", className: "bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200" }]}
loadData={loadRows}
fetchRecord={fetchRecord}
createRecord={createRecord}
updateRecord={updateRecord}
/>
);
}

View File

@@ -0,0 +1,40 @@
import type { ListDetails, ListNewRequest } from "@/lib/api/types.gen";
export const NONE_SELECT_VALUE = "__none__";
export type ListType = NonNullable<ListNewRequest["listType"]>;
export type ListRecord = ListDetails & {
id: string;
list_name?: string | null;
list_description?: string | null;
list_source?: string | null;
list_owner_name?: string | null;
list_owner_person_id?: string | null;
list_type?: ListType | null;
date_created?: string | null;
date_modified?: string | null;
};
export type SelectOption = {
value: string;
label: string;
};
export const LIST_TYPE_OPTIONS: Array<{ value: ListType; label: string }> = [
{ value: "germplasm", label: "种质 (germplasm)" },
{ value: "markers", label: "标记 (markers)" },
{ value: "programs", label: "项目 (programs)" },
{ value: "trials", label: "试验 (trials)" },
{ value: "studies", label: "研究 (studies)" },
{ value: "observationUnits", label: "观测单元 (observationUnits)" },
{ value: "observations", label: "观测值 (observations)" },
{ value: "observationVariables", label: "观测变量 (observationVariables)" },
{ value: "samples", label: "样品 (samples)" },
];
export function listTypeLabel(type: unknown): string {
const normalized = String(type ?? "").trim();
const matched = LIST_TYPE_OPTIONS.find((option) => option.value === normalized);
return matched?.label ?? (normalized || "—");
}

View File

@@ -0,0 +1,111 @@
import { getAuthToken } from "@/utils/token";
import type { LocationRecord } from "@/services/dictionaryService";
interface BrapiListResponse<T> {
result: { data: T[] };
}
interface BrapiSingleResponse<T> {
result: T;
}
type LocationPayload = Partial<Omit<LocationRecord, "id" | "locationDbId" | "parentLocationName">>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8081";
};
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 emptyToNull = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : null;
};
export const mapLocation = (location: Omit<LocationRecord, "id">): LocationRecord => ({
...location,
id: location.locationDbId,
});
const toRequestBody = (payload: LocationPayload) => ({
locationName: emptyToNull(payload.locationName),
locationType: emptyToNull(payload.locationType),
countryCode: emptyToNull(payload.countryCode),
countryName: emptyToNull(payload.countryName),
abbreviation: emptyToNull(payload.abbreviation),
environmentType: emptyToNull(payload.environmentType),
exposure: emptyToNull(payload.exposure),
instituteName: emptyToNull(payload.instituteName),
instituteAddress: emptyToNull(payload.instituteAddress),
coordinateDescription: emptyToNull(payload.coordinateDescription),
coordinateUncertainty: emptyToNull(payload.coordinateUncertainty),
documentationURL: emptyToNull(payload.documentationURL),
parentLocationDbId: emptyToNull(payload.parentLocationDbId),
siteStatus: emptyToNull(payload.siteStatus),
slope: emptyToNull(payload.slope),
topography: emptyToNull(payload.topography),
});
export async function fetchLocationRows(page = 0, pageSize = 1000): Promise<LocationRecord[]> {
const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>(
`/brapi/v2/locations?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`,
);
return response.result.data.map(mapLocation);
}
export async function fetchLocationDetail(locationDbId: string): Promise<LocationRecord> {
const response = await request<BrapiSingleResponse<Omit<LocationRecord, "id">>>(
`/brapi/v2/locations/${encodeURIComponent(locationDbId)}`,
);
return mapLocation(response.result);
}
export async function createLocationRow(payload: LocationPayload): Promise<LocationRecord> {
if (!emptyToNull(payload.locationName)) {
throw new Error("请填写地点名称");
}
const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>("/brapi/v2/locations", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
const location = response.result.data[0];
if (!location) {
throw new Error("新增地点失败:后端未返回数据");
}
return mapLocation(location);
}
export async function updateLocationRow(locationDbId: string, payload: LocationPayload): Promise<LocationRecord> {
const response = await request<BrapiSingleResponse<Omit<LocationRecord, "id">>>(
`/brapi/v2/locations/${encodeURIComponent(locationDbId)}`,
{
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
},
);
return mapLocation(response.result);
}
export async function deleteLocationRow(locationDbId: string): Promise<void> {
await request<BrapiSingleResponse<Omit<LocationRecord, "id">>>(
`/brapi/v2/locations/${encodeURIComponent(locationDbId)}`,
{ method: "DELETE" },
);
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useCallback } from "react";
import { MapPin } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { createLocationRow, fetchLocationDetail, fetchLocationRows, deleteLocationRow, updateLocationRow } from "./api";
const locationFields = [
{ key: "locationName", label: "地点名称", type: "text" as const, required: true, placeholder: "如 Beijing Research Station" },
{ key: "locationType", label: "地点类型", type: "text" as const, placeholder: "如 试验站 / 温室" },
{ key: "countryCode", label: "国家代码", type: "text" as const, placeholder: "CHN" },
{ key: "countryName", label: "国家名称", type: "text" as const, placeholder: "China" },
{ key: "abbreviation", label: "缩写", type: "text" as const, placeholder: "BRS" },
{ key: "environmentType", label: "环境类型", type: "text" as const, placeholder: "Field / Greenhouse" },
{ key: "exposure", label: "光照/暴露", type: "text" as const },
{ key: "instituteName", label: "机构名称", type: "text" as const },
{ key: "instituteAddress", label: "机构地址", type: "text" as const, colSpan: 2 as const },
{ key: "parentLocationDbId", label: "父级地点 ID", type: "text" as const },
{ key: "parentLocationName", label: "父级地点名称", type: "text" as const },
{ key: "siteStatus", label: "地点状态", type: "text" as const, placeholder: "ACTIVE" },
{ key: "slope", label: "坡度", type: "text" as const },
{ key: "topography", label: "地形", type: "text" as const },
{ key: "coordinateDescription", label: "坐标描述", type: "text" as const, colSpan: 2 as const },
{ key: "coordinateUncertainty", label: "坐标不确定性", type: "text" as const },
{ key: "documentationURL", label: "文档链接", type: "text" as const, colSpan: 2 as const },
];
export default function LocationDictionaryPage() {
const loadLocationRows = useCallback(async () => {
const rows = await fetchLocationRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadLocationRecord = useCallback(async (locationDbId: string) => {
const row = await fetchLocationDetail(locationDbId);
return row as unknown as Record<string, unknown>;
}, []);
const updateLocationRecord = useCallback(async (id: string, payload: Record<string, unknown>) => {
const row = await updateLocationRow(id, payload);
return row as unknown as Record<string, unknown>;
}, []);
const createLocationRecord = useCallback(async (payload: Record<string, unknown>) => {
const row = await createLocationRow(payload);
return row as unknown as Record<string, unknown>;
}, []);
return (
<BrapiEntityPage
icon={MapPin}
iconBg="bg-gradient-to-br from-green-500 to-teal-600"
title="Location 地点"
description="维护试验站、田块、温室、实验室等物理地点"
addLabel="新增地点"
columns={[
{ key: "locationDbId", label: "Location DbId" },
{ key: "locationName", label: "地点名称" },
{ key: "locationType", label: "地点类型" },
{ key: "countryCode", label: "国家代码" },
{ key: "instituteName", label: "机构名称" },
{ key: "siteStatus", label: "地点状态" },
]}
fields={locationFields}
data={[]}
stats={[{ label: "/brapi/v2/locations", value: "BrAPI", className: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200" }]}
loadData={loadLocationRows}
fetchRecord={loadLocationRecord}
createRecord={createLocationRecord}
updateRecord={updateLocationRecord}
deleteRecord={deleteLocationRow}
/>
);
}

View File

@@ -0,0 +1,51 @@
import { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import type { PersonFormValues } from "./types";
interface PersonFormProps {
values: PersonFormValues;
submitting: boolean;
onChange: (key: keyof PersonFormValues, value: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}
export function PersonForm({ values, submitting, onChange, onSubmit }: PersonFormProps) {
return (
<form className="card-agriculture grid gap-5 p-5 md:p-6" onSubmit={onSubmit}>
<div className="flex items-start gap-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-secondary text-sm font-black text-secondary-foreground">01</span>
<div>
<h2 className="text-xl font-black tracking-tight text-card-foreground"></h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">便</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm font-black text-card-foreground">
<span> First Name</span>
<input className="input-agriculture" value={values.firstName} onChange={(event) => onChange("firstName", event.target.value)} placeholder="例如Li" />
</label>
<label className="grid gap-2 text-sm font-black text-card-foreground">
<span> Last Name</span>
<input className="input-agriculture" value={values.lastName} onChange={(event) => onChange("lastName", event.target.value)} placeholder="例如Wei" />
</label>
<label className="grid gap-2 text-sm font-black text-card-foreground">
<span> Email</span>
<input className="input-agriculture" value={values.emailAddress} onChange={(event) => onChange("emailAddress", event.target.value)} placeholder="name@example.com" />
</label>
<label className="grid gap-2 text-sm font-black text-card-foreground">
<span> Phone</span>
<input className="input-agriculture" value={values.phoneNumber} onChange={(event) => onChange("phoneNumber", event.target.value)} placeholder="联系电话" />
</label>
<label className="grid gap-2 text-sm font-black text-card-foreground md:col-span-2">
<span> Institute</span>
<input className="input-agriculture" value={values.instituteName} onChange={(event) => onChange("instituteName", event.target.value)} placeholder="单位 / 实验室 / 项目组" />
</label>
</div>
<Button variant="agriculture" size="lg" type="submit" disabled={submitting}>
{submitting ? "正在写入 person 表..." : "保存人员"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,17 @@
interface PersonGuideProps {
error: string | null;
}
export function PersonGuide({ error }: PersonGuideProps) {
return (
<aside className="card-agriculture p-5 md:p-6">
<h3 className="text-lg font-black text-card-foreground"></h3>
<div className="mt-5 grid gap-4 text-sm font-bold text-muted-foreground">
<div className="flex items-center gap-3 rounded-2xl bg-muted px-4 py-3"><span className="h-3 w-3 rounded-full bg-success shadow-[0_0_0_5px_rgba(16,185,129,0.14)]" /> Program</div>
<div className="flex items-center gap-3 rounded-2xl bg-muted px-4 py-3"><span className="h-3 w-3 rounded-full bg-warning shadow-[0_0_0_5px_rgba(132,204,22,0.14)]" /> Trial / Study</div>
<div className="flex items-center gap-3 rounded-2xl bg-muted px-4 py-3"><span className="h-3 w-3 rounded-full bg-info shadow-[0_0_0_5px_rgba(8,145,178,0.14)]" /></div>
</div>
{error ? <div className="mt-4 rounded-2xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm font-bold text-destructive">{error}</div> : null}
</aside>
);
}

View File

@@ -0,0 +1,48 @@
import { Button } from "@/components/ui/button";
import type { PersonFormValues } from "./types";
const quickPeople = [
{ firstName: "Li", lastName: "Wei", instituteName: "Crop Research Center" },
{ firstName: "Anna", lastName: "Chen", instituteName: "Field Trial Team" },
{ firstName: "Ming", lastName: "Zhao", instituteName: "Breeding Lab" },
];
interface PersonHeaderProps {
total: number;
namedCount: number;
onFillExample: (person: Partial<PersonFormValues>) => void;
}
export function PersonHeader({ total, namedCount, onFillExample }: PersonHeaderProps) {
return (
<div className="relative grid gap-6 overflow-hidden rounded-[34px] bg-[radial-gradient(circle_at_16%_18%,rgba(254,240,138,0.55),transparent_26%),linear-gradient(135deg,#083344,var(--primary)_50%,#365314)] p-6 text-primary-foreground shadow-agriculture lg:grid-cols-[1fr_260px] lg:p-8">
<div className="pointer-events-none absolute -right-12 top-10 h-48 w-48 rounded-full border border-primary-foreground/20 bg-primary-foreground/10" />
<div className="relative z-10 max-w-3xl">
<div className="mb-4 inline-flex rounded-full border border-primary-foreground/25 bg-primary-foreground/15 px-3 py-1 text-xs font-black uppercase tracking-[0.2em]">
/ Person
</div>
<h1 className="text-3xl font-black tracking-tight md:text-5xl"></h1>
<p className="mt-4 max-w-2xl text-sm leading-7 text-primary-foreground/90 md:text-base">
BrAPI <strong className="font-black">person</strong> Program Trial Study
</p>
<div className="mt-5 flex flex-wrap gap-2">
{quickPeople.map((person) => (
<Button key={`${person.firstName}-${person.lastName}`} type="button" variant="outline" className="rounded-full border-primary-foreground/20 bg-primary-foreground/15 text-primary-foreground hover:bg-primary-foreground/25" onClick={() => onFillExample(person)}>
{person.firstName} {person.lastName}
</Button>
))}
</div>
</div>
<div className="relative z-10 grid gap-3">
<div className="rounded-3xl border border-primary-foreground/20 bg-primary-foreground/15 p-5 shadow-2xl shadow-emerald-950/15 backdrop-blur">
<span className="text-sm font-black text-primary-foreground/85"></span>
<strong className="mt-3 block text-5xl font-black leading-none">{total}</strong>
</div>
<div className="rounded-3xl border border-primary-foreground/20 bg-primary-foreground/15 p-5 shadow-2xl shadow-emerald-950/15 backdrop-blur">
<span className="text-sm font-black text-primary-foreground/85"></span>
<strong className="mt-3 block text-5xl font-black leading-none">{namedCount}</strong>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Button } from "@/components/ui/button";
import type { PersonItem } from "./types";
interface PersonTableProps {
rows: PersonItem[];
loading: boolean;
onRefresh: () => void;
}
export function PersonTable({ rows, loading, onRefresh }: PersonTableProps) {
return (
<div className="card-agriculture overflow-hidden">
<div className="flex flex-col gap-3 border-b border-border p-5 md:flex-row md:items-center md:justify-between md:p-6">
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-accent text-sm font-black text-accent-foreground">02</span>
<h2 className="text-xl font-black tracking-tight text-card-foreground">Person </h2>
</div>
<Button variant="agriculture" type="button" onClick={onRefresh}>{loading ? "刷新中..." : "刷新数据"}</Button>
</div>
{loading ? (
<div className="p-8 text-center text-sm font-bold text-muted-foreground"> person ...</div>
) : rows.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead className="bg-muted text-xs uppercase tracking-[0.16em] text-muted-foreground">
<tr>
<th className="px-5 py-4 font-black">ID</th>
<th className="px-5 py-4 font-black"></th>
<th className="px-5 py-4 font-black"></th>
<th className="px-5 py-4 font-black"></th>
<th className="px-5 py-4 font-black"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => (
<tr key={row.id} className="transition hover:bg-muted/70">
<td className="px-5 py-4 text-muted-foreground">{row.personDbId || row.id}</td>
<td className="px-5 py-4"><strong className="font-black text-card-foreground">{[row.firstName, row.lastName].filter(Boolean).join(" ") || "-"}</strong></td>
<td className="px-5 py-4 text-muted-foreground">{row.emailAddress || "-"}</td>
<td className="px-5 py-4 text-muted-foreground">{row.phoneNumber || "-"}</td>
<td className="px-5 py-4 text-muted-foreground">{row.instituteName || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="p-8 text-center text-sm font-bold text-muted-foreground"></div>
)}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { createPerson, listPersons } from "@/services/dictionaryService";
import type { PersonFormValues, PersonItem } from "./types";
export async function fetchPersonList(): Promise<PersonItem[]> {
return listPersons();
}
export async function savePerson(values: PersonFormValues): Promise<PersonItem> {
return createPerson({
firstName: values.firstName.trim() || null,
lastName: values.lastName.trim() || null,
emailAddress: values.emailAddress.trim() || null,
phoneNumber: values.phoneNumber.trim() || null,
instituteName: values.instituteName.trim() || null,
});
}

View File

@@ -0,0 +1,31 @@
import { emptyPersonForm, type PersonPageState } from "./types";
export type PersonPageAction =
| { type: "SET_ROWS"; payload: PersonPageState["rows"] }
| { type: "SET_FORM_FIELD"; payload: { key: keyof PersonPageState["form"]; value: string } }
| { type: "SET_FORM"; payload: Partial<PersonPageState["form"]> }
| { type: "RESET_FORM" }
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SUBMITTING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null };
export function personReducer(state: PersonPageState, action: PersonPageAction): PersonPageState {
switch (action.type) {
case "SET_ROWS":
return { ...state, rows: action.payload };
case "SET_FORM_FIELD":
return { ...state, form: { ...state.form, [action.payload.key]: action.payload.value } };
case "SET_FORM":
return { ...state, form: { ...state.form, ...action.payload } };
case "RESET_FORM":
return { ...state, form: emptyPersonForm };
case "SET_LOADING":
return { ...state, loading: action.payload };
case "SET_SUBMITTING":
return { ...state, submitting: action.payload };
case "SET_ERROR":
return { ...state, error: action.payload };
default:
return state;
}
}

View File

@@ -0,0 +1,35 @@
import type { PersonRecord } from "@/services/dictionaryService";
export type PersonItem = PersonRecord;
export interface PersonFormValues {
firstName: string;
lastName: string;
emailAddress: string;
phoneNumber: string;
instituteName: string;
}
export interface PersonPageState {
rows: PersonItem[];
form: PersonFormValues;
loading: boolean;
submitting: boolean;
error: string | null;
}
export const emptyPersonForm: PersonFormValues = {
firstName: "",
lastName: "",
emailAddress: "",
phoneNumber: "",
instituteName: "",
};
export const initialPersonState: PersonPageState = {
rows: [],
form: emptyPersonForm,
loading: true,
submitting: false,
error: null,
};

View File

@@ -0,0 +1,68 @@
"use client";
import { Users } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { createPerson, deletePerson, listPersons, updatePerson } from "@/services/dictionaryService";
const loadPersonRows = async () => listPersons() as unknown as Record<string, unknown>[];
const emptyToNull = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : null;
};
const normalizePersonPayload = (payload: Record<string, unknown>) => ({
firstName: emptyToNull(payload.firstName),
middleName: emptyToNull(payload.middleName),
lastName: emptyToNull(payload.lastName),
emailAddress: emptyToNull(payload.emailAddress),
phoneNumber: emptyToNull(payload.phoneNumber),
instituteName: emptyToNull(payload.instituteName),
mailingAddress: emptyToNull(payload.mailingAddress),
description: emptyToNull(payload.description),
userID: emptyToNull(payload.userID),
});
const createPersonRow = async (payload: Record<string, unknown>) =>
createPerson(normalizePersonPayload(payload)) as unknown as Record<string, unknown>;
const updatePersonRow = async (id: string, payload: Record<string, unknown>) =>
updatePerson(id, normalizePersonPayload(payload)) as unknown as Record<string, unknown>;
export default function PersonDictionaryPage() {
return (
<BrapiEntityPage
icon={Users}
iconBg="bg-gradient-to-br from-blue-500 to-indigo-600"
title="Person 人员"
description="维护项目负责人、联系人、录入人员等人员档案"
addLabel="新增人员"
columns={[
{ key: "personDbId", label: "Person DbId" },
{ key: "firstName", label: "名" },
{ key: "lastName", label: "姓" },
{ key: "emailAddress", label: "邮箱" },
{ key: "phoneNumber", label: "电话" },
{ key: "instituteName", label: "机构" },
{ key: "userID", label: "User ID" },
]}
fields={[
{ key: "firstName", label: "名 (First Name)", type: "text", required: true },
{ key: "middleName", label: "中间名 (Middle Name)", type: "text" },
{ key: "lastName", label: "姓 (Last Name)", type: "text", required: true },
{ key: "emailAddress", label: "邮箱", type: "text", required: true, placeholder: "xxx@example.com" },
{ key: "phoneNumber", label: "电话", type: "text" },
{ key: "userID", label: "User ID", type: "text", placeholder: "BrAPI userID可选" },
{ key: "instituteName", label: "所属机构", type: "text", placeholder: "如 中国农业科学院", colSpan: 2 },
{ key: "mailingAddress", label: "通讯地址", type: "text", colSpan: 2 },
{ key: "description", label: "说明", type: "textarea", placeholder: "人员角色、职责或备注" },
]}
data={[]}
stats={[{ label: "/brapi/v2/people", value: "BrAPI", className: "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-200" }]}
loadData={loadPersonRows}
createRecord={createPersonRow}
updateRecord={updatePersonRow}
deleteRecord={deletePerson}
/>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { Calendar } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { createSeason, deleteSeason, listSeasons, updateSeason } from "@/services/dictionaryService";
const loadSeasonRows = async () => listSeasons() as unknown as Record<string, unknown>[];
const normalizeSeasonPayload = (payload: Record<string, unknown>) => {
const seasonName = String(payload.seasonName ?? "").trim();
const yearValue = String(payload.year ?? "").trim();
return {
seasonName: seasonName || null,
year: yearValue ? Number(yearValue) : null,
};
};
const createSeasonRow = async (payload: Record<string, unknown>) =>
createSeason(normalizeSeasonPayload(payload)) as unknown as Record<string, unknown>;
const updateSeasonRow = async (id: string, payload: Record<string, unknown>) =>
updateSeason(id, normalizeSeasonPayload(payload)) as unknown as Record<string, unknown>;
export default function Page() {
return (
<BrapiEntityPage
icon={Calendar}
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
title="Season 季节"
description="维护 BrAPI 标准 season 表,用于 Study 的年份、季节和试验周期关联"
addLabel="新增季节"
columns={[
{ key: "seasonDbId", label: "Season DbId" },
{ key: "seasonName", label: "季节名称" },
{ key: "year", label: "年份" },
]}
fields={[
{ key: "seasonName", label: "季节名称 (Season Name)", type: "text", required: true, placeholder: "Spring / Summer / Rainy / 2026 Spring" },
{ key: "year", label: "年份", type: "year", required: true, placeholder: "请选择年份" },
]}
data={[]}
stats={[{ label: "/brapi/v2/seasons", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]}
loadData={loadSeasonRows}
createRecord={createSeasonRow}
updateRecord={updateSeasonRow}
deleteRecord={deleteSeason}
/>
);
}

View File

@@ -0,0 +1,111 @@
import { getAuthToken } from "@/utils/token";
import type { OntologyRecord } from "@/services/dictionaryService";
interface BrapiListResponse<T> {
result: { data: T[] };
}
interface BrapiSingleResponse<T> {
result: T;
}
type OntologyApiRecord = Omit<OntologyRecord, "id">;
type OntologyPayload = Partial<
Pick<OntologyRecord, "ontologyName" | "version" | "documentationURL" | "authors" | "copyright" | "licence" | "description">
>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8081";
};
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 emptyToNull = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : null;
};
export const mapOntology = (ontology: OntologyApiRecord): OntologyRecord => ({
...ontology,
id: ontology.ontologyDbId,
ontologyName: ontology.ontologyName ?? ontology.ontology_name ?? null,
ontology_name: ontology.ontologyName ?? ontology.ontology_name ?? null,
documentationURL: ontology.documentationURL ?? ontology.documentationurl ?? null,
documentationurl: ontology.documentationURL ?? ontology.documentationurl ?? null,
});
const toRequestBody = (payload: Record<string, unknown>): OntologyPayload => ({
ontologyName: emptyToNull(payload.ontologyName ?? payload.ontology_name),
version: emptyToNull(payload.version),
documentationURL: emptyToNull(payload.documentationURL ?? payload.documentationurl),
authors: emptyToNull(payload.authors),
copyright: emptyToNull(payload.copyright),
licence: emptyToNull(payload.licence),
description: emptyToNull(payload.description),
});
export async function fetchOntologyRows(page = 0, pageSize = 1000): Promise<OntologyRecord[]> {
const response = await request<BrapiListResponse<OntologyApiRecord>>(
`/brapi/v2/ontologies?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`,
);
return response.result.data.map(mapOntology);
}
export async function fetchOntologyDetail(ontologyDbId: string): Promise<OntologyRecord> {
const response = await request<BrapiSingleResponse<OntologyApiRecord>>(
`/brapi/v2/ontologies/${encodeURIComponent(ontologyDbId)}`,
);
return mapOntology(response.result);
}
export async function createOntologyRow(payload: Record<string, unknown>): Promise<OntologyRecord> {
const body = toRequestBody(payload);
if (!body.ontologyName) {
throw new Error("请填写本体名称");
}
const response = await request<BrapiListResponse<OntologyApiRecord>>("/brapi/v2/ontologies", {
method: "POST",
body: JSON.stringify([body]),
});
const ontology = response.result.data[0];
if (!ontology) {
throw new Error("新增本体失败:后端未返回数据");
}
return mapOntology(ontology);
}
export async function updateOntologyRow(ontologyDbId: string, payload: Record<string, unknown>): Promise<OntologyRecord> {
const response = await request<BrapiSingleResponse<OntologyApiRecord>>(
`/brapi/v2/ontologies/${encodeURIComponent(ontologyDbId)}`,
{
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
},
);
return mapOntology(response.result);
}
export async function deleteOntologyRow(ontologyDbId: string): Promise<void> {
await request<BrapiSingleResponse<OntologyApiRecord>>(
`/brapi/v2/ontologies/${encodeURIComponent(ontologyDbId)}`,
{ method: "DELETE" },
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useCallback } from "react";
import { Network } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import {
createOntologyRow,
deleteOntologyRow,
fetchOntologyDetail,
fetchOntologyRows,
updateOntologyRow,
} from "./api";
const ontologyFields = [
{ key: "ontologyName", label: "本体名称 (Ontology Name)", type: "text" as const, required: true, placeholder: "如 Crop Ontology - Maize" },
{ key: "version", label: "版本号", type: "text" as const, placeholder: "3.2.0" },
{ key: "authors", label: "作者", type: "text" as const, placeholder: "维护组织或作者" },
{ key: "licence", label: "许可", type: "text" as const, placeholder: "如 CC BY 4.0" },
{ key: "copyright", label: "版权", type: "text" as const },
{ key: "documentationURL", label: "文档地址", type: "text" as const, placeholder: "https://cropontology.org/ontology/CO_322", colSpan: 2 as const },
{ key: "description", label: "说明", type: "textarea" as const, placeholder: "本体用途、作物范围或维护说明", colSpan: 2 as const },
];
export default function OntologyDictionaryPage() {
const loadOntologyRows = useCallback(async () => {
const rows = await fetchOntologyRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadOntologyRecord = useCallback(async (ontologyDbId: string) => {
const row = await fetchOntologyDetail(ontologyDbId);
return row as unknown as Record<string, unknown>;
}, []);
const createOntologyRecord = useCallback(async (payload: Record<string, unknown>) => {
const row = await createOntologyRow(payload);
return row as unknown as Record<string, unknown>;
}, []);
const updateOntologyRecord = useCallback(async (id: string, payload: Record<string, unknown>) => {
const row = await updateOntologyRow(id, payload);
return row as unknown as Record<string, unknown>;
}, []);
return (
<BrapiEntityPage
icon={Network}
iconBg="bg-gradient-to-br from-amber-600 to-orange-700"
title="Ontology 本体"
description="维护 Crop Ontology 或企业内部性状本体,作为 Trait / Method / Scale 的来源标准"
addLabel="新增本体"
columns={[
{ key: "ontologyDbId", label: "Ontology DbId" },
{ key: "ontologyName", label: "本体名称" },
{ key: "version", label: "版本" },
{ key: "documentationURL", label: "文档地址" },
{ key: "authors", label: "作者" },
{ key: "licence", label: "许可" },
]}
fields={ontologyFields}
data={[]}
stats={[{ label: "/brapi/v2/ontologies", value: "BrAPI", className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200" }]}
loadData={loadOntologyRows}
fetchRecord={loadOntologyRecord}
createRecord={createOntologyRecord}
updateRecord={updateOntologyRecord}
deleteRecord={deleteOntologyRow}
/>
);
}

View File

@@ -0,0 +1,343 @@
import { getAuthToken } from "@/utils/token";
import type { OntologyOption, TraitMethodScaleKind, TraitMethodScaleRecord } from "./types";
import { NONE_ONTOLOGY_VALUE } 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;
}
interface OntologyResponse {
ontologyDbId: string;
ontologyName: string | null;
ontology_name: string | null;
version: string | null;
}
interface TraitResponse {
traitDbId: string;
traitName: string | null;
traitClass: string | null;
traitDescription: string | null;
traitPUI: string | null;
ontologyDbId: string | null;
ontologyName: string | null;
}
interface MethodResponse {
methodDbId: string;
methodName: string | null;
name: string | null;
methodClass: string | null;
description: string | null;
formula: string | null;
reference: string | null;
methodPUI: string | null;
ontologyDbId: string | null;
ontologyName: string | null;
}
interface ScaleResponse {
scaleDbId: string;
scaleName: string | null;
dataType: number | null;
decimalPlaces: number | null;
units: string | null;
scalePUI: string | null;
validValueMin: string | null;
validValueMax: string | null;
ontologyDbId: string | null;
ontologyName: string | null;
}
type TraitMethodScalePayload = Partial<Record<
"kind" | "db_id" | "name" | "ontology_id" | "description" | "units" | "class" | "pui" | "formula" | "reference" | "data_type" | "decimal_places" | "valid_value_min" | "valid_value_max",
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_ONTOLOGY_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 compositeId = (kind: TraitMethodScaleKind, dbId: string) => `${kind}:${dbId}`;
const parseCompositeId = (id: string): { kind: TraitMethodScaleKind; dbId: string } => {
const [kind, ...rest] = id.split(":");
if (kind !== "Trait" && kind !== "Method" && kind !== "Scale") {
throw new Error("标准项类型无效");
}
return { kind, dbId: rest.join(":") };
};
const mapTrait = (trait: TraitResponse): TraitMethodScaleRecord => ({
id: compositeId("Trait", trait.traitDbId),
db_id: trait.traitDbId,
kind: "Trait",
name: trait.traitName,
ontology_id: trait.ontologyDbId,
ontology_name: trait.ontologyName,
description: trait.traitDescription,
units: null,
class: trait.traitClass,
pui: trait.traitPUI,
formula: null,
reference: null,
data_type: null,
decimal_places: null,
valid_value_min: null,
valid_value_max: null,
});
const mapMethod = (method: MethodResponse): TraitMethodScaleRecord => ({
id: compositeId("Method", method.methodDbId),
db_id: method.methodDbId,
kind: "Method",
name: method.methodName || method.name,
ontology_id: method.ontologyDbId,
ontology_name: method.ontologyName,
description: method.description,
units: null,
class: method.methodClass,
pui: method.methodPUI,
formula: method.formula,
reference: method.reference,
data_type: null,
decimal_places: null,
valid_value_min: null,
valid_value_max: null,
});
const mapScale = (scale: ScaleResponse): TraitMethodScaleRecord => ({
id: compositeId("Scale", scale.scaleDbId),
db_id: scale.scaleDbId,
kind: "Scale",
name: scale.scaleName,
ontology_id: scale.ontologyDbId,
ontology_name: scale.ontologyName,
description: null,
units: scale.units,
class: null,
pui: scale.scalePUI,
formula: null,
reference: null,
data_type: scale.dataType,
decimal_places: scale.decimalPlaces,
valid_value_min: scale.validValueMin,
valid_value_max: scale.validValueMax,
});
const payloadKind = (payload: TraitMethodScalePayload): TraitMethodScaleKind => {
const kind = String(payload.kind ?? "");
if (kind === "Trait" || kind === "Method" || kind === "Scale") return kind;
throw new Error("请选择类型");
};
const commonName = (payload: TraitMethodScalePayload) => {
const name = optionalText(payload.name);
if (!name) throw new Error("请填写名称");
return name;
};
export async function fetchOntologyOptions(): Promise<OntologyOption[]> {
const response = await request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=1000");
return response.result.data.map((ontology) => ({
value: ontology.ontologyDbId,
label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`,
}));
}
export async function fetchTraitMethodScaleRows(kind?: TraitMethodScaleKind): Promise<TraitMethodScaleRecord[]> {
if (kind === "Trait") {
const response = await request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000");
return response.result.data.map(mapTrait);
}
if (kind === "Method") {
const response = await request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000");
return response.result.data.map(mapMethod);
}
if (kind === "Scale") {
const response = await request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000");
return response.result.data.map(mapScale);
}
const [traits, methods, scales] = await Promise.all([
request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"),
request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"),
request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"),
]);
return [
...traits.result.data.map(mapTrait),
...methods.result.data.map(mapMethod),
...scales.result.data.map(mapScale),
].sort((left, right) => `${left.kind}-${left.name || left.db_id}`.localeCompare(`${right.kind}-${right.name || right.db_id}`));
}
export async function createTraitMethodScaleRow(payload: TraitMethodScalePayload): Promise<TraitMethodScaleRecord> {
const kind = payloadKind(payload);
const name = commonName(payload);
const ontologyDbId = optionalText(payload.ontology_id);
if (kind === "Trait") {
const response = await request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits", {
method: "POST",
body: JSON.stringify([{
traitName: name,
traitClass: optionalText(payload.class),
traitDescription: optionalText(payload.description),
traitPUI: optionalText(payload.pui),
ontologyDbId,
}]),
});
return mapTrait(response.result.data[0]);
}
if (kind === "Method") {
const response = await request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods", {
method: "POST",
body: JSON.stringify([{
methodName: name,
methodClass: optionalText(payload.class),
description: optionalText(payload.description),
formula: optionalText(payload.formula),
reference: optionalText(payload.reference),
methodPUI: optionalText(payload.pui),
ontologyDbId,
}]),
});
return mapMethod(response.result.data[0]);
}
const response = await request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales", {
method: "POST",
body: JSON.stringify([{
scaleName: name,
units: optionalText(payload.units),
dataType: optionalNumber(payload.data_type),
decimalPlaces: optionalNumber(payload.decimal_places),
scalePUI: optionalText(payload.pui),
validValueMin: optionalText(payload.valid_value_min),
validValueMax: optionalText(payload.valid_value_max),
ontologyDbId,
}]),
});
return mapScale(response.result.data[0]);
}
export async function updateTraitMethodScaleRow(id: string, payload: TraitMethodScalePayload): Promise<TraitMethodScaleRecord> {
const { kind, dbId } = parseCompositeId(id);
if (payload.kind && payload.kind !== kind) {
throw new Error("编辑时不能修改类型,请删除后重新新增");
}
const name = commonName(payload);
const ontologyDbId = optionalText(payload.ontology_id);
if (kind === "Trait") {
const response = await request<BrapiSingleResponse<TraitResponse>>(`/brapi/v2/traits/${encodeURIComponent(dbId)}`, {
method: "PUT",
body: JSON.stringify({
traitName: name,
traitClass: optionalText(payload.class),
traitDescription: optionalText(payload.description),
traitPUI: optionalText(payload.pui),
ontologyDbId,
}),
});
return mapTrait(response.result);
}
if (kind === "Method") {
const response = await request<BrapiSingleResponse<MethodResponse>>(`/brapi/v2/methods/${encodeURIComponent(dbId)}`, {
method: "PUT",
body: JSON.stringify({
methodName: name,
methodClass: optionalText(payload.class),
description: optionalText(payload.description),
formula: optionalText(payload.formula),
reference: optionalText(payload.reference),
methodPUI: optionalText(payload.pui),
ontologyDbId,
}),
});
return mapMethod(response.result);
}
const response = await request<BrapiSingleResponse<ScaleResponse>>(`/brapi/v2/scales/${encodeURIComponent(dbId)}`, {
method: "PUT",
body: JSON.stringify({
scaleName: name,
units: optionalText(payload.units),
dataType: optionalNumber(payload.data_type),
decimalPlaces: optionalNumber(payload.decimal_places),
scalePUI: optionalText(payload.pui),
validValueMin: optionalText(payload.valid_value_min),
validValueMax: optionalText(payload.valid_value_max),
ontologyDbId,
}),
});
return mapScale(response.result);
}
export async function deleteTraitMethodScaleRow(id: string): Promise<void> {
const { kind, dbId } = parseCompositeId(id);
const path = kind === "Trait" ? "traits" : kind === "Method" ? "methods" : "scales";
await request<BrapiSingleResponse<TraitResponse | MethodResponse | ScaleResponse>>(`/brapi/v2/${path}/${encodeURIComponent(dbId)}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Activity, Ruler, Sigma } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField, type BrapiTableColumn } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createTraitMethodScaleRow,
deleteTraitMethodScaleRow,
fetchOntologyOptions,
fetchTraitMethodScaleRows,
updateTraitMethodScaleRow,
} from "./api";
import { NONE_ONTOLOGY_VALUE, type OntologyOption, type TraitMethodScaleKind } from "./types";
const kindTabs: Array<{ value: TraitMethodScaleKind; label: string }> = [
{ value: "Trait", label: "Trait 性状" },
{ value: "Method", label: "Method 方法" },
{ value: "Scale", label: "Scale 标尺" },
];
const ontologyField = (ontologyOptions: OntologyOption[]): BrapiFormField => ({
key: "ontology_id",
label: "所属 Ontology",
type: "select",
options: [{ value: NONE_ONTOLOGY_VALUE, label: "不关联本体" }, ...ontologyOptions],
});
const commonFields = (ontologyOptions: OntologyOption[]): BrapiFormField[] => [
{ key: "name", label: "名称", type: "text", required: true },
ontologyField(ontologyOptions),
{ key: "pui", label: "PUI", type: "text", placeholder: "永久唯一标识" },
];
const columnsByKind: Record<TraitMethodScaleKind, BrapiTableColumn[]> = {
Trait: [
{ key: "db_id", label: "Trait ID" },
{ key: "name", label: "性状名称" },
{ key: "class", label: "性状分类" },
{ key: "ontology_name", label: "Ontology" },
{ key: "pui", label: "PUI" },
{ key: "description", label: "性状描述" },
],
Method: [
{ key: "db_id", label: "Method ID" },
{ key: "name", label: "方法名称" },
{ key: "class", label: "方法分类" },
{ key: "ontology_name", label: "Ontology" },
{ key: "formula", label: "公式" },
{ key: "reference", label: "参考资料" },
{ key: "description", label: "方法说明" },
],
Scale: [
{ key: "db_id", label: "Scale ID" },
{ key: "name", label: "标尺名称" },
{ key: "ontology_name", label: "Ontology" },
{ key: "units", label: "单位" },
{ key: "data_type", label: "数据类型" },
{ key: "decimal_places", label: "小数位" },
{ key: "valid_value_min", label: "最小值" },
{ key: "valid_value_max", label: "最大值" },
{ key: "pui", label: "PUI" },
],
};
const statsByKind = {
Trait: [{ label: "/brapi/v2/traits", value: "Trait", className: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200" }],
Method: [{ label: "/brapi/v2/methods", value: "Method", className: "bg-blue-50 text-blue-700 dark:bg-blue-400/10 dark:text-blue-200" }],
Scale: [{ label: "/brapi/v2/scales", value: "Scale", className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200" }],
};
function useOntologyOptions() {
const [ontologyOptions, setOntologyOptions] = useState<OntologyOption[]>([]);
const ensureOntologyOptions = useCallback(async () => {
const options = await fetchOntologyOptions();
setOntologyOptions(options);
return options;
}, []);
return { ontologyOptions, ensureOntologyOptions };
}
function TraitTab({ ontologyOptions, ensureOntologyOptions }: { ontologyOptions: OntologyOption[]; ensureOntologyOptions: () => Promise<OntologyOption[]> }) {
const loadRows = useCallback(async () => {
await ensureOntologyOptions();
return fetchTraitMethodScaleRows("Trait") as unknown as Promise<Record<string, unknown>[]>;
}, [ensureOntologyOptions]);
const fields = useMemo<BrapiFormField[]>(() => [
...commonFields(ontologyOptions),
{ key: "class", label: "性状分类", type: "text", placeholder: "如 morphological、yield、quality" },
{ key: "description", label: "性状描述", type: "textarea", placeholder: "描述这个性状测量的生物学含义", colSpan: 2 },
], [ontologyOptions]);
return (
<BrapiEntityPage
icon={Activity}
iconBg="bg-gradient-to-br from-green-500 to-emerald-600"
title="Trait 性状"
description="维护测什么,例如株高、产量、蛋白含量等性状定义"
addLabel="新增性状"
columns={columnsByKind.Trait}
fields={fields}
data={[]}
stats={statsByKind.Trait}
loadData={loadRows}
createRecord={(payload) => createTraitMethodScaleRow({ ...payload, kind: "Trait" }) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateTraitMethodScaleRow(id, { ...payload, kind: "Trait" }) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteTraitMethodScaleRow}
/>
);
}
function MethodTab({ ontologyOptions, ensureOntologyOptions }: { ontologyOptions: OntologyOption[]; ensureOntologyOptions: () => Promise<OntologyOption[]> }) {
const loadRows = useCallback(async () => {
await ensureOntologyOptions();
return fetchTraitMethodScaleRows("Method") as unknown as Promise<Record<string, unknown>[]>;
}, [ensureOntologyOptions]);
const fields = useMemo<BrapiFormField[]>(() => [
...commonFields(ontologyOptions),
{ key: "class", label: "方法分类", type: "text", placeholder: "如 measurement、calculation、visual scoring" },
{ key: "formula", label: "公式", type: "text", placeholder: "如 fresh_weight / plot_area" },
{ key: "reference", label: "参考资料", type: "text", placeholder: "方法来源、标准或论文" },
{ key: "description", label: "方法说明", type: "textarea", placeholder: "说明具体怎么测、采样位置、采样时机等", colSpan: 2 },
], [ontologyOptions]);
return (
<BrapiEntityPage
icon={Sigma}
iconBg="bg-gradient-to-br from-blue-500 to-cyan-600"
title="Method 方法"
description="维护怎么测,例如直尺测量、公式计算、人工分级等方法"
addLabel="新增方法"
columns={columnsByKind.Method}
fields={fields}
data={[]}
stats={statsByKind.Method}
loadData={loadRows}
createRecord={(payload) => createTraitMethodScaleRow({ ...payload, kind: "Method" }) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateTraitMethodScaleRow(id, { ...payload, kind: "Method" }) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteTraitMethodScaleRow}
/>
);
}
function ScaleTab({ ontologyOptions, ensureOntologyOptions }: { ontologyOptions: OntologyOption[]; ensureOntologyOptions: () => Promise<OntologyOption[]> }) {
const loadRows = useCallback(async () => {
await ensureOntologyOptions();
return fetchTraitMethodScaleRows("Scale") as unknown as Promise<Record<string, unknown>[]>;
}, [ensureOntologyOptions]);
const fields = useMemo<BrapiFormField[]>(() => [
...commonFields(ontologyOptions),
{ key: "units", label: "单位", type: "text", placeholder: "如 cm、kg、%" },
{ key: "data_type", label: "数据类型", type: "number", placeholder: "可选数字枚举" },
{ key: "decimal_places", label: "小数位", type: "number", placeholder: "如 0、1、2" },
{ key: "valid_value_min", label: "最小值", type: "text" },
{ key: "valid_value_max", label: "最大值", type: "text" },
], [ontologyOptions]);
return (
<BrapiEntityPage
icon={Ruler}
iconBg="bg-gradient-to-br from-orange-500 to-amber-600"
title="Scale 标尺"
description="维护用什么单位和取值范围,例如厘米、公斤、百分比或等级"
addLabel="新增标尺"
columns={columnsByKind.Scale}
fields={fields}
data={[]}
stats={statsByKind.Scale}
loadData={loadRows}
createRecord={(payload) => createTraitMethodScaleRow({ ...payload, kind: "Scale" }) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateTraitMethodScaleRow(id, { ...payload, kind: "Scale" }) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteTraitMethodScaleRow}
/>
);
}
export default function TraitMethodScalePage() {
const { ontologyOptions, ensureOntologyOptions } = useOntologyOptions();
return (
<Tabs defaultValue="Trait" 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">
{kindTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="Trait" className="mt-0 min-h-0 flex-1">
<TraitTab ontologyOptions={ontologyOptions} ensureOntologyOptions={ensureOntologyOptions} />
</TabsContent>
<TabsContent value="Method" className="mt-0 min-h-0 flex-1">
<MethodTab ontologyOptions={ontologyOptions} ensureOntologyOptions={ensureOntologyOptions} />
</TabsContent>
<TabsContent value="Scale" className="mt-0 min-h-0 flex-1">
<ScaleTab ontologyOptions={ontologyOptions} ensureOntologyOptions={ensureOntologyOptions} />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,27 @@
export const NONE_ONTOLOGY_VALUE = "__none__";
export type TraitMethodScaleKind = "Trait" | "Method" | "Scale";
export interface TraitMethodScaleRecord {
id: string;
db_id: string;
kind: TraitMethodScaleKind;
name: string | null;
ontology_id: string | null;
ontology_name: string | null;
description: string | null;
units: string | null;
class: string | null;
pui: string | null;
formula: string | null;
reference: string | null;
data_type: number | null;
decimal_places: number | null;
valid_value_min: string | null;
valid_value_max: string | null;
}
export interface OntologyOption {
value: string;
label: string;
}

View File

@@ -0,0 +1,5 @@
import { PageHeader } from "@/components/common/PageHeader";
export default function EmployeePage() {
return <PageHeader title="员工管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
}

View File

@@ -0,0 +1,5 @@
import { PageHeader } from "@/components/common/PageHeader";
export default function MenuPage() {
return <PageHeader title="菜单管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
}

View File

@@ -0,0 +1,5 @@
import { PageHeader } from "@/components/common/PageHeader";
export default function RolePage() {
return <PageHeader title="角色管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
}

View File

@@ -0,0 +1,5 @@
import { BrapiDashboard } from "@/components/brapi/BrapiDashboard";
export default function DashboardPage() {
return <BrapiDashboard />;
}

View File

@@ -0,0 +1,251 @@
"use client";
import { useMemo, useState } from "react";
import { format } from "date-fns";
import { CalendarClock } from "lucide-react";
import {
DateTimePicker,
getDefaultFormat,
type DateTimePickerMode,
} from "@/components/common/shadcn-enhanced";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
type DemoCase = {
id: string;
title: string;
description: string;
mode: DateTimePickerMode;
props?: Partial<React.ComponentProps<typeof DateTimePicker>>;
};
const BASE_CASES: DemoCase[] = [
{
id: "year",
title: "年选择器",
description: "mode=\"year\",翻页选择年份",
mode: "year",
},
{
id: "month",
title: "年月选择器",
description: "mode=\"month\",选择年份 + 月份",
mode: "month",
},
{
id: "date",
title: "年月日选择器",
description: "mode=\"date\"日历选择年月日Trial 开始/结束日期使用此模式)",
mode: "date",
props: { showToday: true },
},
{
id: "datetime-hour",
title: "年月日 + 小时",
description: "mode=\"datetime-hour\"",
mode: "datetime-hour",
},
{
id: "datetime-minute",
title: "年月日 + 时分",
description: "mode=\"datetime-minute\"",
mode: "datetime-minute",
},
{
id: "datetime-second",
title: "年月日 + 时分秒",
description: "mode=\"datetime-second\"",
mode: "datetime-second",
},
];
const VARIANT_CASES: DemoCase[] = [
{
id: "clearable",
title: "可清空",
description: "clearable=true",
mode: "date",
props: { clearable: true, showToday: true },
},
{
id: "required",
title: "必填",
description: "required=true不可清空",
mode: "date",
props: { required: true, showToday: true },
},
{
id: "disabled",
title: "禁用",
description: "disabled=true",
mode: "date",
props: { disabled: true, defaultValue: new Date(2026, 2, 15) },
},
{
id: "readonly",
title: "只读",
description: "readOnly=true",
mode: "date",
props: { readOnly: true, defaultValue: new Date(2026, 2, 15) },
},
{
id: "range",
title: "日期范围限制",
description: "minDate / maxDate 限制可选范围",
mode: "date",
props: {
minDate: new Date(2026, 0, 1),
maxDate: new Date(2026, 11, 31),
showToday: true,
clearable: true,
},
},
{
id: "confirm",
title: "确认后提交",
description: "showConfirm=true需点击「确定」才生效",
mode: "datetime-minute",
props: { showConfirm: true, showNow: true, clearable: true },
},
{
id: "12hour",
title: "12 小时制",
description: "use12Hour=true",
mode: "datetime-minute",
props: { use12Hour: true, clearable: true },
},
{
id: "steps",
title: "步进选择",
description: "hourStep=2 / minuteStep=15 / secondStep=10",
mode: "datetime-second",
props: { hourStep: 2, minuteStep: 15, secondStep: 10, clearable: true },
},
];
function formatValue(value: Date | null, mode: DateTimePickerMode) {
if (!value) return "null";
return format(value, getDefaultFormat(mode));
}
function DateTimePickerCase({ demo }: { demo: DemoCase }) {
const [value, setValue] = useState<Date | null>(demo.props?.defaultValue ?? null);
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{demo.title}</CardTitle>
<CardDescription>{demo.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<DateTimePicker
mode={demo.mode}
value={value}
onChange={setValue}
placeholder={`请选择${demo.title}`}
className="max-w-sm"
{...demo.props}
/>
<p className="text-xs text-muted-foreground">
<code className="ml-1 rounded bg-muted px-1.5 py-0.5 text-foreground">
{formatValue(value, demo.mode)}
</code>
</p>
</CardContent>
</Card>
);
}
export default function DateTimePickerDemoPage() {
const [trialStart, setTrialStart] = useState<Date | null>(new Date(2026, 2, 1));
const [trialEnd, setTrialEnd] = useState<Date | null>(new Date(2026, 8, 30));
const trialPreview = useMemo(
() => ({
startDate: formatValue(trialStart, "date"),
endDate: formatValue(trialEnd, "date"),
}),
[trialEnd, trialStart]
);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-sky-500 to-blue-600 p-2.5">
<CalendarClock className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
DateTimePicker
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
shadcn Calendar + Popover + Select / / /
</p>
</div>
</div>
<Card className="border-sky-200 bg-sky-50/40 dark:border-sky-900 dark:bg-sky-950/20">
<CardHeader className="pb-3">
<CardTitle className="text-base">Trial · </CardTitle>
<CardDescription>
Trial / DateTimePicker mode=&quot;date&quot; yyyy-MM-dd
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200"></p>
<DateTimePicker
mode="date"
value={trialStart}
onChange={setTrialStart}
placeholder="请选择开始日期"
showToday
clearable
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200"></p>
<DateTimePicker
mode="date"
value={trialEnd}
onChange={setTrialEnd}
placeholder="请选择结束日期"
showToday
clearable
minDate={trialStart ?? undefined}
/>
</div>
<p className="md:col-span-2 text-xs text-muted-foreground">
payload
<code className="ml-1 rounded bg-background px-1.5 py-0.5">
{`{ startDate: "${trialPreview.startDate}", endDate: "${trialPreview.endDate}" }`}
</code>
</p>
</CardContent>
</Card>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50"></h3>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{BASE_CASES.map((demo) => (
<DateTimePickerCase key={demo.id} demo={demo} />
))}
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50"></h3>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{VARIANT_CASES.map((demo) => (
<DateTimePickerCase key={demo.id} demo={demo} />
))}
</div>
</section>
<p className={cn("text-xs text-muted-foreground")}>
@/components/common/shadcn-enhanced/date-time-picker
</p>
</div>
);
}

View File

@@ -0,0 +1,255 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Layers, Pencil, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { TableQueryPanel } from "@/components/common/TableQueryPanel";
import { cn } from "@/lib/utils";
type DemoCropRow = {
id: string;
crop_name: string;
category: string;
region: string;
status: string;
updatedAt: string;
};
const MOCK_ROWS: DemoCropRow[] = [
{
id: "c99207c170904d73a888b1fad25f8517",
crop_name: "Maize",
category: "Grain",
region: "North China Plain",
status: "Active",
updatedAt: "2026-03-01",
},
{
id: "4e31c03bdb204bb680dbc82216975ff3",
crop_name: "Rice",
category: "Grain",
region: "Yangtze River Basin",
status: "Active",
updatedAt: "2026-02-18",
},
{
id: "8b2d1f4a6c3e4d5f9a0b1c2d3e4f5a6b",
crop_name: "Wheat",
category: "Grain",
region: "Huang-Huai Plain",
status: "Active",
updatedAt: "2026-02-10",
},
{
id: "1a2b3c4d5e6f708192a3b4c5d6e7f809",
crop_name: "Soybean",
category: "Oilseed",
region: "Northeast China",
status: "Draft",
updatedAt: "2026-01-22",
},
{
id: "9f8e7d6c5b4a39281726354453627180",
crop_name: "Cotton",
category: "Fiber",
region: "Xinjiang Production Area",
status: "Active",
updatedAt: "2026-01-15",
},
{
id: "aa11bb22cc33dd44ee55ff6677889900",
crop_name: "Potato",
category: "Tuber",
region: "Inner Mongolia Plateau",
status: "Inactive",
updatedAt: "2025-12-30",
},
{
id: "bb22cc33dd44ee55ff6677889900aa11",
crop_name: "Tomato",
category: "Vegetable",
region: "Shandong Peninsula",
status: "Active",
updatedAt: "2025-12-12",
},
{
id: "cc33dd44ee55ff6677889900aa11bb22",
crop_name: "Sorghum",
category: "Grain",
region: "Loess Plateau",
status: "Active",
updatedAt: "2025-11-08",
},
{
id: "dd44ee55ff6677889900aa11bb22cc33",
crop_name: "Rapeseed",
category: "Oilseed",
region: "Middle Yangtze Region",
status: "Draft",
updatedAt: "2025-10-21",
},
{
id: "ee55ff6677889900aa11bb22cc33dd44",
crop_name: "Barley",
category: "Grain",
region: "Qinghai-Tibet Fringe",
status: "Active",
updatedAt: "2025-09-03",
},
{
id: "ff6677889900aa11bb22cc33dd44ee55",
crop_name: "Peanut",
category: "Oilseed",
region: "Henan Province",
status: "Active",
updatedAt: "2025-08-17",
},
{
id: "6677889900aa11bb22cc33dd44ee55ff",
crop_name: "Sugar Beet",
category: "Industrial",
region: "Heilongjiang Farmland",
status: "Inactive",
updatedAt: "2025-07-29",
},
];
const STATUS_CLASS: Record<string, string> = {
Active: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200",
Draft: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200",
Inactive: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
};
export default function GeneralFormDemoPage() {
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
useEffect(() => {
const timer = window.setTimeout(() => setLoading(false), 600);
return () => window.clearTimeout(timer);
}, []);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return MOCK_ROWS;
return MOCK_ROWS.filter((row) =>
[row.crop_name, row.category, row.region, row.status, row.updatedAt].some((value) =>
value.toLowerCase().includes(keyword)
)
);
}, [search]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const pagedRows = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredRows.slice(start, start + pageSize);
}, [filteredRows, page, pageSize]);
useEffect(() => {
setPage(1);
}, [search, pageSize]);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<TableQueryPanel
title="通用表格 TableQueryPanel"
description="组件演示页,样式对齐 Crop 作物字典页,数据为本地 mock"
icon={Layers}
iconBg="bg-gradient-to-br from-violet-500 to-purple-600"
addLabel="新增作物"
onAdd={() => window.alert("演示:点击新增")}
stats={[
{
label: "Mock 数据",
value: MOCK_ROWS.length,
className:
"bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200",
},
{
label: "variant",
value: "brapi",
className:
"bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
},
]}
variant="brapi"
hideDefaultActions
queryForm={
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="搜索..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="bg-white pl-9 dark:bg-slate-950"
/>
</div>
}
columns={[
{ key: "crop_name", label: "作物名称" },
{ key: "category", label: "类别" },
{ key: "region", label: "主产区" },
{
key: "status",
label: "状态",
render: (value) => (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium",
STATUS_CLASS[String(value)] ?? STATUS_CLASS.Draft
)}
>
{String(value)}
</span>
),
},
{ key: "updatedAt", label: "更新时间" },
{
key: "__actions",
label: "操作",
wrap: false,
headerClassName: "w-24 pr-4 text-right",
className: "pr-4 text-right",
render: () => (
<div className="flex justify-end">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-slate-500 hover:text-slate-700"
type="button"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
),
},
]}
data={pagedRows}
loading={loading}
emptyText="暂无数据,请调整搜索条件"
rowKey="id"
showIndex
pagination={{
page,
pageSize,
total: filteredRows.length,
totalPages,
}}
pageSizeOptions={[5, 10, 20]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { ImageIcon } from "lucide-react";
import { useState } from "react";
import { ImageUploader, type UploadedImage } from "@/components/common/image-uploader";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
function UploadedList({ images }: { images: UploadedImage[] }) {
if (images.length === 0) {
return (
<p className="text-sm text-muted-foreground"></p>
);
}
return (
<div className="space-y-2">
{images.map((img, i) => (
<div key={img.url} className="flex items-start gap-3 rounded-lg border bg-muted/30 p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.url}
alt={img.filename}
className="h-12 w-12 shrink-0 rounded object-cover"
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate text-sm font-medium">{img.filename}</p>
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">{img.contentType}</Badge>
<Badge variant="outline" className="text-xs">
{(img.size / 1024).toFixed(1)} KB
</Badge>
</div>
<p className="truncate text-xs text-muted-foreground">
<a href={img.url} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2 hover:text-foreground">
{img.url}
</a>
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">#{i + 1}</span>
</div>
))}
</div>
);
}
export default function ImageUploaderDemoPage() {
const [basicImages, setBasicImages] = useState<UploadedImage[]>([]);
const [singleImage, setSingleImage] = useState<UploadedImage | null>(null);
const [multiImages, setMultiImages] = useState<UploadedImage[]>([]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-sky-500 to-blue-600 p-2.5">
<ImageIcon className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
ImageUploader
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
MinIO
</p>
</div>
</div>
{/* Info card */}
<Card className="border-sky-200 bg-sky-50/40 dark:border-sky-900 dark:bg-sky-950/20">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription> multipart/form-data POST </CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-3">
{[
["上传接口", "POST /brapi/v2/upload/image"],
["最大单文件", "10 MB"],
["支持格式", "JPG · PNG · GIF · WebP · BMP"],
].map(([label, value]) => (
<div key={label} className="rounded-md border border-sky-200/60 bg-background/80 px-3 py-2 dark:border-sky-800">
<span className="font-medium text-slate-800 dark:text-slate-100">{label}</span>
<span className="ml-2 text-muted-foreground">{value}</span>
</div>
))}
</CardContent>
</Card>
<Tabs defaultValue="basic">
<TabsList>
<TabsTrigger value="basic"></TabsTrigger>
<TabsTrigger value="single"></TabsTrigger>
<TabsTrigger value="limit"></TabsTrigger>
</TabsList>
{/* ── Basic ── */}
<TabsContent value="basic" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> MinIO</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ImageUploader
onUploaded={(img) => setBasicImages((prev) => [...prev, img])}
onRemoved={(url) => setBasicImages((prev) => prev.filter((i) => i.url !== url))}
/>
<Separator />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{basicImages.length}
</p>
<UploadedList images={basicImages} />
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── Single ── */}
<TabsContent value="single" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription>maxFiles=1</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ImageUploader
maxFiles={1}
onUploaded={(img) => setSingleImage(img)}
onRemoved={() => setSingleImage(null)}
/>
<Separator />
{singleImage ? (
<div className="flex items-center gap-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={singleImage.url}
alt={singleImage.filename}
className="h-24 w-24 rounded-lg border object-cover shadow-sm"
/>
<div className="space-y-1">
<p className="font-medium">{singleImage.filename}</p>
<p className="text-xs text-muted-foreground">{singleImage.contentType} · {(singleImage.size / 1024).toFixed(1)} KB</p>
<a
href={singleImage.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-sky-600 underline underline-offset-2 hover:text-sky-500 dark:text-sky-400"
>
</a>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</CardContent>
</Card>
</TabsContent>
{/* ── Limit ── */}
<TabsContent value="limit" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> 4 </CardTitle>
<CardDescription>maxFiles=4</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ImageUploader
maxFiles={4}
onUploaded={(img) => setMultiImages((prev) => [...prev, img])}
onRemoved={(url) => setMultiImages((prev) => prev.filter((i) => i.url !== url))}
/>
<Separator />
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
{multiImages.length} / 4
</p>
<UploadedList images={multiImages} />
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<p className="text-xs text-muted-foreground">
@/components/common/image-uploader · POST /brapi/v2/upload/image
</p>
</div>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/common/PageHeader";
import { cn } from "@/lib/utils";
type DemoEmployeeRow = Record<string, unknown> & {
id: string;
name: string;
department: string;
role: string;
email: string;
status: string;
joinedAt: string;
};
const MOCK_ROWS: DemoEmployeeRow[] = [
{ id: "1", name: "张伟", department: "育种部", role: "高级育种师", email: "zhangwei@example.com", status: "Active", joinedAt: "2022-03-15" },
{ id: "2", name: "李娜", department: "表型组", role: "数据采集员", email: "lina@example.com", status: "Active", joinedAt: "2023-06-01" },
{ id: "3", name: "王强", department: "基因型实验室", role: "实验主管", email: "wangqiang@example.com", status: "Active", joinedAt: "2021-11-20" },
{ id: "4", name: "赵敏", department: "项目管理", role: "Trial 协调员", email: "zhaomin@example.com", status: "Draft", joinedAt: "2024-01-08" },
{ id: "5", name: "陈晨", department: "育种部", role: "助理育种师", email: "chenchen@example.com", status: "Active", joinedAt: "2024-05-12" },
{ id: "6", name: "刘洋", department: "IT 支持", role: "系统管理员", email: "liuyang@example.com", status: "Inactive", joinedAt: "2020-09-03" },
{ id: "7", name: "孙悦", department: "表型组", role: "图像分析员", email: "sunyue@example.com", status: "Active", joinedAt: "2023-12-18" },
{ id: "8", name: "周杰", department: "基因型实验室", role: "测序技术员", email: "zhoujie@example.com", status: "Active", joinedAt: "2022-08-25" },
{ id: "9", name: "吴婷", department: "项目管理", role: "Program 负责人", email: "wuting@example.com", status: "Active", joinedAt: "2019-04-10" },
{ id: "10", name: "郑磊", department: "育种部", role: "田间管理员", email: "zhenglei@example.com", status: "Draft", joinedAt: "2025-02-01" },
{ id: "11", name: "冯雪", department: "表型组", role: "质控专员", email: "fengxue@example.com", status: "Active", joinedAt: "2023-03-22" },
{ id: "12", name: "蒋波", department: "IT 支持", role: "数据工程师", email: "jiangbo@example.com", status: "Active", joinedAt: "2021-07-14" },
];
const STATUS_CLASS: Record<string, string> = {
Active: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200",
Draft: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200",
Inactive: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
};
function filterRows(rows: DemoEmployeeRow[], filters: Record<string, string>) {
const keyword = filters.keyword?.trim().toLowerCase() ?? "";
const department = filters.department ?? "";
const status = filters.status ?? "";
return rows.filter((row) => {
if (department && row.department !== department) return false;
if (status && row.status !== status) return false;
if (!keyword) return true;
return [row.name, row.department, row.role, row.email, row.status].some((value) =>
String(value).toLowerCase().includes(keyword)
);
});
}
export default function PageHeaderDemoPage() {
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
useEffect(() => {
const timer = window.setTimeout(() => setLoading(false), 500);
return () => window.clearTimeout(timer);
}, []);
const filteredRows = useMemo(
() => filterRows(MOCK_ROWS, appliedFilters),
[appliedFilters]
);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const pagedRows = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredRows.slice(start, start + pageSize);
}, [filteredRows, page, pageSize]);
useEffect(() => {
setPage(1);
}, [appliedFilters, pageSize]);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<PageHeader
title="PageHeader 页面壳"
description="集成标题区、配置化查询表单、数据表格与分页的通用页面组件演示,数据为本地 mock。"
actions={
<Button className="gap-2">
<Plus className="h-4 w-4" />
</Button>
}
searchFields={[
{
key: "keyword",
label: "关键词",
placeholder: "姓名、邮箱、岗位",
},
{
key: "department",
label: "部门",
type: "select",
defaultValue: "all",
placeholder: "全部部门",
options: [
{ value: "all", label: "全部部门" },
{ value: "育种部", label: "育种部" },
{ value: "表型组", label: "表型组" },
{ value: "基因型实验室", label: "基因型实验室" },
{ value: "项目管理", label: "项目管理" },
{ value: "IT 支持", label: "IT 支持" },
],
},
{
key: "status",
label: "状态",
type: "select",
defaultValue: "all",
placeholder: "全部状态",
options: [
{ value: "all", label: "全部状态" },
{ value: "Active", label: "Active" },
{ value: "Draft", label: "Draft" },
{ value: "Inactive", label: "Inactive" },
],
},
]}
columns={[
{ key: "name", label: "姓名", minWidth: "120px" },
{ key: "department", label: "部门", minWidth: "140px" },
{ key: "role", label: "岗位", minWidth: "160px" },
{ key: "email", label: "邮箱", minWidth: "200px" },
{
key: "status",
label: "状态",
minWidth: "100px",
render: (value) => (
<span
className={cn(
"inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium",
STATUS_CLASS[String(value)] ?? STATUS_CLASS.Draft
)}
>
{String(value)}
</span>
),
},
{ key: "joinedAt", label: "入职日期", minWidth: "120px" },
]}
data={pagedRows}
loading={loading}
emptyText="暂无匹配数据,请调整查询条件"
rowKey="id"
pagination={{
page,
size: pageSize,
total: filteredRows.length,
totalPages,
}}
sizeOptions={[5, 10, 20]}
onSearch={(filters) => {
const nextFilters = { ...filters };
if (nextFilters.department === "all") nextFilters.department = "";
if (nextFilters.status === "all") nextFilters.status = "";
setAppliedFilters(nextFilters);
}}
onReset={() => setAppliedFilters({})}
onPageChange={setPage}
onSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { Scale } from "lucide-react";
import {
PackageSpecConverter,
SeedCountWeightConverter,
UnitConverter,
} from "@/components/common/unit-converter";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
CATEGORY_LABELS,
DIMENSION_LABELS,
UNIT_DEMO_GROUPS,
UNIT_REGISTRY,
convertUnitValue,
formatUnitNumber,
getUnitsByCategory,
type UnitCategory,
} from "@/lib/units";
import { cn } from "@/lib/utils";
function UnitRegistryTable({ category }: { category?: UnitCategory }) {
const rows = category
? getUnitsByCategory(category)
: UNIT_REGISTRY.filter((u) => u.isActive !== false);
return (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right">factor_to_base</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-mono text-xs">{u.unitCode}</TableCell>
<TableCell>{u.symbol}</TableCell>
<TableCell>{u.nameZh}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{DIMENSION_LABELS[u.dimension]}
</TableCell>
<TableCell className="font-mono text-xs">{u.baseUnitCode ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs">
{u.factorToBase != null ? u.factorToBase : "—"}
</TableCell>
<TableCell>
<Badge variant={u.isConvertible ? "default" : "secondary"}>
{u.isConvertible ? "是" : "否"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={u.isRequiredSpec ? "destructive" : "outline"}>
{u.isRequiredSpec ? "是" : "否"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
function QuickExample({
value,
from,
to,
label,
}: {
value: number;
from: string;
to: string;
label: string;
}) {
const result = convertUnitValue(value, from, to);
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{label}</span>
{result.ok ? (
<span className="ml-2 font-medium text-foreground">
= {formatUnitNumber(result.value)}
</span>
) : (
<span className="ml-2 text-destructive"></span>
)}
</div>
);
}
export default function UnitConverterDemoPage() {
const categories = Object.keys(CATEGORY_LABELS) as UnitCategory[];
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 p-2.5">
<Scale className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">
UnitConverter
</h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
+ + factor_to_base
</p>
</div>
</div>
<Card className="border-emerald-200 bg-emerald-50/40 dark:border-emerald-900 dark:bg-emerald-950/20">
<CardHeader className="pb-3">
<CardTitle className="text-base"></CardTitle>
<CardDescription>
base_value = value × factor_to_basetarget = base_value / target.factor_to_base
</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
{[
["质量", "kg"],
["面积", "m²国内可显示亩"],
["长度", "m"],
["体积", "L"],
["产量", "kg/ha"],
["施肥/用药", "kg/ha、g/ha、L/ha"],
["种植密度", "plants/ha"],
["灌溉", "mm ↔ m³/ha"],
].map(([label, base]) => (
<div
key={label}
className="rounded-md border border-emerald-200/60 bg-background/80 px-3 py-2 dark:border-emerald-800"
>
<span className="font-medium text-slate-800 dark:text-slate-100">{label}</span>
<span className="ml-2 text-muted-foreground"> {base}</span>
</div>
))}
</CardContent>
</Card>
<Tabs defaultValue="scenarios">
<TabsList className="flex h-auto flex-wrap gap-1">
<TabsTrigger value="scenarios"></TabsTrigger>
<TabsTrigger value="seed"> </TabsTrigger>
<TabsTrigger value="package"></TabsTrigger>
<TabsTrigger value="registry"></TabsTrigger>
</TabsList>
<TabsContent value="scenarios" className="mt-4 space-y-4">
<div className="grid gap-4 lg:grid-cols-2">
{UNIT_DEMO_GROUPS.map((group) => (
<Card key={group.id}>
<CardHeader className="pb-3">
<CardTitle className="text-base">{group.title}</CardTitle>
<CardDescription>{group.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UnitConverter
dimension={group.dimension}
defaultFromCode={group.defaultFrom}
defaultToCode={group.defaultTo}
defaultValue={
group.examples[0]?.from === group.defaultFrom
? group.examples[0].value
: 1
}
/>
<div className="space-y-2">
{"examples" in group &&
group.examples.map((ex) => (
<QuickExample
key={ex.label}
value={ex.value}
from={ex.from}
to={ex.to}
label={ex.label}
/>
))}
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="seed" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> TKW</CardTitle>
<CardDescription>
SeedLot thousand_kernel_weight g/kg
</CardDescription>
</CardHeader>
<CardContent>
<SeedCountWeightConverter defaultTkw={25} defaultCount={10000} />
<div className="mt-4 rounded-lg border bg-muted/30 p-3 text-sm">
25 g10000 ={" "}
<strong>{formatUnitNumber((10000 * 25) / 1000)} g</strong>250 g
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="package" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> × </CardTitle>
<CardDescription>
is_required_spec = true
</CardDescription>
</CardHeader>
<CardContent>
<PackageSpecConverter />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="registry" className="mt-4 space-y-4">
<Tabs defaultValue={categories[0]}>
<TabsList className="flex h-auto flex-wrap gap-1">
{categories.map((cat) => (
<TabsTrigger key={cat} value={cat}>
{CATEGORY_LABELS[cat]}
</TabsTrigger>
))}
</TabsList>
{categories.map((cat) => (
<TabsContent key={cat} value={cat} className="mt-4">
<UnitRegistryTable category={cat} />
</TabsContent>
))}
</Tabs>
</TabsContent>
</Tabs>
<p className={cn("text-xs text-muted-foreground")}>
@/lib/units · @/components/common/unit-converter
</p>
</div>
);
}

View File

@@ -0,0 +1,401 @@
import { createCachedLoader, loadDropdownBundle } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import {
NONE_SELECT_VALUE,
type ReferenceBasesRecord,
type ReferenceRecord,
type ReferenceSetRecord,
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;
}
interface VariantSetResponse {
variantSetDbId: string;
variantSetName: string | null;
referenceSetDbId: string | null;
}
type ReferenceSetPayload = Partial<Record<
| "id"
| "reference_set_name"
| "assembly_pui"
| "description"
| "is_derived"
| "md5checksum"
| "source_uri"
| "species_ontology_term"
| "species_ontology_termuri"
| "source_germplasm_id",
unknown
>>;
type ReferencePayload = Partial<Record<
"id" | "reference_name" | "reference_set_id" | "length" | "md5checksum" | "source_divergence",
unknown
>>;
type ReferenceBasesPayload = Partial<Record<
"id" | "reference_id" | "page_number" | "bases",
unknown
>>;
const URL_PATTERN = /^https?:\/\/.+/i;
const BASES_PATTERN = /^[ACGTNacgtn*.-]*$/;
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 || `Request failed: ${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 requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const optionalNumber = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isNaN(parsed) ? null : parsed;
};
const optionalBoolean = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return null;
return ["true", "1", "yes"].includes(normalized.toLowerCase());
};
const optionalUrl = (value: unknown, label: string) => {
const normalized = optionalText(value);
if (!normalized) return null;
if (!URL_PATTERN.test(normalized)) {
throw new Error(`${label} 必须是有效的 URL`);
}
return normalized;
};
const validateBases = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return null;
if (normalized.length > 2048) {
throw new Error("碱基序列片段不能超过 2048 字符");
}
if (!BASES_PATTERN.test(normalized)) {
throw new Error("碱基序列仅允许 A/C/G/T/N 及常见占位符");
}
return normalized.toUpperCase();
};
const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({
...item,
id: item.referenceSetDbId || item.id,
reference_set_name: item.reference_set_name || item.referenceSetName || null,
assembly_pui: item.assembly_pui || item.assemblyPUI || null,
is_derived: item.is_derived ?? item.isDerived ?? null,
source_uri: item.source_uri || item.sourceURI || null,
species_ontology_term:
item.species_ontology_term
|| item.speciesOntologyTerm
|| (item as { species?: { term?: string } }).species?.term
|| null,
species_ontology_termuri:
item.species_ontology_termuri
|| item.speciesOntologyTermURI
|| (item as { species?: { termURI?: string } }).species?.termURI
|| null,
source_germplasm_id:
item.source_germplasm_id
|| item.sourceGermplasmDbId
|| (item as { sourceGermplasm?: Array<{ germplasmDbId?: string }> }).sourceGermplasm?.[0]?.germplasmDbId
|| null,
source_germplasm_name:
item.source_germplasm_name
|| item.sourceGermplasmName
|| (item as { sourceGermplasm?: Array<{ germplasmName?: string }> }).sourceGermplasm?.[0]?.germplasmName
|| null,
});
const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({
...reference,
id: reference.referenceDbId || reference.id,
reference_name: reference.reference_name || reference.referenceName || null,
reference_set_id: reference.reference_set_id || reference.referenceSetDbId || null,
reference_set_name: reference.reference_set_name || reference.referenceSetName || null,
source_divergence: reference.source_divergence ?? reference.sourceDivergence ?? null,
});
const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({
...item,
id: item.referenceBasesDbId || item.id,
reference_id: item.reference_id || item.referenceDbId || null,
reference_name: item.reference_name || item.referenceName || null,
page_number: item.page_number ?? item.pageNumber ?? null,
});
const referenceSetBody = (payload: ReferenceSetPayload) => ({
referenceSetName: requiredText(payload.reference_set_name, "ReferenceSet 名称不能为空"),
assemblyPUI: optionalText(payload.assembly_pui),
description: optionalText(payload.description),
isDerived: optionalBoolean(payload.is_derived),
md5checksum: optionalText(payload.md5checksum),
sourceURI: optionalUrl(payload.source_uri, "来源 URI"),
species: optionalText(payload.species_ontology_term) || optionalUrl(payload.species_ontology_termuri, "物种本体 URI")
? {
term: optionalText(payload.species_ontology_term),
termURI: optionalUrl(payload.species_ontology_termuri, "物种本体 URI"),
}
: undefined,
sourceGermplasmDbId: optionalText(payload.source_germplasm_id),
});
const referenceBody = (payload: ReferencePayload) => ({
referenceName: requiredText(payload.reference_name, "Reference 名称不能为空"),
referenceSetDbId: requiredText(payload.reference_set_id, "ReferenceSet 不能为空"),
length: optionalNumber(payload.length),
md5checksum: optionalText(payload.md5checksum),
sourceDivergence: optionalNumber(payload.source_divergence),
});
const referenceBasesBody = (payload: ReferenceBasesPayload) => ({
referenceDbId: requiredText(payload.reference_id, "Reference 不能为空"),
pageNumber: optionalNumber(payload.page_number),
bases: validateBases(payload.bases),
});
const attachReferenceSetCounts = (
rows: ReferenceSetRecord[],
references: ReferenceRecord[],
variantSets: VariantSetResponse[],
) => rows.map((row) => ({
...row,
reference_count: references.filter((item) => item.reference_set_id === row.id).length,
variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length,
}));
const referenceSetRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets?page=0&pageSize=1000");
return response.result.data.map(mapReferenceSet);
});
const referenceRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references?page=0&pageSize=1000");
return response.result.data.map(mapReference);
});
const variantSetRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=1000");
return response.result.data;
});
const referenceBasesRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases?page=0&pageSize=1000");
return response.result.data.map(mapReferenceBases);
});
export function invalidateReferenceSetPageCache() {
referenceSetRowsLoader.invalidate();
referenceRowsLoader.invalidate();
variantSetRowsLoader.invalidate();
referenceBasesRowsLoader.invalidate();
}
export async function fetchReferenceSetRows(force = false): Promise<ReferenceSetRecord[]> {
const [referenceSets, references, variantSets] = await Promise.all([
referenceSetRowsLoader.load(force),
referenceRowsLoader.load(force),
variantSetRowsLoader.load(force),
]);
return attachReferenceSetCounts(referenceSets, references, variantSets);
}
export async function fetchReferenceRows(force = false): Promise<ReferenceRecord[]> {
return referenceRowsLoader.load(force);
}
export async function fetchReferenceBasesRows(force = false): Promise<ReferenceBasesRecord[]> {
return referenceBasesRowsLoader.load(force);
}
export async function fetchReferenceSetOptions(force = false): Promise<{
referenceSets: SelectOption[];
references: SelectOption[];
germplasm: SelectOption[];
}> {
const [sharedOptions, referenceSets, references] = await Promise.all([
loadDropdownBundle({ germplasms: true }, force),
referenceSetRowsLoader.load(force),
referenceRowsLoader.load(force),
]);
return {
germplasm: sharedOptions.germplasms,
referenceSets: referenceSets.map((item) => ({
value: item.id,
label: item.reference_set_name || item.id,
})),
references: references.map((item) => ({
value: item.id,
label: `${item.reference_name || item.id}${item.reference_set_name ? ` / ${item.reference_set_name}` : ""}`,
})),
};
}
export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise<ReferenceSetRecord> {
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets", {
method: "POST",
body: JSON.stringify({
referenceSetDbId: requiredText(payload.id, "ReferenceSet ID 不能为空"),
...referenceSetBody(payload),
}),
});
invalidateReferenceSetPageCache();
return mapReferenceSet(response.result.data[0]);
}
export async function updateReferenceSetRow(id: string, payload: ReferenceSetPayload): Promise<ReferenceSetRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("ReferenceSet ID 不可修改,请新建记录");
}
const response = await request<BrapiSingleResponse<ReferenceSetRecord>>(
`/brapi/v2/referencesets/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(referenceSetBody(payload)),
},
);
invalidateReferenceSetPageCache();
return mapReferenceSet(response.result);
}
export async function deleteReferenceSetRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ReferenceSetRecord>>(
`/brapi/v2/referencesets/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateReferenceSetPageCache();
}
export async function createReferenceRow(payload: ReferencePayload): Promise<ReferenceRecord> {
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references", {
method: "POST",
body: JSON.stringify({
referenceDbId: requiredText(payload.id, "Reference ID 不能为空"),
...referenceBody(payload),
}),
});
invalidateReferenceSetPageCache();
return mapReference(response.result.data[0]);
}
export async function updateReferenceRow(id: string, payload: ReferencePayload): Promise<ReferenceRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("Reference ID 不可修改,请新建记录");
}
const response = await request<BrapiSingleResponse<ReferenceRecord>>(
`/brapi/v2/references/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(referenceBody(payload)),
},
);
invalidateReferenceSetPageCache();
return mapReference(response.result);
}
export async function deleteReferenceRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ReferenceRecord>>(
`/brapi/v2/references/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateReferenceSetPageCache();
}
export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> {
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases", {
method: "POST",
body: JSON.stringify({
referenceBasesDbId: requiredText(payload.id, "ReferenceBases ID 不能为空"),
...referenceBasesBody(payload),
}),
});
invalidateReferenceSetPageCache();
return mapReferenceBases(response.result.data[0]);
}
export async function updateReferenceBasesRow(id: string, payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("ReferenceBases ID 不可修改,请新建记录");
}
const response = await request<BrapiSingleResponse<ReferenceBasesRecord>>(
`/brapi/v2/referencebases/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(referenceBasesBody(payload)),
},
);
invalidateReferenceSetPageCache();
return mapReferenceBases(response.result);
}
export async function deleteReferenceBasesRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ReferenceBasesRecord>>(
`/brapi/v2/referencebases/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateReferenceSetPageCache();
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { BookOpen, Dna, Layers } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createReferenceBasesRow,
createReferenceRow,
createReferenceSetRow,
deleteReferenceBasesRow,
deleteReferenceRow,
deleteReferenceSetRow,
fetchReferenceBasesRows,
fetchReferenceRows,
fetchReferenceSetOptions,
fetchReferenceSetRows,
updateReferenceBasesRow,
updateReferenceRow,
updateReferenceSetRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const booleanOptions: SelectOption[] = [
{ value: NONE_SELECT_VALUE, label: "不指定" },
{ value: "true", label: "是" },
{ value: "false", label: "否" },
];
const optionOrNone = (label: string, options: SelectOption[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
const boolLabel = (value: unknown) => {
if (value === true) return "是";
if (value === false) return "否";
return "N/A";
};
const truncateBases = (value: unknown) => {
const text = String(value ?? "").trim();
if (!text) return "N/A";
return text.length > 32 ? `${text.slice(0, 32)}` : text;
};
export default function ReferenceSetPage() {
const [tab, setTab] = useState("reference-sets");
const [referenceSetOptions, setReferenceSetOptions] = useState<SelectOption[]>([]);
const [referenceOptions, setReferenceOptions] = useState<SelectOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const applyOptions = useCallback((options: Awaited<ReturnType<typeof fetchReferenceSetOptions>>) => {
setReferenceSetOptions(options.referenceSets);
setReferenceOptions(options.references);
setGermplasmOptions(options.germplasm);
return options;
}, []);
const refreshOptions = useCallback(async (force = false) => {
const options = await fetchReferenceSetOptions(force);
applyOptions(options);
return options;
}, [applyOptions]);
useEffect(() => {
let mounted = true;
refreshOptions()
.catch(() => undefined)
.finally(() => {
if (!mounted) return;
});
return () => {
mounted = false;
};
}, [refreshOptions]);
const loadReferenceSets = useCallback(async () => {
const rows = await fetchReferenceSetRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadReferences = useCallback(async () => {
const rows = await fetchReferenceRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadReferenceBases = useCallback(async () => {
const rows = await fetchReferenceBasesRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const refreshAfterMutation = useCallback(async <T,>(action: () => Promise<T>) => {
const result = await action();
await refreshOptions(true);
return result;
}, [refreshOptions]);
const referenceSetFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "ReferenceSet ID", type: "text", required: true, placeholder: "refset-001" },
{
key: "reference_set_name",
label: "参考集合名称",
type: "text",
required: true,
placeholder: "Maize B73 v4",
},
{ key: "assembly_pui", label: "Assembly PUI", type: "text", placeholder: "GA4GH 永久标识" },
{ key: "description", label: "说明", type: "textarea", colSpan: 2, placeholder: "参考集合说明" },
{ key: "is_derived", label: "是否派生参考", type: "select", options: booleanOptions },
{ key: "md5checksum", label: "MD5 校验值", type: "text", placeholder: "md5 checksum" },
{ key: "source_uri", label: "来源 URI", type: "text", placeholder: "https://..." },
{ key: "species_ontology_term", label: "物种本体 Term", type: "text", placeholder: "Zea mays" },
{ key: "species_ontology_termuri", label: "物种本体 URI", type: "text", placeholder: "https://..." },
{
key: "source_germplasm_id",
label: "来源 Germplasm",
type: "select",
options: optionOrNone("不关联 Germplasm", germplasmOptions),
},
], [germplasmOptions]);
const referenceFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Reference ID", type: "text", required: true, placeholder: "reference-001" },
{ key: "reference_name", label: "参考序列名称", type: "text", required: true, placeholder: "chr1" },
{
key: "reference_set_id",
label: "ReferenceSet",
type: "select",
required: true,
options: referenceSetOptions,
},
{ key: "length", label: "序列长度", type: "number", placeholder: "1000000" },
{ key: "source_divergence", label: "来源差异", type: "number", placeholder: "0.01" },
{ key: "md5checksum", label: "MD5", type: "text", placeholder: "md5 checksum", colSpan: 2 },
], [referenceSetOptions]);
const referenceBasesFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "ReferenceBases ID", type: "text", required: true, placeholder: "refbases-001" },
{
key: "reference_id",
label: "Reference",
type: "select",
required: true,
options: referenceOptions,
},
{ key: "page_number", label: "分页序号", type: "number", required: true, placeholder: "0" },
{
key: "bases",
label: "碱基序列片段",
type: "textarea",
required: true,
colSpan: 2,
placeholder: "ACGT...(最多 2048 字符)",
},
], [referenceOptions]);
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="reference-sets" className="gap-2">
<Layers className="h-4 w-4" />
ReferenceSet
</TabsTrigger>
<TabsTrigger value="references" className="gap-2">
<BookOpen className="h-4 w-4" />
Reference
</TabsTrigger>
<TabsTrigger value="reference-bases" className="gap-2">
<Dna className="h-4 w-4" />
ReferenceBases
</TabsTrigger>
</TabsList>
{tab === "reference-sets" ? (
<TabsContent value="reference-sets" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Layers}
iconBg="bg-gradient-to-br from-indigo-500 to-violet-600"
title="ReferenceSet 参考基因组集合"
description="维护参考基因组集合、Assembly 标识、物种信息和来源 Germplasm。"
addLabel="新增 ReferenceSet"
columns={[
{ key: "referenceSetDbId", label: "ReferenceSet ID" },
{ key: "reference_set_name", label: "名称" },
{ key: "assembly_pui", label: "Assembly PUI" },
{ key: "species_ontology_term", label: "物种" },
{ key: "source_germplasm_name", label: "来源 Germplasm" },
{ key: "reference_count", label: "Reference 数" },
{ key: "variantset_count", label: "VariantSet 数" },
{ key: "is_derived", label: "派生", render: boolLabel },
]}
fields={referenceSetFields}
data={[]}
stats={[{
label: "/brapi/v2/referencesets",
value: "BrAPI",
className: "bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-200",
}]}
loadData={loadReferenceSets}
createRecord={(payload) => refreshAfterMutation(() => createReferenceSetRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceSetRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceSetRow(id);
await refreshOptions(true);
}}
/>
</TabsContent>
) : null}
{tab === "references" ? (
<TabsContent value="references" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={BookOpen}
iconBg="bg-gradient-to-br from-emerald-500 to-teal-600"
title="Reference 参考序列"
description="维护 ReferenceSet 下的染色体、Contig 或 Scaffold 等参考序列。"
addLabel="新增 Reference"
columns={[
{ key: "referenceDbId", label: "Reference ID" },
{ key: "reference_name", label: "序列名称" },
{ key: "reference_set_name", label: "ReferenceSet" },
{ key: "length", label: "长度" },
{ key: "source_divergence", label: "来源差异" },
{ key: "md5checksum", label: "MD5" },
]}
fields={referenceFields}
data={[]}
stats={[{
label: "/brapi/v2/references",
value: "BrAPI",
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
}]}
loadData={loadReferences}
createRecord={(payload) => refreshAfterMutation(() => createReferenceRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceRow(id);
await refreshOptions(true);
}}
/>
</TabsContent>
) : null}
{tab === "reference-bases" ? (
<TabsContent value="reference-bases" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Dna}
iconBg="bg-gradient-to-br from-cyan-500 to-blue-600"
title="ReferenceBases 序列片段"
description="按 Reference 分页维护碱基序列片段,适合导入或补录分页内容。"
addLabel="新增 ReferenceBases"
columns={[
{ key: "referenceBasesDbId", label: "ID" },
{ key: "reference_name", label: "Reference" },
{ key: "page_number", label: "分页序号" },
{ key: "bases", label: "碱基片段", render: truncateBases },
]}
fields={referenceBasesFields}
data={[]}
stats={[{
label: "/brapi/v2/referencebases",
value: "Admin",
className: "bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200",
}]}
loadData={loadReferenceBases}
createRecord={(payload) => refreshAfterMutation(() => createReferenceBasesRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceBasesRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceBasesRow(id);
await refreshOptions(true);
}}
/>
</TabsContent>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,58 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface ReferenceSetRecord {
id: string;
referenceSetDbId: string;
referenceSetName: string | null;
reference_set_name: string | null;
assemblyPUI: string | null;
assembly_pui: string | null;
description: string | null;
isDerived: boolean | null;
is_derived: boolean | null;
md5checksum: string | null;
sourceURI: string | null;
source_uri: string | null;
speciesOntologyTerm: string | null;
species_ontology_term: string | null;
speciesOntologyTermURI: string | null;
species_ontology_termuri: string | null;
sourceGermplasmDbId: string | null;
source_germplasm_id: string | null;
sourceGermplasmName: string | null;
source_germplasm_name: string | null;
reference_count?: number | null;
variantset_count?: number | null;
}
export interface ReferenceRecord {
id: string;
referenceDbId: string;
referenceName: string | null;
reference_name: string | null;
referenceSetDbId: string | null;
reference_set_id: string | null;
referenceSetName: string | null;
reference_set_name: string | null;
length: number | string | null;
md5checksum: string | null;
sourceDivergence: number | string | null;
source_divergence: number | string | null;
}
export interface ReferenceBasesRecord {
id: string;
referenceBasesDbId: string;
reference_id: string | null;
referenceDbId: string | null;
reference_name: string | null;
referenceName: string | null;
page_number: number | string | null;
pageNumber: number | string | null;
bases: string | null;
}

View File

@@ -0,0 +1,689 @@
import { toBrapiIsoDateTime } from "@/lib/brapiIso";
import {
createCachedLoader,
loadDropdownBundle,
} from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import {
normalizePlateRow,
type StudyContextOption,
type TrialContextOption,
validatePlateCoreContext,
validatePlateWell,
} from "./plateUtils";
import {
resolveSampleWell,
validateSamplePayload,
type ObservationUnitContextOption,
} from "./sampleUtils";
import {
NONE_SELECT_VALUE,
type PlateQuery,
type PlateRecord,
type SampleQuery,
type SampleRecord,
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;
}
interface StudyResponse {
studyDbId: string;
studyName: string | null;
trialDbId: string | null;
programDbId: string | null;
}
interface TrialResponse {
trialDbId: string;
trialName: string | null;
programDbId: string | null;
}
type PlatePayload = Partial<Record<
| "id"
| "plate_name"
| "plate_barcode"
| "plate_format"
| "sample_type"
| "program_id"
| "trial_id"
| "study_id",
unknown
>>;
type SamplePayload = Partial<Record<
| "id"
| "sample_name"
| "sample_barcode"
| "sample_pui"
| "sample_group_db_id"
| "sample_type"
| "tissue_type"
| "sample_description"
| "sample_timestamp"
| "taken_by"
| "study_id"
| "trial_id"
| "program_id"
| "germplasm_id"
| "observation_unit_id"
| "plate_id"
| "row"
| "column"
| "well",
unknown
>>;
interface ObservationUnitResponse {
observationUnitDbId: string;
observationUnitName: string | null;
studyDbId: string | null;
}
interface CallSetListResponse {
metadata: {
pagination: BrapiPagination;
};
result: {
data: Array<Record<string, unknown>>;
};
}
export interface SamplePlateOptions {
programs: SelectOption[];
trials: TrialContextOption[];
studies: StudyContextOption[];
germplasm: SelectOption[];
observationUnits: ObservationUnitContextOption[];
plates: SelectOption[];
people: SelectOption[];
}
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>;
}
export const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
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 trialContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<TrialResponse>>("/brapi/v2/trials?page=0&pageSize=1000");
return response.result.data.map((trial) => ({
value: trial.trialDbId,
label: trial.trialName || trial.trialDbId,
programDbId: trial.programDbId ?? null,
}));
});
const studyContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=1000");
return response.result.data.map((study) => ({
value: study.studyDbId,
label: study.studyName || study.studyDbId,
trialDbId: study.trialDbId ?? null,
programDbId: study.programDbId ?? null,
}));
});
const observationUnitContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ObservationUnitResponse>>(
"/brapi/v2/observationunits?page=0&pageSize=1000",
);
return response.result.data.map((unit) => ({
value: unit.observationUnitDbId,
label: unit.observationUnitName || unit.observationUnitDbId,
studyDbId: unit.studyDbId ?? null,
}));
});
const plateRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<PlateRecord>>("/brapi/v2/plates?page=0&pageSize=1000");
return response.result.data.map(mapPlate);
});
const sampleRowsAllLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SampleRecord>>("/brapi/v2/samples?page=0&pageSize=1000");
return response.result.data.map(mapSample);
});
export function invalidatePlateRowsCache() {
plateRowsLoader.invalidate();
}
export function invalidateSampleRowsCache() {
sampleRowsAllLoader.invalidate();
}
function hasPlateServerFilter(query?: PlateQuery) {
return Boolean(
(query?.program_id && query.program_id !== NONE_SELECT_VALUE)
|| (query?.trial_id && query.trial_id !== NONE_SELECT_VALUE)
|| (query?.study_id && query.study_id !== NONE_SELECT_VALUE)
|| query?.plate_name,
);
}
function hasSampleServerFilter(query?: SampleQuery, plateDbId?: string) {
return Boolean(
plateDbId
|| query?.sample_name
|| (query?.program_id && query.program_id !== NONE_SELECT_VALUE)
|| (query?.trial_id && query.trial_id !== NONE_SELECT_VALUE)
|| (query?.study_id && query.study_id !== NONE_SELECT_VALUE)
|| (query?.plate_id && query.plate_id !== NONE_SELECT_VALUE)
|| (query?.observation_unit_id && query.observation_unit_id !== NONE_SELECT_VALUE),
);
}
async function loadRawPlates(query?: PlateQuery, force = false): Promise<PlateRecord[]> {
if (hasPlateServerFilter(query)) {
const params = new URLSearchParams({ page: "0", pageSize: "1000" });
if (query?.program_id && query.program_id !== NONE_SELECT_VALUE) params.set("programDbId", query.program_id);
if (query?.trial_id && query.trial_id !== NONE_SELECT_VALUE) params.set("trialDbId", query.trial_id);
if (query?.study_id && query.study_id !== NONE_SELECT_VALUE) params.set("studyDbId", query.study_id);
if (query?.plate_name) params.set("plateName", query.plate_name);
const response = await request<BrapiListResponse<PlateRecord>>(`/brapi/v2/plates?${params.toString()}`);
return response.result.data.map(mapPlate);
}
return plateRowsLoader.load(force);
}
async function loadRawSamples(query?: SampleQuery, plateDbId?: string, force = false): Promise<SampleRecord[]> {
if (hasSampleServerFilter(query, plateDbId)) {
const params = new URLSearchParams({ page: "0", pageSize: "1000" });
const effectivePlateId = plateDbId || optionalText(query?.plate_id);
if (effectivePlateId) params.set("plateDbId", effectivePlateId);
if (query?.sample_name) params.set("sampleName", query.sample_name);
if (query?.study_id && query.study_id !== NONE_SELECT_VALUE) params.set("studyDbId", query.study_id);
if (query?.trial_id && query.trial_id !== NONE_SELECT_VALUE) params.set("trialDbId", query.trial_id);
if (query?.program_id && query.program_id !== NONE_SELECT_VALUE) params.set("programDbId", query.program_id);
if (query?.observation_unit_id && query.observation_unit_id !== NONE_SELECT_VALUE) {
params.set("observationUnitDbId", query.observation_unit_id);
}
const response = await request<BrapiListResponse<SampleRecord>>(`/brapi/v2/samples?${params.toString()}`);
return response.result.data.map(mapSample);
}
return sampleRowsAllLoader.load(force);
}
export const mapPlate = (plate: PlateRecord): PlateRecord => ({
...plate,
id: plate.plateDbId || plate.id,
plate_name: plate.plate_name || plate.plateName || null,
plate_barcode: plate.plate_barcode || plate.plateBarcode || null,
plate_format: plate.plate_format || plate.plateFormat || null,
sample_type: plate.sample_type || plate.sampleType || null,
program_id: plate.program_id || plate.programDbId || null,
trial_id: plate.trial_id || plate.trialDbId || null,
study_id: plate.study_id || plate.studyDbId || null,
});
export const mapSample = (sample: SampleRecord): SampleRecord => ({
...sample,
id: sample.sampleDbId || sample.id,
sample_name: sample.sample_name || sample.sampleName || null,
sample_barcode: sample.sample_barcode || sample.sampleBarcode || null,
sample_pui: sample.sample_pui || sample.samplePUI || null,
sample_group_db_id: sample.sample_group_db_id || sample.sampleGroupDbId || null,
sample_type: sample.sample_type || sample.sampleType || null,
tissue_type: sample.tissue_type || sample.tissueType || null,
sample_description: sample.sample_description || sample.sampleDescription || null,
sample_timestamp: sample.sample_timestamp || sample.sampleTimestamp || null,
taken_by: sample.taken_by || sample.takenBy || null,
study_id: sample.study_id || sample.studyDbId || null,
trial_id: sample.trial_id || sample.trialDbId || null,
program_id: sample.program_id || sample.programDbId || null,
germplasm_id: sample.germplasm_id || sample.germplasmDbId || null,
observation_unit_id: sample.observation_unit_id || sample.observationUnitDbId || null,
plate_id: sample.plate_id || sample.plateDbId || null,
plate_name: sample.plate_name || sample.plateName || null,
});
function enrichSampleRows(
samples: SampleRecord[],
options: Pick<SamplePlateOptions, "programs" | "trials" | "studies" | "plates">,
): SampleRecord[] {
const programLabel = new Map(options.programs.map((item) => [item.value, item.label]));
const trialLabel = new Map(options.trials.map((item) => [item.value, item.label]));
const studyLabel = new Map(options.studies.map((item) => [item.value, item.label]));
const plateLabel = new Map(options.plates.map((item) => [item.value, item.label]));
return samples.map((sample) => ({
...sample,
program_name: sample.program_id ? programLabel.get(sample.program_id) ?? sample.program_id : null,
trial_name: sample.trial_id ? trialLabel.get(sample.trial_id) ?? sample.trial_id : null,
study_name: sample.study_id ? studyLabel.get(sample.study_id) ?? sample.study_id : null,
plate_name: sample.plate_id ? plateLabel.get(sample.plate_id) ?? sample.plate_name ?? sample.plate_id : sample.plate_name ?? null,
}));
}
function filterSamples(samples: SampleRecord[], query: SampleQuery) {
const sampleName = optionalText(query.sample_name)?.toLowerCase();
const barcode = optionalText(query.sample_barcode)?.toLowerCase();
const programId = optionalText(query.program_id);
const trialId = optionalText(query.trial_id);
const studyId = optionalText(query.study_id);
const plateId = optionalText(query.plate_id);
const observationUnitId = optionalText(query.observation_unit_id);
return samples.filter((sample) => {
if (programId && sample.program_id !== programId) return false;
if (trialId && sample.trial_id !== trialId) return false;
if (studyId && sample.study_id !== studyId) return false;
if (plateId && sample.plate_id !== plateId) return false;
if (observationUnitId && sample.observation_unit_id !== observationUnitId) return false;
if (sampleName && !String(sample.sample_name ?? "").toLowerCase().includes(sampleName)) return false;
if (barcode && !String(sample.sample_barcode ?? "").toLowerCase().includes(barcode)) return false;
return true;
});
}
function enrichPlateRows(
plates: PlateRecord[],
options: Pick<SamplePlateOptions, "programs" | "trials" | "studies">,
samples: SampleRecord[],
): PlateRecord[] {
const programLabel = new Map(options.programs.map((item) => [item.value, item.label]));
const trialLabel = new Map(options.trials.map((item) => [item.value, item.label]));
const studyLabel = new Map(options.studies.map((item) => [item.value, item.label]));
const sampleCount = samples.reduce<Map<string, number>>((acc, sample) => {
const plateId = sample.plate_id;
if (!plateId) return acc;
acc.set(plateId, (acc.get(plateId) ?? 0) + 1);
return acc;
}, new Map());
return plates.map((plate) => ({
...plate,
program_name: plate.program_id ? programLabel.get(plate.program_id) ?? plate.program_id : null,
trial_name: plate.trial_id ? trialLabel.get(plate.trial_id) ?? plate.trial_id : null,
study_name: plate.study_id ? studyLabel.get(plate.study_id) ?? plate.study_id : null,
sample_count: sampleCount.get(plate.id) ?? 0,
}));
}
function filterPlates(plates: PlateRecord[], query: PlateQuery) {
const plateName = optionalText(query.plate_name)?.toLowerCase();
const barcode = optionalText(query.plate_barcode)?.toLowerCase();
const programId = optionalText(query.program_id);
const trialId = optionalText(query.trial_id);
const studyId = optionalText(query.study_id);
return plates.filter((plate) => {
if (programId && plate.program_id !== programId) return false;
if (trialId && plate.trial_id !== trialId) return false;
if (studyId && plate.study_id !== studyId) return false;
if (plateName && !String(plate.plate_name ?? "").toLowerCase().includes(plateName)) return false;
if (barcode && !String(plate.plate_barcode ?? "").toLowerCase().includes(barcode)) return false;
return true;
});
}
const plateBody = (payload: PlatePayload) => {
const body: Record<string, unknown> = {
plateName: requiredText(payload.plate_name, "请填写样本板名称"),
};
const plateBarcode = optionalText(payload.plate_barcode);
const plateFormat = optionalText(payload.plate_format);
const sampleType = optionalText(payload.sample_type);
const programDbId = optionalText(payload.program_id);
const trialDbId = optionalText(payload.trial_id);
const studyDbId = optionalText(payload.study_id);
if (plateBarcode) body.plateBarcode = plateBarcode;
if (plateFormat) body.plateFormat = plateFormat;
if (sampleType) body.sampleType = sampleType;
if (programDbId) body.programDbId = programDbId;
if (trialDbId) body.trialDbId = trialDbId;
if (studyDbId) body.studyDbId = studyDbId;
return body;
};
const sampleBody = (payload: SamplePayload, plateFormat?: string | null) => {
const row = optionalText(payload.row);
const column = optionalNumber(payload.column);
const well = resolveSampleWell(row, column, optionalText(payload.well));
validatePlateWell(plateFormat ?? null, row, column, well);
const body: Record<string, unknown> = {
sampleName: requiredText(payload.sample_name, "请填写样本名称"),
};
const optionalFields: Array<[string, unknown]> = [
["sampleBarcode", optionalText(payload.sample_barcode)],
["samplePUI", optionalText(payload.sample_pui)],
["sampleGroupDbId", optionalText(payload.sample_group_db_id)],
["sampleType", optionalText(payload.sample_type)],
["tissueType", optionalText(payload.tissue_type)],
["sampleDescription", optionalText(payload.sample_description)],
["sampleTimestamp", toBrapiIsoDateTime(payload.sample_timestamp)],
["takenBy", optionalText(payload.taken_by)],
["studyDbId", optionalText(payload.study_id)],
["trialDbId", optionalText(payload.trial_id)],
["programDbId", optionalText(payload.program_id)],
["germplasmDbId", optionalText(payload.germplasm_id)],
["observationUnitDbId", optionalText(payload.observation_unit_id)],
["plateDbId", optionalText(payload.plate_id)],
["row", row ? normalizePlateRow(row) : null],
["column", column],
["well", well],
];
optionalFields.forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== "") body[key] = value;
});
return body;
};
async function getContextOptions() {
const [trials, studies, sharedOptions] = await Promise.all([
trialContextLoader.load(),
studyContextLoader.load(),
loadDropdownBundle({ programs: true }),
]);
return { trials, studies, programs: sharedOptions.programs };
}
async function validatePlatePayload(payload: PlatePayload) {
const { trials, studies } = await getContextOptions();
validatePlateCoreContext(payload, trials, studies);
}
export async function loadSamplePlatePageData(params: {
plateQuery?: PlateQuery;
sampleQuery?: SampleQuery;
force?: boolean;
} = {}): Promise<{
options: SamplePlateOptions;
plates: PlateRecord[];
samples: SampleRecord[];
}> {
const force = params.force ?? false;
const options = await fetchSamplePlateOptions(force);
const [rawPlates, allSamples] = await Promise.all([
loadRawPlates(params.plateQuery, force),
sampleRowsAllLoader.load(force),
]);
let tableSamples = allSamples;
if (hasSampleServerFilter(params.sampleQuery)) {
tableSamples = await loadRawSamples(params.sampleQuery, undefined, force);
}
const filteredPlates = filterPlates(rawPlates, params.plateQuery ?? {});
const plates = enrichPlateRows(filteredPlates, options, allSamples);
const samples = enrichSampleRows(filterSamples(tableSamples, params.sampleQuery ?? {}), options);
return { options, plates, samples };
}
export async function fetchPlateRows(query?: PlateQuery, force = false): Promise<PlateRecord[]> {
const { plates } = await loadSamplePlatePageData({ plateQuery: query, force });
return plates;
}
export async function fetchPlateDetail(id: string): Promise<PlateRecord> {
const response = await request<BrapiSingleResponse<PlateRecord>>(
`/brapi/v2/plates/${encodeURIComponent(id)}`,
);
const [options, allSamples] = await Promise.all([
fetchSamplePlateOptions(),
sampleRowsAllLoader.load(),
]);
return enrichPlateRows([mapPlate(response.result)], options, allSamples)[0];
}
async function getSampleValidationContext() {
const [trials, studies, observationUnits, plates, existingSamples] = await Promise.all([
trialContextLoader.load(),
studyContextLoader.load(),
observationUnitContextLoader.load(),
plateRowsLoader.load(),
sampleRowsAllLoader.load(),
]);
return { trials, studies, observationUnits, plates, existingSamples };
}
function resolvePlateFormatFromContext(
plateId: string | null,
plates: PlateRecord[],
explicitFormat?: string | null,
) {
if (explicitFormat) return explicitFormat;
if (!plateId) return null;
return plates.find((plate) => plate.id === plateId)?.plate_format ?? null;
}
async function validateSamplePayloadRequest(
payload: SamplePayload,
options?: { plateFormat?: string | null; sampleDbId?: string | null },
) {
const context = await getSampleValidationContext();
const plateId = optionalText(payload.plate_id);
validateSamplePayload(payload, {
plateFormat: resolvePlateFormatFromContext(plateId, context.plates, options?.plateFormat),
trials: context.trials,
studies: context.studies,
observationUnits: context.observationUnits,
existingSamples: context.existingSamples,
sampleDbId: options?.sampleDbId,
});
}
export async function fetchSampleRows(
query?: SampleQuery,
plateDbId?: string,
preloadedOptions?: SamplePlateOptions,
): Promise<SampleRecord[]> {
const rawSamples = await loadRawSamples(query, plateDbId);
const options = preloadedOptions ?? await fetchSamplePlateOptions();
return enrichSampleRows(filterSamples(rawSamples, query ?? {}), options);
}
export async function fetchSampleDetail(id: string): Promise<SampleRecord> {
const response = await request<BrapiSingleResponse<SampleRecord>>(
`/brapi/v2/samples/${encodeURIComponent(id)}`,
);
const options = await fetchSamplePlateOptions();
return enrichSampleRows([mapSample(response.result)], options)[0];
}
export async function countCallsetsBySample(sampleDbId: string): Promise<number> {
const response = await request<CallSetListResponse>(
`/brapi/v2/callsets?sampleDbId=${encodeURIComponent(sampleDbId)}&page=0&pageSize=1`,
);
return response.metadata.pagination.totalCount ?? 0;
}
export async function fetchSamplePlateOptions(force = false): Promise<SamplePlateOptions> {
const [sharedOptions, trials, studies, plates, observationUnits] = await Promise.all([
loadDropdownBundle({
programs: true,
germplasms: true,
people: true,
}, force),
trialContextLoader.load(force),
studyContextLoader.load(force),
plateRowsLoader.load(force),
observationUnitContextLoader.load(force),
]);
const plateOptions = plates.map((plate) => ({
value: plate.id,
label: plate.plate_name || plate.id,
}));
return {
programs: sharedOptions.programs,
trials,
studies,
germplasm: sharedOptions.germplasms,
observationUnits,
plates: plateOptions,
people: sharedOptions.people,
};
}
export function normalizePlateFormData(record: PlateRecord): Record<string, unknown> {
return {
id: record.id,
plate_name: record.plate_name ?? "",
plate_barcode: record.plate_barcode ?? "",
plate_format: record.plate_format && record.plate_format !== NONE_SELECT_VALUE ? record.plate_format : NONE_SELECT_VALUE,
sample_type: record.sample_type && record.sample_type !== NONE_SELECT_VALUE ? record.sample_type : NONE_SELECT_VALUE,
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
trial_id: record.trial_id && record.trial_id !== NONE_SELECT_VALUE ? record.trial_id : NONE_SELECT_VALUE,
study_id: record.study_id && record.study_id !== NONE_SELECT_VALUE ? record.study_id : NONE_SELECT_VALUE,
};
}
export async function createPlateRow(payload: PlatePayload): Promise<PlateRecord> {
await validatePlatePayload(payload);
const response = await request<BrapiListResponse<PlateRecord>>("/brapi/v2/plates", {
method: "POST",
body: JSON.stringify([plateBody(payload)]),
});
invalidatePlateRowsCache();
return mapPlate(response.result.data[0]);
}
export async function updatePlateRow(id: string, payload: PlatePayload): Promise<PlateRecord> {
await validatePlatePayload(payload);
const response = await request<BrapiListResponse<PlateRecord>>("/brapi/v2/plates", {
method: "PUT",
body: JSON.stringify({ [id]: plateBody(payload) }),
});
invalidatePlateRowsCache();
return mapPlate(response.result.data[0]);
}
export async function countSamplesByPlate(id: string): Promise<number> {
const samples = await fetchSampleRows(undefined, id);
return samples.length;
}
export async function createSampleRow(payload: SamplePayload, plateFormat?: string | null): Promise<SampleRecord> {
await validateSamplePayloadRequest(payload, { plateFormat });
const response = await request<BrapiListResponse<SampleRecord>>("/brapi/v2/samples", {
method: "POST",
body: JSON.stringify([sampleBody(payload, plateFormat)]),
});
invalidateSampleRowsCache();
invalidatePlateRowsCache();
return mapSample(response.result.data[0]);
}
export async function updateSampleRow(id: string, payload: SamplePayload, plateFormat?: string | null): Promise<SampleRecord> {
await validateSamplePayloadRequest(payload, { plateFormat, sampleDbId: id });
const response = await request<BrapiListResponse<SampleRecord>>("/brapi/v2/samples", {
method: "PUT",
body: JSON.stringify({ [id]: sampleBody(payload, plateFormat) }),
});
invalidateSampleRowsCache();
invalidatePlateRowsCache();
return mapSample(response.result.data[0]);
}
export function normalizeSampleFormData(record: SampleRecord): Record<string, unknown> {
return {
id: record.id,
sample_name: record.sample_name ?? "",
sample_barcode: record.sample_barcode ?? "",
sample_pui: record.sample_pui ?? "",
sample_group_db_id: record.sample_group_db_id ?? "",
sample_type: record.sample_type && record.sample_type !== NONE_SELECT_VALUE ? record.sample_type : NONE_SELECT_VALUE,
tissue_type: record.tissue_type && record.tissue_type !== NONE_SELECT_VALUE ? record.tissue_type : NONE_SELECT_VALUE,
sample_description: record.sample_description ?? "",
sample_timestamp: record.sample_timestamp ?? "",
taken_by: record.taken_by && record.taken_by !== NONE_SELECT_VALUE ? record.taken_by : NONE_SELECT_VALUE,
study_id: record.study_id && record.study_id !== NONE_SELECT_VALUE ? record.study_id : NONE_SELECT_VALUE,
trial_id: record.trial_id && record.trial_id !== NONE_SELECT_VALUE ? record.trial_id : NONE_SELECT_VALUE,
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
germplasm_id: record.germplasm_id && record.germplasm_id !== NONE_SELECT_VALUE ? record.germplasm_id : NONE_SELECT_VALUE,
observation_unit_id: record.observation_unit_id && record.observation_unit_id !== NONE_SELECT_VALUE ? record.observation_unit_id : NONE_SELECT_VALUE,
plate_id: record.plate_id && record.plate_id !== NONE_SELECT_VALUE ? record.plate_id : NONE_SELECT_VALUE,
row: record.row ?? "",
column: record.column ?? "",
well: record.well ?? "",
};
}
export async function deleteSampleRow(id: string): Promise<void> {
const callsetCount = await countCallsetsBySample(id);
if (callsetCount > 0) {
throw new Error(`该样本已有 ${callsetCount} 个 CallSet 关联,无法删除。请先处理下游基因型数据。`);
}
throw new Error("BrAPI Samples 接口暂不支持 DELETE请在后端扩展删除能力后再启用");
}
export async function deletePlateRow(id: string): Promise<void> {
const count = await countSamplesByPlate(id);
if (count > 0) {
throw new Error(`该样本板下仍有 ${count} 个样本请先迁移或删除样本后再操作。BrAPI 暂不支持 DELETE /plates`);
}
throw new Error("BrAPI Plates 接口暂不支持 DELETE请在后端扩展删除能力后再启用");
}

View File

@@ -0,0 +1,233 @@
"use client";
import Link from "next/link";
import { useCallback, useMemo, useState } from "react";
import { ClipboardList, RotateCcw, Search } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
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 {
createPlateRow,
fetchPlateDetail,
loadSamplePlatePageData,
normalizePlateFormData,
updatePlateRow,
} from "../api";
import {
optionLabel,
optionOrNone,
PLATE_FORMAT_OPTIONS,
SAMPLE_TYPE_OPTIONS,
type StudyContextOption,
type TrialContextOption,
} from "../plateUtils";
import { NONE_SELECT_VALUE, type PlateQuery, type SelectOption } from "../types";
const emptyQuery = (): PlateQuery => ({
plate_name: "",
plate_barcode: "",
program_id: NONE_SELECT_VALUE,
trial_id: NONE_SELECT_VALUE,
study_id: NONE_SELECT_VALUE,
});
export function PlateTab() {
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [trialOptions, setTrialOptions] = useState<TrialContextOption[]>([]);
const [studyOptions, setStudyOptions] = useState<StudyContextOption[]>([]);
const [draftQuery, setDraftQuery] = useState<PlateQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<PlateQuery>(emptyQuery);
const applyOptions = useCallback((options: Awaited<ReturnType<typeof loadSamplePlatePageData>>["options"]) => {
setProgramOptions(options.programs);
setTrialOptions(options.trials);
setStudyOptions(options.studies);
return options;
}, []);
const loadRows = useCallback(async () => {
const { options, plates } = await loadSamplePlatePageData({ plateQuery: appliedQuery });
applyOptions(options);
return plates as unknown as Record<string, unknown>[];
}, [appliedQuery, applyOptions]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchPlateDetail(id);
return normalizePlateFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "plate_name", label: "样本板名称", type: "text", required: true, placeholder: "如 2026-华占-96孔板-01" },
{ key: "plate_barcode", label: "样本板条码", type: "text", placeholder: "扫码或手填,建议唯一" },
{ key: "plate_format", label: "板规格", type: "select", options: PLATE_FORMAT_OPTIONS },
{ key: "sample_type", label: "样本类型", type: "select", options: SAMPLE_TYPE_OPTIONS },
{
key: "program_id",
label: "所属项目",
type: "select",
options: optionOrNone("不关联项目", programOptions),
},
{
key: "trial_id",
label: "所属 Trial",
type: "select",
options: optionOrNone("不关联 Trial", trialOptions),
},
{
key: "study_id",
label: "所属 Study",
type: "select",
options: optionOrNone("不关联 Study", studyOptions),
},
], [programOptions, studyOptions, trialOptions]);
const renderFormExtra = useCallback(() => (
<div className="col-span-2 rounded-lg border border-amber-100 bg-amber-50/60 p-3 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-100">
<p className="font-medium">Core </p>
<p className="mt-1 text-amber-800/80 dark:text-amber-200/80">
Program / Trial / Study 96 //AH 112
</p>
</div>
), []);
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 xl:grid-cols-5">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.plate_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, plate_name: event.target.value }))}
placeholder="plateName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.plate_barcode ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, plate_barcode: event.target.value }))}
placeholder="barcode 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.program_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部项目", programOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Trial</Label>
<Select
value={draftQuery.trial_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, trial_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部 Trial", trialOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Study</Label>
<Select
value={draftQuery.study_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, study_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部 Study", studyOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end 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, studyOptions, trialOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={ClipboardList}
iconBg="bg-gradient-to-br from-orange-500 to-amber-600"
title="样本板管理"
description="管理送检前的样本板:条码、规格、样本类型及 Program / Trial / Study 归属。点击进入详情可维护下属样本。"
addLabel="新建样本板"
columns={[
{
key: "plate_name",
label: "样本板名称",
render: (value, row) => {
const id = String(row.id ?? row.plateDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link href={`/genotyping/sample-plate/plates/${encodeURIComponent(id)}`} className="font-medium text-orange-600 hover:underline dark:text-orange-400">
{name}
</Link>
);
},
},
{ key: "plate_barcode", label: "条码" },
{ key: "plate_format", label: "规格", render: (value) => optionLabel(PLATE_FORMAT_OPTIONS, value) },
{ key: "sample_type", label: "样本类型", render: (value) => optionLabel(SAMPLE_TYPE_OPTIONS, value) },
{ key: "program_name", label: "项目" },
{ key: "trial_name", label: "Trial" },
{ key: "study_name", label: "Study" },
{
key: "sample_count",
label: "样本数",
render: (value) => {
const count = Number(value ?? 0);
return (
<Badge variant="outline" className={count > 0 ? "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200" : ""}>
{count}
</Badge>
);
},
},
]}
fields={fields}
data={[]}
stats={[{ label: "/brapi/v2/plates", value: "BrAPI", className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200" }]}
loadData={loadRows}
fetchRecord={fetchRecord}
createRecord={(payload) => createPlateRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updatePlateRow(id, payload) as unknown as Promise<Record<string, unknown>>}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { RotateCcw, Search, TestTube } 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 {
createSampleRow,
deleteSampleRow,
fetchSampleDetail,
loadSamplePlatePageData,
normalizeSampleFormData,
updateSampleRow,
} from "../api";
import { optionLabel, optionOrNone, SAMPLE_TYPE_OPTIONS } from "../plateUtils";
import { TISSUE_TYPE_OPTIONS, type ObservationUnitContextOption } from "../sampleUtils";
import { NONE_SELECT_VALUE, type PlateRecord, type SampleQuery, type SelectOption } from "../types";
import { buildSampleFields } from "./sampleFormConfig";
import type { StudyContextOption, TrialContextOption } from "../plateUtils";
const emptyQuery = (): SampleQuery => ({
sample_name: "",
sample_barcode: "",
program_id: NONE_SELECT_VALUE,
trial_id: NONE_SELECT_VALUE,
study_id: NONE_SELECT_VALUE,
plate_id: NONE_SELECT_VALUE,
observation_unit_id: NONE_SELECT_VALUE,
});
function toSelectValue(value: string | null) {
return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE;
}
export function SampleTab() {
const searchParams = useSearchParams();
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [trialOptions, setTrialOptions] = useState<TrialContextOption[]>([]);
const [studyOptions, setStudyOptions] = useState<StudyContextOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [observationUnitOptions, setObservationUnitOptions] = useState<ObservationUnitContextOption[]>([]);
const [plateOptions, setPlateOptions] = useState<SelectOption[]>([]);
const [personOptions, setPersonOptions] = useState<SelectOption[]>([]);
const [plates, setPlates] = useState<PlateRecord[]>([]);
const [draftQuery, setDraftQuery] = useState<SampleQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<SampleQuery>(emptyQuery);
const urlDefaultFormValues = useMemo(() => {
const values: Record<string, unknown> = {};
const studyId = searchParams.get("study_id");
const trialId = searchParams.get("trial_id");
const programId = searchParams.get("program_id");
const observationUnitId = searchParams.get("observation_unit_id");
const plateId = searchParams.get("plate_id");
if (studyId) values.study_id = studyId;
if (trialId) values.trial_id = trialId;
if (programId) values.program_id = programId;
if (observationUnitId) values.observation_unit_id = observationUnitId;
if (plateId) values.plate_id = plateId;
return Object.keys(values).length > 0 ? values : undefined;
}, [searchParams]);
const applyOptions = useCallback((
options: Awaited<ReturnType<typeof loadSamplePlatePageData>>["options"],
plateRows: PlateRecord[],
) => {
setProgramOptions(options.programs);
setTrialOptions(options.trials);
setStudyOptions(options.studies);
setGermplasmOptions(options.germplasm);
setObservationUnitOptions(options.observationUnits);
setPlateOptions(options.plates);
setPersonOptions(options.people);
setPlates(plateRows);
return options;
}, []);
const loadRows = useCallback(async () => {
const { options, plates, samples } = await loadSamplePlatePageData({ sampleQuery: appliedQuery });
applyOptions(options, plates);
return samples as unknown as Record<string, unknown>[];
}, [appliedQuery, applyOptions]);
const resolvePlateFormat = useCallback((plateId: unknown) => {
const id = String(plateId ?? "").trim();
if (!id || id === NONE_SELECT_VALUE) return null;
return plates.find((plate) => plate.id === id)?.plate_format ?? null;
}, [plates]);
const formOptions = useMemo(() => ({
programOptions,
trialOptions,
studyOptions,
germplasmOptions,
observationUnitOptions,
plateOptions,
personOptions,
plates,
}), [germplasmOptions, observationUnitOptions, personOptions, plateOptions, plates, programOptions, studyOptions, trialOptions]);
const fields = useMemo<BrapiFormField[]>(() => buildSampleFields(formOptions), [formOptions]);
const renderFormExtra = useCallback(() => (
<div className="col-span-2 rounded-lg border border-rose-100 bg-rose-50/60 p-3 text-xs text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100">
<p className="font-medium"></p>
<p className="mt-1 text-rose-800/80 dark:text-rose-200/80">
Study Study Program / Trial / Study
//+
CallSetBrAPI DELETE
</p>
</div>
), []);
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 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.sample_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, sample_name: event.target.value }))}
placeholder="sampleName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.sample_barcode ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, sample_barcode: event.target.value }))}
placeholder="barcode 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select value={toSelectValue(draftQuery.program_id ?? null)} onValueChange={(value) => setDraftQuery((current) => ({ ...current, program_id: value }))}>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部项目", programOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Trial</Label>
<Select value={toSelectValue(draftQuery.trial_id ?? null)} onValueChange={(value) => setDraftQuery((current) => ({ ...current, trial_id: value }))}>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部 Trial", trialOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Study</Label>
<Select value={toSelectValue(draftQuery.study_id ?? null)} onValueChange={(value) => setDraftQuery((current) => ({ ...current, study_id: value }))}>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部 Study", studyOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select value={toSelectValue(draftQuery.plate_id ?? null)} onValueChange={(value) => setDraftQuery((current) => ({ ...current, plate_id: value }))}>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部样本板", plateOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 xl:col-span-2">
<Label className="text-xs text-slate-500"></Label>
<Select value={toSelectValue(draftQuery.observation_unit_id ?? null)} onValueChange={(value) => setDraftQuery((current) => ({ ...current, observation_unit_id: value }))}>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
{optionOrNone("全部观测单元", observationUnitOptions).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end 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, observationUnitOptions, plateOptions, programOptions, studyOptions, trialOptions]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchSampleDetail(id);
return normalizeSampleFormData(detail);
}, []);
return (
<BrapiEntityPage
useEnhancedDialog
icon={TestTube}
iconBg="bg-gradient-to-br from-rose-500 to-red-600"
title="样本管理"
description="管理送检样本:条码、组织类型、观测单元来源、样本板孔位及 Core 冗余字段。"
addLabel="新建样本"
defaultFormValues={urlDefaultFormValues}
columns={[
{ key: "sample_name", label: "样本名称" },
{ key: "sample_barcode", label: "条码" },
{ key: "sample_type", label: "类型", render: (value) => optionLabel(SAMPLE_TYPE_OPTIONS, value) },
{ key: "tissue_type", label: "组织", render: (value) => optionLabel(TISSUE_TYPE_OPTIONS, value) || String(value ?? "—") },
{ key: "program_name", label: "项目" },
{ key: "trial_name", label: "Trial" },
{ key: "study_name", label: "Study" },
{ key: "plate_name", label: "样本板" },
{ key: "observation_unit_id", label: "观测单元", render: (value) => optionLabel(observationUnitOptions, value) },
{ key: "well", label: "孔位" },
]}
fields={fields}
data={[]}
stats={[{ label: "/brapi/v2/samples", value: "BrAPI", className: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-200" }]}
loadData={loadRows}
fetchRecord={fetchRecord}
createRecord={(payload) => createSampleRow(payload, resolvePlateFormat(payload.plate_id)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateSampleRow(id, payload, resolvePlateFormat(payload.plate_id)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteSampleRow}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -0,0 +1,159 @@
import type { BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
optionOrNone,
resolveContextFromStudy,
resolveContextFromTrial,
SAMPLE_TYPE_OPTIONS,
type StudyContextOption,
type TrialContextOption,
} from "../plateUtils";
import {
buildWellFromRowColumn,
resolveContextFromObservationUnit,
resolveContextFromPlate,
TISSUE_TYPE_OPTIONS,
type ObservationUnitContextOption,
} from "../sampleUtils";
import { NONE_SELECT_VALUE, type PlateRecord, type SelectOption } from "../types";
export interface SampleFormOptions {
programOptions: SelectOption[];
trialOptions: TrialContextOption[];
studyOptions: StudyContextOption[];
germplasmOptions: SelectOption[];
observationUnitOptions: ObservationUnitContextOption[];
plateOptions: SelectOption[];
personOptions: SelectOption[];
plates: PlateRecord[];
hidePlateField?: boolean;
}
export function buildSampleFields(options: SampleFormOptions): BrapiFormField[] {
const fields: BrapiFormField[] = [
{ key: "sample_name", label: "样本名称", type: "text", required: true, placeholder: "如 华占-叶样-001" },
{ key: "sample_barcode", label: "样本条码", type: "text", placeholder: "扫码或手填,建议唯一" },
{ key: "sample_pui", label: "样本永久标识 (PUI)", type: "text", placeholder: "DOI / URL / UUID" },
{ key: "sample_group_db_id", label: "样本分组 ID", type: "text", placeholder: "可选" },
{ key: "sample_type", label: "样本类型", type: "select", options: SAMPLE_TYPE_OPTIONS },
{ key: "tissue_type", label: "组织类型", type: "select", options: TISSUE_TYPE_OPTIONS },
{ key: "sample_timestamp", label: "取样时间", type: "date" },
{
key: "taken_by",
label: "取样人",
type: "select",
options: optionOrNone("未指定", options.personOptions),
},
{
key: "program_id",
label: "所属项目",
type: "select",
options: optionOrNone("不关联项目", options.programOptions),
},
{
key: "trial_id",
label: "所属 Trial",
type: "select",
options: optionOrNone("不关联 Trial", options.trialOptions),
},
{
key: "study_id",
label: "所属 Study",
type: "select",
options: optionOrNone("不关联 Study", options.studyOptions),
},
{
key: "germplasm_id",
label: "种质",
type: "select",
options: optionOrNone("不关联种质", options.germplasmOptions),
},
{
key: "observation_unit_id",
label: "观测单元",
type: "select",
options: optionOrNone("不关联观测单元", options.observationUnitOptions),
},
];
if (!options.hidePlateField) {
fields.push({
key: "plate_id",
label: "所在样本板",
type: "select",
options: optionOrNone("未上板", options.plateOptions),
});
}
fields.push(
{ key: "row", label: "板行", type: "text", placeholder: "AH96 孔板)" },
{ key: "column", label: "板列", type: "number", placeholder: "112" },
{ key: "well", label: "孔位", type: "text", placeholder: "如 A01可留空由行/列自动生成" },
{ key: "sample_description", label: "样本说明", type: "textarea", colSpan: 2 },
);
return fields;
}
export function createSampleFormExtraHandlers(options: SampleFormOptions) {
return (props: {
formData: Record<string, unknown>;
updateForm: (key: string, value: string) => void;
updateFormBatch: (patch: Record<string, unknown>) => void;
}) => {
const handleStudyChange = (value: string) => {
const context = resolveContextFromStudy(value === NONE_SELECT_VALUE ? null : value, options.studyOptions);
props.updateFormBatch({
study_id: value,
trial_id: context.trial_id,
program_id: context.program_id,
});
};
const handleTrialChange = (value: string) => {
const context = resolveContextFromTrial(value === NONE_SELECT_VALUE ? null : value, options.trialOptions);
props.updateFormBatch({
trial_id: value,
program_id: context.program_id,
});
};
const handlePlateChange = (value: string) => {
const plateId = value === NONE_SELECT_VALUE ? null : value;
const context = resolveContextFromPlate(plateId, options.plates);
props.updateFormBatch({
plate_id: value,
program_id: context.program_id,
trial_id: context.trial_id,
study_id: context.study_id,
});
};
const handleObservationUnitChange = (value: string) => {
const unitId = value === NONE_SELECT_VALUE ? null : value;
const context = resolveContextFromObservationUnit(unitId, options.observationUnitOptions);
props.updateFormBatch({
observation_unit_id: value,
study_id: context.study_id,
});
};
const handleRowColumnChange = (key: "row" | "column", value: string) => {
const nextRow = key === "row" ? value : String(props.formData.row ?? "");
const nextColumn = key === "column" ? value : String(props.formData.column ?? "");
const autoWell = buildWellFromRowColumn(nextRow, nextColumn);
if (autoWell && !String(props.formData.well ?? "").trim()) {
props.updateFormBatch({ [key]: value, well: autoWell });
} else {
props.updateForm(key, value);
}
};
return {
handleStudyChange,
handleTrialChange,
handlePlateChange,
handleObservationUnitChange,
handleRowColumnChange,
};
};
}

View File

@@ -0,0 +1,45 @@
"use client";
import { Suspense, useState } from "react";
import { ClipboardList, TestTube } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { PlateTab } from "./components/PlateTab";
import { SampleTab } from "./components/SampleTab";
function SampleTabFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />;
}
export default function SamplePlatePage() {
const [tab, setTab] = useState("plates");
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="plates" className="gap-2">
<ClipboardList className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="samples" className="gap-2">
<TestTube className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{tab === "plates" ? (
<TabsContent value="plates" className="mt-0 min-h-0 flex-1">
<PlateTab />
</TabsContent>
) : null}
{tab === "samples" ? (
<TabsContent value="samples" className="mt-0 min-h-0 flex-1">
<Suspense fallback={<SampleTabFallback />}>
<SampleTab />
</Suspense>
</TabsContent>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,130 @@
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
export const PLATE_FORMAT_OPTIONS: SelectOption[] = [
{ value: "PLATE_96", label: "96 孔板 (PLATE_96)" },
{ value: "TUBES", label: "试管/无板 (TUBES)" },
];
export const SAMPLE_TYPE_OPTIONS: SelectOption[] = [
{ value: "DNA", label: "DNA" },
{ value: "RNA", label: "RNA" },
{ value: "TISSUE", label: "TISSUE 组织" },
{ value: "MIXED", label: "MIXED 混合" },
];
export const PLATE_96_ROWS = ["A", "B", "C", "D", "E", "F", "G", "H"] as const;
export const PLATE_96_MAX_COLUMN = 12;
export interface TrialContextOption extends SelectOption {
programDbId: string | null;
}
export interface StudyContextOption extends SelectOption {
trialDbId: string | null;
programDbId: string | null;
}
export function optionOrNone(label: string, options: SelectOption[]) {
return [{ value: NONE_SELECT_VALUE, label }, ...options];
}
export function optionLabel(options: SelectOption[], value: unknown) {
const text = String(value ?? "").trim();
if (!text || text === NONE_SELECT_VALUE) return "—";
return options.find((option) => option.value === text)?.label || text;
}
export function normalizePlateRow(row: string | null | undefined) {
return String(row ?? "").trim().toUpperCase();
}
export function validatePlateWell(
plateFormat: string | null | undefined,
row: string | null | undefined,
column: number | string | null | undefined,
well: string | null | undefined,
) {
if (!plateFormat || plateFormat === "TUBES" || plateFormat === NONE_SELECT_VALUE) return;
if (plateFormat !== "PLATE_96") return;
const normalizedRow = normalizePlateRow(row);
const col = column === null || column === undefined || column === "" ? null : Number(column);
if (normalizedRow && !PLATE_96_ROWS.includes(normalizedRow as typeof PLATE_96_ROWS[number])) {
throw new Error(`96 孔板行号需在 AH当前为 ${normalizedRow}`);
}
if (col !== null && !Number.isNaN(col) && (col < 1 || col > PLATE_96_MAX_COLUMN)) {
throw new Error(`96 孔板列号需在 1${PLATE_96_MAX_COLUMN},当前为 ${col}`);
}
if (well) {
const match = /^([A-H])(\d{1,2})$/i.exec(well.trim());
if (match) {
const wellRow = match[1].toUpperCase();
const wellCol = Number(match[2]);
if (!PLATE_96_ROWS.includes(wellRow as typeof PLATE_96_ROWS[number]) || wellCol < 1 || wellCol > PLATE_96_MAX_COLUMN) {
throw new Error(`孔位 ${well} 超出 96 孔板规格 (A01H12)`);
}
}
}
}
export function validatePlateCoreContext(
payload: {
program_id?: unknown;
trial_id?: unknown;
study_id?: unknown;
},
trials: TrialContextOption[],
studies: StudyContextOption[],
) {
const programId = optionalId(payload.program_id);
const trialId = optionalId(payload.trial_id);
const studyId = optionalId(payload.study_id);
if (studyId) {
const study = studies.find((item) => item.value === studyId);
if (!study) throw new Error("所选 Study 不存在");
if (trialId && study.trialDbId && trialId !== study.trialDbId) {
throw new Error("Study 所属 Trial 与所选 Trial 不一致");
}
if (programId && study.programDbId && programId !== study.programDbId) {
throw new Error("Study 所属 Program 与所选 Program 不一致");
}
}
if (trialId) {
const trial = trials.find((item) => item.value === trialId);
if (!trial) throw new Error("所选 Trial 不存在");
if (programId && trial.programDbId && programId !== trial.programDbId) {
throw new Error("Trial 所属 Program 与所选 Program 不一致");
}
}
}
export function resolveContextFromStudy(
studyId: string | null,
studies: StudyContextOption[],
): { trial_id: string; program_id: string } {
if (!studyId) return { trial_id: NONE_SELECT_VALUE, program_id: NONE_SELECT_VALUE };
const study = studies.find((item) => item.value === studyId);
return {
trial_id: study?.trialDbId || NONE_SELECT_VALUE,
program_id: study?.programDbId || NONE_SELECT_VALUE,
};
}
export function resolveContextFromTrial(
trialId: string | null,
trials: TrialContextOption[],
): { program_id: string } {
if (!trialId) return { program_id: NONE_SELECT_VALUE };
const trial = trials.find((item) => item.value === trialId);
return { program_id: trial?.programDbId || NONE_SELECT_VALUE };
}
function optionalId(value: unknown) {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
}

View File

@@ -0,0 +1,195 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { ArrowLeft, ClipboardList, TestTube } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
createSampleRow,
fetchPlateDetail,
fetchSampleDetail,
fetchSamplePlateOptions,
fetchSampleRows,
normalizeSampleFormData,
updateSampleRow,
} from "../../api";
import { buildSampleFields } from "../../components/sampleFormConfig";
import {
optionLabel,
PLATE_FORMAT_OPTIONS,
SAMPLE_TYPE_OPTIONS,
} from "../../plateUtils";
import { TISSUE_TYPE_OPTIONS } from "../../sampleUtils";
import { NONE_SELECT_VALUE, type PlateRecord, type SelectOption } from "../../types";
import type { ObservationUnitContextOption } from "../../sampleUtils";
import type { StudyContextOption, TrialContextOption } from "../../plateUtils";
export default function PlateDetailPage() {
const params = useParams<{ plateDbId: string }>();
const plateDbId = decodeURIComponent(params.plateDbId);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [plate, setPlate] = useState<PlateRecord | null>(null);
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [trialOptions, setTrialOptions] = useState<TrialContextOption[]>([]);
const [studyOptions, setStudyOptions] = useState<StudyContextOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [observationUnitOptions, setObservationUnitOptions] = useState<ObservationUnitContextOption[]>([]);
const [personOptions, setPersonOptions] = useState<SelectOption[]>([]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
Promise.all([fetchPlateDetail(plateDbId), fetchSamplePlateOptions()])
.then(([detail, options]) => {
if (!mounted) return;
setPlate(detail);
setProgramOptions(options.programs);
setTrialOptions(options.trials);
setStudyOptions(options.studies);
setGermplasmOptions(options.germplasm);
setObservationUnitOptions(options.observationUnits);
setPersonOptions(options.people);
})
.catch((event) => {
if (!mounted) return;
setError(event instanceof Error ? event.message : "加载样本板详情失败");
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => { mounted = false; };
}, [plateDbId]);
const defaultFormValues = useMemo(() => {
if (!plate) return undefined;
return {
plate_id: plate.id,
study_id: plate.study_id && plate.study_id !== NONE_SELECT_VALUE ? plate.study_id : NONE_SELECT_VALUE,
trial_id: plate.trial_id && plate.trial_id !== NONE_SELECT_VALUE ? plate.trial_id : NONE_SELECT_VALUE,
program_id: plate.program_id && plate.program_id !== NONE_SELECT_VALUE ? plate.program_id : NONE_SELECT_VALUE,
sample_type: plate.sample_type && plate.sample_type !== NONE_SELECT_VALUE ? plate.sample_type : NONE_SELECT_VALUE,
};
}, [plate]);
const formOptions = useMemo(() => ({
programOptions,
trialOptions,
studyOptions,
germplasmOptions,
observationUnitOptions,
plateOptions: plate ? [{ value: plate.id, label: plate.plate_name || plate.id }] : [],
personOptions,
plates: plate ? [plate] : [],
hidePlateField: true,
}), [germplasmOptions, observationUnitOptions, personOptions, plate, programOptions, studyOptions, trialOptions]);
const fields = useMemo<BrapiFormField[]>(() => buildSampleFields(formOptions), [formOptions]);
const loadSamples = useCallback(async () => {
const rows = await fetchSampleRows(undefined, plateDbId);
return rows as unknown as Record<string, unknown>[];
}, [plateDbId]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchSampleDetail(id);
return normalizeSampleFormData({ ...detail, plate_id: plate?.id ?? detail.plate_id });
}, [plate?.id]);
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 || !plate) {
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="/genotyping/sample-plate"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/genotyping/sample-plate"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
{(plate.sample_count ?? 0) > 0 ? (
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
{plate.sample_count}
</Badge>
) : null}
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<ClipboardList className="h-5 w-5 text-orange-500" />
{plate.plate_name || plate.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Plate ID</span>{plate.id}</div>
<div><span className="text-slate-500"></span>{plate.plate_barcode || "—"}</div>
<div><span className="text-slate-500"></span>{optionLabel(PLATE_FORMAT_OPTIONS, plate.plate_format)}</div>
<div><span className="text-slate-500"></span>{optionLabel(SAMPLE_TYPE_OPTIONS, plate.sample_type)}</div>
<div><span className="text-slate-500"></span>{plate.program_name || "—"}</div>
<div><span className="text-slate-500">Trial</span>{plate.trial_name || "—"}</div>
<div><span className="text-slate-500">Study</span>{plate.study_name || "—"}</div>
<div><span className="text-slate-500"></span>{plate.sample_count ?? 0}</div>
</CardContent>
</Card>
<BrapiEntityPage
useEnhancedDialog
icon={TestTube}
iconBg="bg-gradient-to-br from-rose-500 to-red-600"
title="板内样本"
description="在此样本板下维护 sample新增时默认带出 plate_id 及 Core 上下文。"
addLabel="新增样本"
defaultFormValues={defaultFormValues}
columns={[
{ key: "sample_name", label: "样本名称" },
{ key: "sample_barcode", label: "条码" },
{ key: "sample_type", label: "类型", render: (value) => optionLabel(SAMPLE_TYPE_OPTIONS, value) },
{ key: "tissue_type", label: "组织", render: (value) => optionLabel(TISSUE_TYPE_OPTIONS, value) || String(value ?? "—") },
{ key: "well", label: "孔位" },
{ key: "row", label: "行" },
{ key: "column", label: "列" },
{ key: "observation_unit_id", label: "观测单元", render: (value) => optionLabel(observationUnitOptions, value) },
]}
fields={fields}
data={[]}
loadData={loadSamples}
fetchRecord={fetchRecord}
createRecord={(payload) => createSampleRow(
{ ...payload, plate_id: plate.id },
plate.plate_format,
) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateSampleRow(
id,
{ ...payload, plate_id: plate.id },
plate.plate_format,
) as unknown as Promise<Record<string, unknown>>}
/>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { NONE_SELECT_VALUE, type SampleRecord, type SelectOption } from "./types";
import {
normalizePlateRow,
validatePlateCoreContext,
validatePlateWell,
type StudyContextOption,
type TrialContextOption,
} from "./plateUtils";
export const TISSUE_TYPE_OPTIONS: SelectOption[] = [
{ value: "Leaf", label: "Leaf 叶" },
{ value: "Root", label: "Root 根" },
{ value: "Stem", label: "Stem 茎" },
{ value: "Seed", label: "Seed 种子" },
{ value: "Flower", label: "Flower 花" },
{ value: "Fruit", label: "Fruit 果" },
{ value: "Meristem", label: "Meristem 分生组织" },
];
export interface ObservationUnitContextOption extends SelectOption {
studyDbId: string | null;
}
export function buildWellFromRowColumn(
row: string | null | undefined,
column: number | string | null | undefined,
) {
const normalizedRow = normalizePlateRow(row);
const col = column === null || column === undefined || column === "" ? null : Number(column);
if (!normalizedRow || col === null || Number.isNaN(col)) return null;
return `${normalizedRow}${String(col).padStart(2, "0")}`;
}
export function resolveSampleWell(
row: string | null | undefined,
column: number | string | null | undefined,
well: string | null | undefined,
) {
const explicitWell = String(well ?? "").trim();
if (explicitWell) return explicitWell;
return buildWellFromRowColumn(row, column);
}
export function validateSampleObservationUnitStudy(
payload: { study_id?: unknown; observation_unit_id?: unknown },
observationUnits: ObservationUnitContextOption[],
) {
const studyId = optionalId(payload.study_id);
const observationUnitId = optionalId(payload.observation_unit_id);
if (!observationUnitId || !studyId) return;
const unit = observationUnits.find((item) => item.value === observationUnitId);
if (!unit) throw new Error("所选观测单元不存在");
if (unit.studyDbId && studyId !== unit.studyDbId) {
throw new Error("观测单元所属 Study 与样本所选 Study 不一致");
}
}
export function validateSampleWellUniqueness(
plateId: string,
well: string,
existingSamples: SampleRecord[],
excludeSampleId?: string | null,
) {
const normalizedWell = well.trim().toUpperCase();
const duplicate = existingSamples.find((sample) => {
if (excludeSampleId && sample.id === excludeSampleId) return false;
if (sample.plate_id !== plateId) return false;
const sampleWell = resolveSampleWell(sample.row, sample.column, sample.well);
return sampleWell?.trim().toUpperCase() === normalizedWell;
});
if (duplicate) {
throw new Error(`孔位 ${well} 在该样本板内已被样本「${duplicate.sample_name || duplicate.id}」占用`);
}
}
export function validateSamplePayload(
payload: {
program_id?: unknown;
trial_id?: unknown;
study_id?: unknown;
observation_unit_id?: unknown;
plate_id?: unknown;
row?: unknown;
column?: unknown;
well?: unknown;
},
context: {
plateFormat?: string | null;
trials: TrialContextOption[];
studies: StudyContextOption[];
observationUnits: ObservationUnitContextOption[];
existingSamples: SampleRecord[];
sampleDbId?: string | null;
},
) {
validatePlateCoreContext(payload, context.trials, context.studies);
validateSampleObservationUnitStudy(payload, context.observationUnits);
const plateId = optionalId(payload.plate_id);
const row = optionalText(payload.row);
const column = optionalNumber(payload.column);
const well = resolveSampleWell(row, column, optionalText(payload.well));
validatePlateWell(context.plateFormat ?? null, row, column, well);
if (plateId && well) {
validateSampleWellUniqueness(plateId, well, context.existingSamples, context.sampleDbId);
}
}
export function resolveContextFromPlate(
plateId: string | null,
plates: Array<{ id: string; program_id?: string | null; trial_id?: string | null; study_id?: string | null }>,
) {
if (!plateId) {
return {
program_id: NONE_SELECT_VALUE,
trial_id: NONE_SELECT_VALUE,
study_id: NONE_SELECT_VALUE,
};
}
const plate = plates.find((item) => item.id === plateId);
return {
program_id: plate?.program_id || NONE_SELECT_VALUE,
trial_id: plate?.trial_id || NONE_SELECT_VALUE,
study_id: plate?.study_id || NONE_SELECT_VALUE,
};
}
export function resolveContextFromObservationUnit(
observationUnitId: string | null,
observationUnits: ObservationUnitContextOption[],
) {
if (!observationUnitId) return { study_id: NONE_SELECT_VALUE };
const unit = observationUnits.find((item) => item.value === observationUnitId);
return { study_id: unit?.studyDbId || NONE_SELECT_VALUE };
}
function optionalId(value: unknown) {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
}
function optionalText(value: unknown) {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
}
function optionalNumber(value: unknown) {
const normalized = optionalText(value);
if (normalized === null) return null;
const parsed = Number(normalized);
return Number.isNaN(parsed) ? null : parsed;
}

View File

@@ -0,0 +1,90 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface PlateQuery {
plate_name?: string;
plate_barcode?: string;
program_id?: string;
trial_id?: string;
study_id?: string;
}
export interface PlateRecord {
id: string;
plateDbId: string;
plateName: string | null;
plateBarcode: string | null;
plateFormat: string | null;
sampleType: string | null;
programDbId: string | null;
trialDbId: string | null;
studyDbId: string | null;
plate_name: string | null;
plate_barcode: string | null;
plate_format: string | null;
sample_type: string | null;
program_id: string | null;
trial_id: string | null;
study_id: string | null;
program_name?: string | null;
trial_name?: string | null;
study_name?: string | null;
sample_count?: number;
}
export interface SampleQuery {
sample_name?: string;
sample_barcode?: string;
program_id?: string;
trial_id?: string;
study_id?: string;
plate_id?: string;
observation_unit_id?: string;
}
export interface SampleRecord {
id: string;
sampleDbId: string;
sampleName: string | null;
sampleBarcode: string | null;
samplePUI: string | null;
sampleGroupDbId: string | null;
sampleType: string | null;
tissueType: string | null;
sampleDescription: string | null;
sampleTimestamp: string | null;
takenBy: string | null;
studyDbId: string | null;
trialDbId: string | null;
programDbId: string | null;
germplasmDbId: string | null;
observationUnitDbId: string | null;
plateDbId: string | null;
plateName: string | null;
row: string | null;
column: number | string | null;
well: string | null;
sample_name: string | null;
sample_barcode: string | null;
sample_pui: string | null;
sample_group_db_id: string | null;
sample_type: string | null;
tissue_type: string | null;
sample_description: string | null;
sample_timestamp: string | null;
taken_by: string | null;
study_id: string | null;
trial_id: string | null;
program_id: string | null;
germplasm_id: string | null;
observation_unit_id: string | null;
plate_id: string | null;
plate_name: string | null;
program_name?: string | null;
trial_name?: string | null;
study_name?: string | null;
}

View File

@@ -0,0 +1,286 @@
import { getAuthToken } from "@/utils/token";
import {
NONE_SELECT_VALUE,
type CallRecord,
type SelectOption,
type VariantRecord,
} 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;
}
interface ReferenceSetResponse {
referenceSetDbId: string;
referenceSetName: string | null;
}
interface VariantSetResponse {
variantSetDbId: string;
variantSetName: string | null;
referenceSetDbId: string | null;
}
interface CallSetResponse {
callSetDbId: string;
callSetName: string | null;
sampleDbId: string | null;
sampleName: string | null;
}
type VariantPayload = Partial<Record<
| "id"
| "variant_name"
| "variant_type"
| "variant_set_id"
| "reference_set_id"
| "start"
| "end"
| "reference_bases"
| "svlen"
| "filters_applied"
| "filters_passed",
unknown
>>;
type CallPayload = Partial<Record<
| "id"
| "call_set_id"
| "variant_id"
| "genotype_text"
| "genotype_likelihood"
| "read_depth"
| "phase_set",
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 || `Request failed: ${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 requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const optionalNumber = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isNaN(parsed) ? null : parsed;
};
const optionalBoolean = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return null;
return ["true", "1", "pass", "passed"].includes(normalized.toLowerCase());
};
const genotypeToText = (value: unknown) => {
if (!value) return null;
if (typeof value === "string") return value;
if (typeof value === "object" && "values" in value) {
const values = (value as { values?: unknown[] }).values;
return Array.isArray(values) ? values.map((item) => String(item)).join("/") : null;
}
return null;
};
const mapVariant = (variant: VariantRecord): VariantRecord => ({
...variant,
id: variant.variantDbId || variant.variantId || variant.id,
variant_name: variant.variant_name || variant.variantName || null,
variant_type: variant.variant_type || variant.variantType || null,
variant_set_id: variant.variant_set_id || variant.variantSetDbId || null,
variant_set_name: variant.variant_set_name || variant.variantSetName || null,
reference_set_id: variant.reference_set_id || variant.referenceSetDbId || null,
reference_set_name: variant.reference_set_name || variant.referenceSetName || null,
start: variant.start ?? variant.variantStart ?? variant.variant_start ?? null,
end: variant.end ?? variant.variantEnd ?? variant.variant_end ?? null,
variant_start: variant.variant_start ?? variant.variantStart ?? variant.start ?? null,
variant_end: variant.variant_end ?? variant.variantEnd ?? variant.end ?? null,
reference_bases: variant.reference_bases || variant.referenceBases || null,
filters_applied: variant.filters_applied ?? variant.filtersApplied ?? null,
filters_passed: variant.filters_passed ?? variant.filtersPassed ?? null,
});
const mapCall = (call: CallRecord): CallRecord => ({
...call,
id: call.callDbId || call.id,
call_set_id: call.call_set_id || call.callSetDbId || null,
call_set_name: call.call_set_name || call.callSetName || null,
variant_id: call.variant_id || call.variantDbId || null,
variant_name: call.variant_name || call.variantName || null,
genotype_text: call.genotype_text || call.genotypeText || genotypeToText(call.genotype),
genotype_likelihood: call.genotype_likelihood ?? call.genotypeLikelihood ?? null,
read_depth: call.read_depth ?? call.readDepth ?? null,
phase_set: call.phase_set || call.phaseSet || null,
});
const variantBody = (payload: VariantPayload) => ({
variantName: requiredText(payload.variant_name, "Variant name is required"),
variantType: optionalText(payload.variant_type),
variantSetDbId: optionalText(payload.variant_set_id),
referenceSetDbId: optionalText(payload.reference_set_id),
start: optionalNumber(payload.start),
end: optionalNumber(payload.end),
referenceBases: optionalText(payload.reference_bases),
svlen: optionalNumber(payload.svlen),
filtersApplied: optionalBoolean(payload.filters_applied),
filtersPassed: optionalBoolean(payload.filters_passed),
});
const callBody = (payload: CallPayload) => ({
callSetDbId: requiredText(payload.call_set_id, "CallSet is required"),
variantDbId: requiredText(payload.variant_id, "Variant is required"),
genotype: requiredText(payload.genotype_text, "Genotype is required"),
genotypeLikelihood: optionalNumber(payload.genotype_likelihood),
readDepth: optionalNumber(payload.read_depth),
phaseSet: optionalText(payload.phase_set),
});
export async function fetchVariantRows(): Promise<VariantRecord[]> {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=1000");
return response.result.data.map(mapVariant);
}
export async function fetchCallRows(): Promise<CallRecord[]> {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls?page=0&pageSize=1000");
return response.result.data.map(mapCall);
}
export async function fetchVariantOptions(): Promise<{
referenceSets: SelectOption[];
variantSets: SelectOption[];
callSets: SelectOption[];
variants: SelectOption[];
}> {
const [referenceSets, variantSets, callSets, variants] = await Promise.all([
request<BrapiListResponse<ReferenceSetResponse>>("/brapi/v2/referencesets?page=0&pageSize=1000"),
request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=1000"),
request<BrapiListResponse<CallSetResponse>>("/brapi/v2/callsets?page=0&pageSize=1000"),
request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=1000"),
]);
return {
referenceSets: referenceSets.result.data.map((item) => ({
value: item.referenceSetDbId,
label: item.referenceSetName || item.referenceSetDbId,
})),
variantSets: variantSets.result.data.map((item) => ({
value: item.variantSetDbId,
label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`,
})),
callSets: callSets.result.data.map((item) => ({
value: item.callSetDbId,
label: `${item.callSetName || item.callSetDbId}${item.sampleName || item.sampleDbId ? ` / ${item.sampleName || item.sampleDbId}` : ""}`,
})),
variants: variants.result.data.map(mapVariant).map((item) => ({
value: item.id,
label: `${item.variant_name || item.id}${item.variant_type ? ` / ${item.variant_type}` : ""}`,
})),
};
}
export async function createVariantRow(payload: VariantPayload): Promise<VariantRecord> {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", {
method: "POST",
body: JSON.stringify({
variantDbId: requiredText(payload.id, "Variant ID is required"),
...variantBody(payload),
}),
});
return mapVariant(response.result.data[0]);
}
export async function updateVariantRow(id: string, payload: VariantPayload): Promise<VariantRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) throw new Error("Variant ID is immutable. Create a new record instead.");
const response = await request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(variantBody(payload)),
});
return mapVariant(response.result);
}
export async function deleteVariantRow(id: string): Promise<void> {
await request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export async function createCallRow(payload: CallPayload): Promise<CallRecord> {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls", {
method: "POST",
body: JSON.stringify({
callDbId: requiredText(payload.id, "Call ID is required"),
...callBody(payload),
}),
});
return mapCall(response.result.data[0]);
}
export async function updateCallRow(id: string, payload: CallPayload): Promise<CallRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) throw new Error("Call ID is immutable. Create a new record instead.");
const response = await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(callBody(payload)),
});
return mapCall(response.result);
}
export async function deleteCallRow(id: string): Promise<void> {
await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,190 @@
/**
* filekorolheader: Variant / Call - 变异数据管理页面
* 功能Variant 变异位点维护、Call 基因型判读维护
* 路径:/genotyping/variant
* 规范:遵循开发项目规范.md使用 shadcn 语义化样式和 BrAPI 数据接口
*/
"use client";
import { useCallback, useMemo, useState } from "react";
import { Binary, Sigma } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createCallRow,
createVariantRow,
deleteCallRow,
deleteVariantRow,
fetchCallRows,
fetchVariantOptions,
fetchVariantRows,
updateCallRow,
updateVariantRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const variantTypeOptions: SelectOption[] = [
{ value: NONE_SELECT_VALUE, label: "不指定类型" },
{ value: "SNP", label: "SNP / 单核苷酸变异" },
{ value: "INDEL", label: "INDEL / 插入缺失" },
{ value: "SV", label: "SV / 结构变异" },
{ value: "CNV", label: "CNV / 拷贝数变异" },
];
const booleanOptions: SelectOption[] = [
{ value: NONE_SELECT_VALUE, label: "不指定" },
{ value: "true", label: "是" },
{ value: "false", label: "否" },
];
const optionOrNone = (label: string, options: SelectOption[]) => [
{ value: NONE_SELECT_VALUE, label },
...options,
];
const optionLabel = (options: SelectOption[], value: unknown) => {
const text = String(value ?? "").trim();
return options.find((option) => option.value === text)?.label || text || "N/A";
};
const boolLabel = (value: unknown) => {
if (value === true) return "是";
if (value === false) return "否";
return "N/A";
};
export default function VariantPage() {
const [referenceSetOptions, setReferenceSetOptions] = useState<SelectOption[]>([]);
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
const loadOptions = useCallback(async () => {
const options = await fetchVariantOptions();
setReferenceSetOptions(options.referenceSets);
setVariantSetOptions(options.variantSets);
setCallSetOptions(options.callSets);
setVariantOptions(options.variants);
}, []);
const loadVariants = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchVariantRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const loadCalls = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const variantFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Variant ID", type: "text", required: true, placeholder: "variant-001" },
{ key: "variant_name", label: "变异名称", type: "text", required: true, placeholder: "S1_12345_A_T" },
{ key: "variant_type", label: "变异类型", type: "select", options: variantTypeOptions },
{
key: "variant_set_id",
label: "VariantSet",
type: "select",
options: optionOrNone("不关联 VariantSet", variantSetOptions),
},
{
key: "reference_set_id",
label: "ReferenceSet",
type: "select",
options: optionOrNone("不关联 ReferenceSet", referenceSetOptions),
},
{ key: "start", label: "起点", type: "number", placeholder: "1000" },
{ key: "end", label: "终点", type: "number", placeholder: "1001" },
{ key: "reference_bases", label: "参考碱基", type: "text", placeholder: "A" },
{ key: "svlen", label: "SV 长度", type: "number", placeholder: "1" },
{ key: "filters_applied", label: "已应用过滤", type: "select", options: booleanOptions },
{ key: "filters_passed", label: "通过过滤", type: "select", options: booleanOptions },
], [referenceSetOptions, variantSetOptions]);
const callFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" },
{
key: "call_set_id",
label: "CallSet",
type: "select",
required: true,
options: callSetOptions,
},
{
key: "variant_id",
label: "Variant",
type: "select",
required: true,
options: variantOptions,
},
{ key: "genotype_text", label: "Genotype", type: "text", required: true, placeholder: "A/G" },
{ key: "genotype_likelihood", label: "似然值", type: "number", placeholder: "0.98" },
{ key: "read_depth", label: "测序深度", type: "number", placeholder: "42" },
{ key: "phase_set", label: "Phase Set", type: "text", placeholder: "PS001" },
], [callSetOptions, variantOptions]);
return (
<Tabs defaultValue="variants" 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="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger>
<TabsTrigger value="calls" className="gap-2"><Binary className="h-4 w-4" />Calls</TabsTrigger>
</TabsList>
<TabsContent value="variants" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Sigma}
iconBg="bg-gradient-to-br from-rose-600 to-pink-700"
title="Variant 变异位点"
description="维护变异集合中的 SNP、INDEL、SV 等位点和坐标信息。"
addLabel="新增 Variant"
columns={[
{ key: "variantDbId", label: "Variant ID" },
{ key: "variant_name", label: "变异名称" },
{ key: "variant_type", label: "类型", render: (value) => optionLabel(variantTypeOptions, value) },
{ key: "variant_set_name", label: "VariantSet" },
{ key: "reference_set_name", label: "ReferenceSet" },
{ key: "start", label: "起点" },
{ key: "end", label: "终点" },
{ key: "reference_bases", label: "参考碱基" },
{ key: "filters_passed", label: "过滤", render: boolLabel },
]}
fields={variantFields}
data={[]}
stats={[{ label: "/brapi/v2/variants", value: "BrAPI", className: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-200" }]}
loadData={loadVariants}
createRecord={(payload) => createVariantRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateVariantRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteVariantRow}
/>
</TabsContent>
<TabsContent value="calls" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Binary}
iconBg="bg-gradient-to-br from-sky-500 to-cyan-600"
title="Call 基因型判读"
description="维护样品 CallSet 在指定 Variant 上的 genotype、深度和相位信息。"
addLabel="新增 Call"
columns={[
{ key: "callDbId", label: "Call ID" },
{ key: "call_set_name", label: "CallSet" },
{ key: "variant_name", label: "Variant" },
{ key: "genotype_text", label: "Genotype" },
{ key: "genotype_likelihood", label: "似然值" },
{ key: "read_depth", label: "深度" },
{ key: "phase_set", label: "Phase Set" },
]}
fields={callFields}
data={[]}
stats={[{ label: "/brapi/v2/calls", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]}
loadData={loadCalls}
createRecord={(payload) => createCallRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteCallRow}
/>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,79 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface ReferenceRecord {
id: string;
referenceId: string;
referenceDbId: string;
referenceName: string | null;
reference_name: string | null;
referenceSetDbId: string | null;
reference_set_id: string | null;
referenceSetName: string | null;
reference_set_name: string | null;
length: number | string | null;
md5checksum: string | null;
sourceDivergence: number | string | null;
source_divergence: number | string | null;
}
export interface VariantRecord {
id: string;
variantId: string;
variantDbId: string;
variantName: string | null;
variant_name: string | null;
variantType: string | null;
variant_type: string | null;
variantSetDbId: string | null;
variant_set_id: string | null;
variantSetName: string | null;
variant_set_name: string | null;
referenceSetDbId: string | null;
reference_set_id: string | null;
referenceSetName: string | null;
reference_set_name: string | null;
start: number | string | null;
end: number | string | null;
variantStart: number | string | null;
variantEnd: number | string | null;
variant_start: number | string | null;
variant_end: number | string | null;
referenceBases: string | null;
reference_bases: string | null;
svlen: number | string | null;
filtersApplied: boolean | null;
filters_applied: boolean | null;
filtersPassed: boolean | null;
filters_passed: boolean | null;
}
export interface CallGenotype {
values?: string[];
}
export interface CallRecord {
id: string;
callDbId: string;
callSetDbId: string | null;
call_set_id: string | null;
callSetName: string | null;
call_set_name: string | null;
variantDbId: string | null;
variant_id: string | null;
variantName: string | null;
variant_name: string | null;
genotype: CallGenotype | string | null;
genotypeText: string | null;
genotype_text: string | null;
genotypeLikelihood: number | string | null;
genotype_likelihood: number | string | null;
readDepth: number | string | null;
read_depth: number | string | null;
phaseSet: string | null;
phase_set: string | null;
}

View File

@@ -0,0 +1,127 @@
import type { BreedingMethod } from "@/lib/api/types.gen";
import { loadBreedingMethodOptions, invalidateBreedingMethodOptions } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import type { BreedingMethodRecord } 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;
}
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 emptyToNull = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : null;
};
export const mapBreedingMethod = (method: BreedingMethod): BreedingMethodRecord => {
const name = method.breedingMethodName ?? null;
return {
id: method.breedingMethodDbId || "",
breedingMethodDbId: method.breedingMethodDbId || "",
breedingMethodName: name,
name,
abbreviation: method.abbreviation ?? null,
description: method.description ?? null,
};
};
async function fetchBreedingMethodList(): Promise<BreedingMethod[]> {
const response = await request<BrapiListResponse<BreedingMethod>>("/brapi/v2/breedingmethods?page=0&pageSize=1000");
return response.result?.data ?? [];
}
const toRequestBody = (payload: Record<string, unknown>) => {
const breedingMethodName = emptyToNull(payload.breedingMethodName ?? payload.name);
if (!breedingMethodName) {
throw new Error("请填写方法名称");
}
return {
breedingMethodName,
abbreviation: emptyToNull(payload.abbreviation),
description: emptyToNull(payload.description),
};
};
export async function fetchBreedingMethodRows(): Promise<BreedingMethodRecord[]> {
const methods = await fetchBreedingMethodList();
return methods.map((method) => mapBreedingMethod(method));
}
export async function createBreedingMethodRow(payload: Record<string, unknown>): Promise<BreedingMethodRecord> {
const response = await request<BrapiListResponse<BreedingMethod>>("/brapi/v2/breedingmethods", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
const created = response.result.data[0];
invalidateBreedingMethodOptions();
return mapBreedingMethod(created);
}
export async function updateBreedingMethodRow(id: string, payload: Record<string, unknown>): Promise<BreedingMethodRecord> {
const response = await request<BrapiSingleResponse<BreedingMethod>>(
`/brapi/v2/breedingmethods/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
},
);
invalidateBreedingMethodOptions();
return mapBreedingMethod(response.result);
}
export async function deleteBreedingMethodRow(id: string): Promise<void> {
await request<BrapiSingleResponse<BreedingMethod>>(
`/brapi/v2/breedingmethods/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
invalidateBreedingMethodOptions();
}
export async function fetchBreedingMethodOptions(force = false): Promise<Array<{ value: string; label: string }>> {
return loadBreedingMethodOptions(force);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { GitBranch } from "lucide-react";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import {
createBreedingMethodRow,
deleteBreedingMethodRow,
fetchBreedingMethodRows,
updateBreedingMethodRow,
} from "./api";
const loadBreedingMethodRows = async () => fetchBreedingMethodRows() as unknown as Record<string, unknown>[];
export default function BreedingMethodPage() {
return (
<BrapiEntityPage
icon={GitBranch}
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
title="Breeding Method 育种方法"
description="维护 breeding_method 字典说明种质材料的形成方式杂交、回交、DH 等)"
addLabel="新增育种方法"
useEnhancedDialog
columns={[
{ key: "breedingMethodDbId", label: "方法 ID" },
{ key: "name", label: "方法名称" },
{ key: "abbreviation", label: "缩写" },
{
key: "description",
label: "描述",
render: (value) => {
const text = String(value ?? "").trim();
if (!text) return "—";
return text.length > 48 ? `${text.slice(0, 48)}` : text;
},
},
]}
fields={[
{
key: "breedingMethodName",
label: "方法名称 (Name)",
type: "text",
required: true,
placeholder: "如 Male Backcross / Doubled Haploid / 回交选育",
},
{
key: "abbreviation",
label: "缩写 (Abbreviation)",
type: "text",
placeholder: "如 MB / BC / DH",
},
{
key: "description",
label: "描述 (Description)",
type: "textarea",
placeholder: "如:回交用于恢复目标基因",
colSpan: 2,
},
]}
data={[]}
stats={[
{
label: "/brapi/v2/breedingmethods",
value: "BrAPI",
className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200",
},
]}
loadData={loadBreedingMethodRows}
createRecord={(payload) => createBreedingMethodRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateBreedingMethodRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteBreedingMethodRow}
/>
);
}

View File

@@ -0,0 +1,8 @@
export interface BreedingMethodRecord {
id: string;
breedingMethodDbId: string;
breedingMethodName: string | null;
name: string | null;
abbreviation: string | null;
description: string | null;
}

View File

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

View 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);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>>}
/>
);
}

View File

@@ -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/pedigreeparents
</span>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索当前材料 / 关联材料 / 关系类型"
className="max-w-sm"
/>
</div>
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
{loading ? (
<Skeleton className="h-40 w-full" />
) : (
<Table>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-slate-900">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>parent_type</TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center text-sm text-slate-400">
parent / child
</TableCell>
</TableRow>
) : (
filteredRows.map((row) => (
<TableRow key={row.id}>
<TableCell>
{edgeTypeLabel(row.edge_type)}
{row.read_only ? (
<span className="ml-2 text-xs text-slate-400"></span>
) : null}
</TableCell>
<TableCell>{row.this_node_name || row.this_node_id}</TableCell>
<TableCell>{row.connected_node_name || row.connected_node_id}</TableCell>
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
<TableCell className="text-right">
{!row.read_only ? (
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="outline"
className="gap-1 text-red-600 hover:text-red-600"
onClick={() => setDeletingEdge(row)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<span className="text-xs text-slate-400"></span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl" title={editingEdgeId ? "编辑系谱边" : "新增系谱边"}>
<DialogBody className="space-y-4">
<div>
<Label className="mb-1.5 block text-sm">edge_type </Label>
<Select
value={form?.edge_type || "parent"}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, edge_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关系类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{EDGE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.this_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, this_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择当前材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select
value={form?.connected_node_id || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, connected_node_id: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择关联材料" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{nodeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showParentType ? (
<div>
<Label className="mb-1.5 block text-sm">parent_type </Label>
<Select
value={form?.parent_type || NONE_SELECT_VALUE}
onValueChange={(value) => {
if (!form) return;
setForm({ ...form, parent_type: value });
}}
>
<SelectTrigger><SelectValue placeholder="选择亲本类型" /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
<SelectItem value={NONE_SELECT_VALUE}> parent_type</SelectItem>
{PARENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<p className="text-xs text-slate-500 dark:text-slate-400">
parentchild
</p>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button
onClick={handleSave}
disabled={
saving
|| !form
|| form.this_node_id === NONE_SELECT_VALUE
|| form.connected_node_id === NONE_SELECT_VALUE
|| (showParentType && form.parent_type === NONE_SELECT_VALUE)
}
>
{saving ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={Boolean(deletingEdge)} onOpenChange={(open) => !open && setDeletingEdge(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{" "}
{deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""}
{" "}
{deletingEdge?.this_node_name || deletingEdge?.this_node_id}
{" → "}
{deletingEdge?.connected_node_name || deletingEdge?.connected_node_id}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
{deleting ? "删除中..." : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -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>>}
/>
);
}

View 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 || "—";
};

View File

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

View 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,
});

View 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>
);
}

View 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;
}

View File

@@ -0,0 +1,217 @@
import { fetchBreedingMethodOptions } from "../breeding-method/api";
import { loadCommonCropNameOptions } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token";
import { NONE_SELECT_VALUE, type GermplasmRecord, 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;
}
interface CommonCropNamesResponse {
result: {
data: string[];
};
}
type GermplasmPayload = Partial<Record<
| "germplasm_name"
| "default_display_name"
| "accession_number"
| "germplasmpui"
| "documentationurl"
| "genus"
| "species"
| "species_authority"
| "subtaxa"
| "subtaxa_authority"
| "country_of_origin_code"
| "acquisition_date"
| "acquisition_source_code"
| "biological_status_of_accession_code"
| "collection"
| "mls_status"
| "seed_source"
| "seed_source_description"
| "germplasm_preprocessing"
| "crop_id"
| "breeding_method_id",
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 germplasmName = (payload: GermplasmPayload) => {
const name = optionalText(payload.germplasm_name);
if (!name) throw new Error("请填写种质名称");
return name;
};
const mapGermplasm = (germplasm: GermplasmRecord): GermplasmRecord => ({
...germplasm,
id: germplasm.germplasmDbId || germplasm.id,
germplasm_name: germplasm.germplasm_name || germplasm.germplasmName,
default_display_name: germplasm.default_display_name || germplasm.defaultDisplayName,
accession_number: germplasm.accession_number || germplasm.accessionNumber,
germplasmpui: germplasm.germplasmpui || germplasm.germplasmPUI,
documentationurl: germplasm.documentationurl || germplasm.documentationURL,
species_authority: germplasm.species_authority || germplasm.speciesAuthority,
subtaxa_authority: germplasm.subtaxa_authority || germplasm.subtaxaAuthority,
country_of_origin_code: germplasm.country_of_origin_code || germplasm.countryOfOriginCode,
acquisition_date: germplasm.acquisition_date || germplasm.acquisitionDate,
acquisition_source_code: germplasm.acquisition_source_code ?? germplasm.acquisitionSourceCode,
biological_status_of_accession_code:
germplasm.biological_status_of_accession_code ?? germplasm.biologicalStatusOfAccessionCode,
mls_status: germplasm.mls_status ?? germplasm.mlsStatus,
seed_source: germplasm.seed_source || germplasm.seedSource,
seed_source_description: germplasm.seed_source_description || germplasm.seedSourceDescription,
germplasm_preprocessing: germplasm.germplasm_preprocessing || germplasm.germplasmPreprocessing,
crop_id: germplasm.crop_id || germplasm.commonCropName || germplasm.cropDbId,
crop_name: germplasm.crop_name || germplasm.commonCropName || germplasm.cropName,
breeding_method_id: germplasm.breeding_method_id || germplasm.breedingMethodDbId,
breeding_method_name: germplasm.breeding_method_name || germplasm.breedingMethodName,
});
const toRequestBody = (payload: GermplasmPayload) => ({
germplasmName: germplasmName(payload),
defaultDisplayName: optionalText(payload.default_display_name),
accessionNumber: optionalText(payload.accession_number),
germplasmPUI: optionalText(payload.germplasmpui),
documentationurl: optionalText(payload.documentationurl),
genus: optionalText(payload.genus),
species: optionalText(payload.species),
species_authority: optionalText(payload.species_authority),
subtaxa: optionalText(payload.subtaxa),
subtaxa_authority: optionalText(payload.subtaxa_authority),
countryOfOriginCode: optionalText(payload.country_of_origin_code),
country_of_origin_code: optionalText(payload.country_of_origin_code),
acquisitionDate: optionalText(payload.acquisition_date),
acquisition_source_code: optionalNumber(payload.acquisition_source_code),
biological_status_of_accession_code: optionalNumber(payload.biological_status_of_accession_code),
collection: optionalText(payload.collection),
mls_status: optionalNumber(payload.mls_status),
seed_source: optionalText(payload.seed_source),
seed_source_description: optionalText(payload.seed_source_description),
germplasm_preprocessing: optionalText(payload.germplasm_preprocessing),
...(optionalText(payload.crop_id) ? { commonCropName: optionalText(payload.crop_id) } : {}),
breedingMethodDbId: optionalText(payload.breeding_method_id),
});
export async function fetchGermplasmRows(): Promise<GermplasmRecord[]> {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm?page=0&pageSize=1000");
return response.result.data.map(mapGermplasm);
}
export async function fetchGermplasmDetail(id: string): Promise<GermplasmRecord> {
const response = await request<BrapiSingleResponse<GermplasmRecord>>(
`/brapi/v2/germplasm/${encodeURIComponent(id)}`,
);
return mapGermplasm(response.result);
}
export function normalizeGermplasmFormData(record: GermplasmRecord): Record<string, unknown> {
const cropId = record.crop_id || record.commonCropName || "";
const breedingMethodId = record.breeding_method_id || record.breedingMethodDbId || "";
return {
...record,
crop_id: cropId && cropId !== NONE_SELECT_VALUE ? cropId : NONE_SELECT_VALUE,
breeding_method_id:
breedingMethodId && breedingMethodId !== NONE_SELECT_VALUE ? breedingMethodId : NONE_SELECT_VALUE,
};
}
export async function fetchGermplasmOptions(force = false): Promise<{
crops: SelectOption[];
breedingMethods: SelectOption[];
}> {
const [cropsResult, breedingMethodsResult] = await Promise.allSettled([
loadCommonCropNameOptions(force),
fetchBreedingMethodOptions(force),
]);
const crops = cropsResult.status === "fulfilled" ? cropsResult.value : [];
const breedingMethods = breedingMethodsResult.status === "fulfilled"
? breedingMethodsResult.value
: [];
return { crops, breedingMethods };
}
export async function createGermplasmRow(payload: GermplasmPayload): Promise<GermplasmRecord> {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
return mapGermplasm(response.result.data[0]);
}
export async function updateGermplasmRow(id: string, payload: GermplasmPayload): Promise<GermplasmRecord> {
const response = await request<BrapiSingleResponse<GermplasmRecord>>(`/brapi/v2/germplasm/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
});
return mapGermplasm(response.result);
}
export async function deleteGermplasmRow(id: string): Promise<void> {
await request<BrapiSingleResponse<GermplasmRecord>>(`/brapi/v2/germplasm/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,179 @@
import type { GermplasmAttribute } from "@/lib/api/types.gen";
import { getAuthToken } from "@/utils/token";
import {
readAttributeDataType,
type AttributeDataType,
type AttributeRecord,
type SelectOption,
NONE_SELECT_VALUE,
} from "./attributeTypes";
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;
}
interface CommonCropNamesResponse {
result: {
data: string[];
};
}
type AttributePayload = Partial<Record<
| "attributeName"
| "attributeCategory"
| "attributeDescription"
| "attributePUI"
| "crop_id"
| "dataType",
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;
};
export const mapAttribute = (attribute: GermplasmAttribute): AttributeRecord => {
const cropName = attribute.commonCropName ?? null;
return {
id: attribute.attributeDbId || "",
attributeDbId: attribute.attributeDbId || "",
attributeName: attribute.attributeName ?? null,
attributeCategory: attribute.attributeCategory ?? null,
attributeDescription: attribute.attributeDescription ?? null,
commonCropName: cropName,
crop_id: cropName,
dataType: readAttributeDataType(attribute),
attributePUI: attribute.attributePUI ?? null,
};
};
export function normalizeAttributeFormData(record: AttributeRecord): Record<string, unknown> {
return {
...record,
crop_id: record.crop_id && record.crop_id !== NONE_SELECT_VALUE ? record.crop_id : NONE_SELECT_VALUE,
dataType: record.dataType && record.dataType !== NONE_SELECT_VALUE ? record.dataType : NONE_SELECT_VALUE,
};
}
const toRequestBody = (payload: AttributePayload) => {
const attributeName = optionalText(payload.attributeName);
if (!attributeName) throw new Error("请填写属性名称");
const dataType = optionalText(payload.dataType) as AttributeDataType | null;
const scaleName = `${attributeName} scale`;
return {
attributeName,
attributeCategory: optionalText(payload.attributeCategory),
attributeDescription: optionalText(payload.attributeDescription),
attributePUI: optionalText(payload.attributePUI),
...(optionalText(payload.crop_id) ? { commonCropName: optionalText(payload.crop_id) } : {}),
...(dataType
? {
scale: {
scaleName,
dataType,
},
}
: {}),
};
};
export async function fetchAttributeRows(): Promise<AttributeRecord[]> {
const response = await request<BrapiListResponse<GermplasmAttribute>>("/brapi/v2/attributes?page=0&pageSize=1000");
return (response.result?.data ?? []).map(mapAttribute);
}
export async function fetchAttributeDetail(id: string): Promise<AttributeRecord> {
const response = await request<BrapiSingleResponse<GermplasmAttribute>>(
`/brapi/v2/attributes/${encodeURIComponent(id)}`,
);
return mapAttribute(response.result);
}
export async function fetchAttributeOptions(): Promise<SelectOption[]> {
const rows = await fetchAttributeRows();
return rows.map((row) => ({
value: row.attributeDbId,
label: row.attributeCategory
? `${row.attributeName || row.attributeDbId} / ${row.attributeCategory}`
: (row.attributeName || row.attributeDbId),
})).filter((option) => option.value);
}
export async function fetchAttributeFormOptions(): Promise<{ crops: SelectOption[] }> {
const cropsResult = await request<CommonCropNamesResponse>("/brapi/v2/commoncropnames?page=0&pageSize=1000").catch(() => null);
return {
crops: (cropsResult?.result?.data ?? []).map((cropName) => ({
value: cropName,
label: cropName,
})),
};
}
export async function createAttributeRow(payload: AttributePayload): Promise<AttributeRecord> {
const response = await request<BrapiListResponse<GermplasmAttribute>>("/brapi/v2/attributes", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
return mapAttribute(response.result.data[0]);
}
export async function updateAttributeRow(id: string, payload: AttributePayload): Promise<AttributeRecord> {
const response = await request<BrapiSingleResponse<GermplasmAttribute>>(
`/brapi/v2/attributes/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
},
);
return mapAttribute(response.result);
}

View File

@@ -0,0 +1,48 @@
import type { GermplasmAttribute, GermplasmAttributeValue } from "@/lib/api/types.gen";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
export { NONE_SELECT_VALUE, type SelectOption };
export type AttributeDataType = "Code" | "Date" | "Duration" | "Nominal" | "Numerical" | "Ordinal" | "Text";
export const ATTRIBUTE_DATA_TYPE_OPTIONS: SelectOption[] = [
{ value: "Text", label: "Text 文本" },
{ value: "Numerical", label: "Numerical 数值" },
{ value: "Nominal", label: "Nominal 名义分类" },
{ value: "Ordinal", label: "Ordinal 有序分类" },
{ value: "Code", label: "Code 编码" },
{ value: "Date", label: "Date 日期" },
{ value: "Duration", label: "Duration 时长" },
];
export interface AttributeRecord {
id: string;
attributeDbId: string;
attributeName: string | null;
attributeCategory: string | null;
attributeDescription: string | null;
commonCropName: string | null;
crop_id: string | null;
dataType: AttributeDataType | null;
attributePUI: string | null;
}
export interface AttributeValueRecord {
id: string;
attributeValueDbId: string;
attributeDbId: string | null;
attribute_id: string | null;
attributeName: string | null;
germplasmDbId: string | null;
germplasm_id: string | null;
germplasmName: string | null;
value: string | null;
determinedDate: string | null;
determined_date: string | null;
}
export const readAttributeDataType = (attribute: GermplasmAttribute): AttributeDataType | null => {
const fromScale = attribute.scale?.dataType;
if (fromScale) return fromScale as AttributeDataType;
return null;
};

View File

@@ -0,0 +1,152 @@
import type { GermplasmAttributeValue } from "@/lib/api/types.gen";
import { toBrapiIsoDateTime } from "@/lib/brapiIso";
import { getAuthToken } from "@/utils/token";
import { type AttributeValueRecord, NONE_SELECT_VALUE } from "./attributeTypes";
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 AttributeValuePayload = Partial<Record<
| "attribute_id"
| "germplasm_id"
| "value"
| "determined_date",
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 formatDeterminedDate = (value: unknown) => {
if (value === null || value === undefined || value === "") return null;
const text = String(value);
return text.length >= 10 ? text.slice(0, 10) : text;
};
export const mapAttributeValue = (row: GermplasmAttributeValue): AttributeValueRecord => {
const determinedDate = formatDeterminedDate(row.determinedDate);
return {
id: row.attributeValueDbId || "",
attributeValueDbId: row.attributeValueDbId || "",
attributeDbId: row.attributeDbId ?? null,
attribute_id: row.attributeDbId ?? null,
attributeName: row.attributeName ?? null,
germplasmDbId: row.germplasmDbId ?? null,
germplasm_id: row.germplasmDbId ?? null,
germplasmName: row.germplasmName ?? null,
value: row.value ?? null,
determinedDate,
determined_date: determinedDate,
};
};
export function normalizeAttributeValueFormData(record: AttributeValueRecord): Record<string, unknown> {
return {
...record,
attribute_id: record.attribute_id && record.attribute_id !== NONE_SELECT_VALUE ? record.attribute_id : NONE_SELECT_VALUE,
germplasm_id: record.germplasm_id && record.germplasm_id !== NONE_SELECT_VALUE ? record.germplasm_id : NONE_SELECT_VALUE,
};
}
const toRequestBody = (payload: AttributeValuePayload) => {
const attributeDbId = optionalText(payload.attribute_id);
const germplasmDbId = optionalText(payload.germplasm_id);
const value = optionalText(payload.value);
if (!attributeDbId) throw new Error("请选择属性定义");
if (!germplasmDbId) throw new Error("请选择种质材料");
if (!value) throw new Error("请填写属性值");
return {
attributeDbId,
germplasmDbId,
value,
...(toBrapiIsoDateTime(payload.determined_date)
? { determinedDate: toBrapiIsoDateTime(payload.determined_date) }
: {}),
};
};
export async function fetchAttributeValueRows(germplasmDbId?: string): Promise<AttributeValueRecord[]> {
const query = germplasmDbId
? `?page=0&pageSize=1000&germplasmDbId=${encodeURIComponent(germplasmDbId)}`
: "?page=0&pageSize=1000";
const response = await request<BrapiListResponse<GermplasmAttributeValue>>(`/brapi/v2/attributevalues${query}`);
return (response.result?.data ?? []).map(mapAttributeValue);
}
export async function fetchAttributeValueDetail(id: string): Promise<AttributeValueRecord> {
const response = await request<BrapiSingleResponse<GermplasmAttributeValue>>(
`/brapi/v2/attributevalues/${encodeURIComponent(id)}`,
);
return mapAttributeValue(response.result);
}
export async function createAttributeValueRow(payload: AttributeValuePayload): Promise<AttributeValueRecord> {
const response = await request<BrapiListResponse<GermplasmAttributeValue>>("/brapi/v2/attributevalues", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
return mapAttributeValue(response.result.data[0]);
}
export async function updateAttributeValueRow(id: string, payload: AttributeValuePayload): Promise<AttributeValueRecord> {
const response = await request<BrapiSingleResponse<GermplasmAttributeValue>>(
`/brapi/v2/attributevalues/${encodeURIComponent(id)}`,
{
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
},
);
return mapAttributeValue(response.result);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Tags } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
ATTRIBUTE_DATA_TYPE_OPTIONS,
NONE_SELECT_VALUE,
type SelectOption,
} from "../attributeTypes";
import {
createAttributeRow,
fetchAttributeDetail,
fetchAttributeFormOptions,
fetchAttributeRows,
normalizeAttributeFormData,
updateAttributeRow,
} from "../attributeApi";
export function GermplasmAttributeTab() {
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
useEffect(() => {
let mounted = true;
fetchAttributeFormOptions()
.then((options) => {
if (mounted) setCropOptions(options.crops);
})
.catch(() => {
// 下拉选项加载失败时不阻断列表页
});
return () => {
mounted = false;
};
}, []);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchAttributeFormOptions(), fetchAttributeRows()]);
setCropOptions(options.crops);
return rows as unknown as Record<string, unknown>[];
}, []);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchAttributeDetail(id);
return normalizeAttributeFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "attributeName",
label: "属性名称",
type: "text",
required: true,
placeholder: "如 籽粒颜色 / 抗病基因 / 熟期类型",
},
{
key: "attributeCategory",
label: "属性分类",
type: "text",
placeholder: "如 Morphological / Genetic / Quality",
},
{
key: "dataType",
label: "数据类型",
type: "select",
required: true,
options: [{ value: NONE_SELECT_VALUE, label: "请选择数据类型" }, ...ATTRIBUTE_DATA_TYPE_OPTIONS],
},
{
key: "crop_id",
label: "适用作物",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不限定作物" }, ...cropOptions],
},
{ key: "attributePUI", label: "属性 PUI", type: "text", placeholder: "永久唯一标识(可选)" },
{
key: "attributeDescription",
label: "属性说明",
type: "textarea",
placeholder: "描述该属性的业务含义与取值说明",
colSpan: 2,
},
], [cropOptions]);
return (
<BrapiEntityPage
icon={Tags}
iconBg="bg-gradient-to-br from-violet-500 to-purple-600"
title="Germplasm Attribute 属性定义"
description="维护材料可有哪些稳定属性germplasm_attribute_definition供属性值录入时选择"
addLabel="新增属性定义"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "attributeDbId", label: "属性 ID" },
{ key: "attributeName", label: "属性名称" },
{ key: "attributeCategory", label: "分类" },
{
key: "dataType",
label: "数据类型",
render: (value) => (value ? String(value) : "—"),
},
{ key: "commonCropName", label: "适用作物" },
{
key: "attributeDescription",
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/attributes",
value: "BrAPI",
className: "bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200",
},
]}
loadData={loadRows}
createRecord={(payload) => createAttributeRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateAttributeRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ListChecks } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { fetchAttributeOptions } from "../attributeApi";
import {
createAttributeValueRow,
fetchAttributeValueDetail,
fetchAttributeValueRows,
normalizeAttributeValueFormData,
updateAttributeValueRow,
} from "../attributeValueApi";
import { fetchGermplasmRows } from "../api";
import { NONE_SELECT_VALUE, type SelectOption } from "../attributeTypes";
export function GermplasmAttributeValueTab() {
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [attributeOptions, setAttributeOptions] = useState<SelectOption[]>([]);
const loadSelectOptions = useCallback(async () => {
const [germplasmRows, attributes] = await Promise.all([
fetchGermplasmRows().catch(() => []),
fetchAttributeOptions().catch(() => []),
]);
setGermplasmOptions(
germplasmRows.map((row) => ({
value: row.id,
label: row.default_display_name || row.germplasm_name || row.id,
})).filter((option) => option.value),
);
setAttributeOptions(attributes);
return { germplasmOptions: germplasmRows, attributeOptions: attributes };
}, []);
useEffect(() => {
loadSelectOptions().catch(() => {
// 下拉选项加载失败时不阻断列表页
});
}, [loadSelectOptions]);
const loadRows = useCallback(async () => {
await loadSelectOptions();
const rows = await fetchAttributeValueRows();
return rows as unknown as Record<string, unknown>[];
}, [loadSelectOptions]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchAttributeValueDetail(id);
return normalizeAttributeValueFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{
key: "germplasm_id",
label: "种质材料",
type: "select",
required: true,
placeholder: germplasmOptions.length > 0 ? "请选择种质" : "请先在「种质列表」Tab 创建材料",
options: [{ value: NONE_SELECT_VALUE, label: "请选择种质" }, ...germplasmOptions],
},
{
key: "attribute_id",
label: "属性定义",
type: "select",
required: true,
placeholder: attributeOptions.length > 0 ? "请选择属性" : "请先在「属性定义」Tab 创建属性",
options: [{ value: NONE_SELECT_VALUE, label: "请选择属性" }, ...attributeOptions],
},
{
key: "value",
label: "属性值",
type: "text",
required: true,
placeholder: "按属性数据类型填写实际取值",
},
{ key: "determined_date", label: "测定日期", type: "date" },
], [attributeOptions, germplasmOptions]);
return (
<BrapiEntityPage
icon={ListChecks}
iconBg="bg-gradient-to-br from-indigo-500 to-blue-600"
title="Germplasm Attribute Value 属性值"
description="记录某个种质在某个属性上的实际取值germplasm_attribute_value"
addLabel="新增属性值"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "attributeValueDbId", label: "属性值 ID" },
{ key: "germplasmName", label: "种质" },
{ key: "attributeName", label: "属性" },
{ key: "value", label: "取值" },
{ key: "determined_date", label: "测定日期" },
]}
fields={fields}
data={[]}
stats={[
{
label: "/brapi/v2/attributevalues",
value: "BrAPI",
className: "bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-200",
},
]}
loadData={loadRows}
createRecord={(payload) => createAttributeValueRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateAttributeValueRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,180 @@
"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Dna, ListChecks, Tags } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createGermplasmRow,
deleteGermplasmRow,
fetchGermplasmDetail,
fetchGermplasmOptions,
fetchGermplasmRows,
normalizeGermplasmFormData,
updateGermplasmRow,
} from "./api";
import { GermplasmAttributeTab } from "./components/GermplasmAttributeTab";
import { GermplasmAttributeValueTab } from "./components/GermplasmAttributeValueTab";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
export default function GermplasmPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-slate-500"></div>}>
<GermplasmPageContent />
</Suspense>
);
}
function GermplasmPageContent() {
const searchParams = useSearchParams();
const initialTab = useMemo(() => {
const tab = searchParams.get("tab");
if (tab === "attributes" || tab === "attribute-values") return tab;
return "germplasm";
}, [searchParams]);
const [activeTab, setActiveTab] = useState(initialTab);
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
const [breedingMethodOptions, setBreedingMethodOptions] = useState<SelectOption[]>([]);
const applyGermplasmOptions = useCallback((options: Awaited<ReturnType<typeof fetchGermplasmOptions>>) => {
setCropOptions(options.crops);
setBreedingMethodOptions(options.breedingMethods);
return options;
}, []);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
let mounted = true;
fetchGermplasmOptions()
.then((options) => {
if (mounted) applyGermplasmOptions(options);
})
.catch(() => {
// 下拉选项加载失败时不阻断列表页
});
return () => {
mounted = false;
};
}, [applyGermplasmOptions]);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchGermplasmOptions(), fetchGermplasmRows()]);
applyGermplasmOptions(options);
return rows as unknown as Record<string, unknown>[];
}, [applyGermplasmOptions]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchGermplasmDetail(id);
return normalizeGermplasmFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "germplasm_name", label: "种质名称", type: "text", required: true, placeholder: "如 B73 / Mo17 / CASS-0001" },
{ key: "default_display_name", label: "展示名称", type: "text", placeholder: "页面和报表优先展示的名称" },
{ key: "accession_number", label: "登录号", type: "text", placeholder: "如 CASS-0001" },
{ key: "germplasmpui", label: "Germplasm PUI", type: "text", placeholder: "永久唯一标识" },
{
key: "crop_id",
label: "作物Common Crop Name",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联作物" }, ...cropOptions],
},
{ key: "genus", label: "属", type: "text", placeholder: "如 Zea" },
{ key: "species", label: "种", type: "text", placeholder: "如 mays" },
{ key: "species_authority", label: "种命名人", type: "text" },
{ key: "subtaxa", label: "亚分类", type: "text" },
{ key: "subtaxa_authority", label: "亚分类命名人", type: "text" },
{
key: "country_of_origin_code",
label: "来源国家",
type: "text",
placeholder: "如 CHN / USA / 中国",
},
{ key: "acquisition_date", label: "获取日期", type: "date" },
{ key: "collection", label: "所属集合", type: "text", placeholder: "如 Maize Core Collection" },
{
key: "breeding_method_id",
label: "育种方法",
type: "select",
placeholder: breedingMethodOptions.length > 0 ? "请选择育种方法" : "暂无育种方法,请先在「育种方法」页维护",
options: [{ value: NONE_SELECT_VALUE, label: "不指定育种方法" }, ...breedingMethodOptions],
},
{ key: "acquisition_source_code", label: "获取来源代码", type: "number" },
{ key: "biological_status_of_accession_code", label: "生物状态代码", type: "number" },
{ key: "mls_status", label: "MLS 状态", type: "number" },
{ key: "seed_source", label: "种子来源", type: "text" },
{ key: "documentationurl", label: "文档地址", type: "text", placeholder: "https://..." },
{ key: "seed_source_description", label: "种子来源说明", type: "textarea", colSpan: 2 },
{ key: "germplasm_preprocessing", label: "预处理说明", type: "textarea", colSpan: 2 },
], [cropOptions, breedingMethodOptions]);
return (
<Tabs value={activeTab} onValueChange={setActiveTab} 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="germplasm" className="gap-2">
<Dna className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="attributes" className="gap-2">
<Tags className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="attribute-values" className="gap-2">
<ListChecks className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="germplasm" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
icon={Dna}
iconBg="bg-gradient-to-br from-teal-500 to-emerald-600"
title="Germplasm 种质"
description="维护品种、品系、材料、资源材料等种质核心对象,并关联作物与育种方法"
addLabel="新增种质"
useEnhancedDialog
fetchRecord={fetchRecord}
columns={[
{ key: "id", label: "种质 ID" },
{ key: "germplasm_name", label: "种质名称" },
{ key: "default_display_name", label: "展示名称" },
{ key: "accession_number", label: "登录号" },
{ key: "country_of_origin_code", label: "来源国家" },
{ key: "crop_name", label: "作物" },
{ key: "genus", label: "属" },
{ key: "species", label: "种" },
{
key: "breeding_method_name",
label: "育种方法",
render: (value, row) => value ? String(value) : String(row.breeding_method_id ?? "—"),
},
]}
fields={fields}
data={[]}
stats={[
{ label: "/brapi/v2/germplasm", value: "BrAPI", className: "bg-teal-50 text-teal-700 dark:bg-teal-400/10 dark:text-teal-200" },
{ label: "/brapi/v2/commoncropnames", value: "GET", className: "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200" },
{ label: "/brapi/v2/breedingmethods", value: "GET", className: "bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200" },
]}
loadData={loadRows}
createRecord={(payload) => createGermplasmRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateGermplasmRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteGermplasmRow}
/>
</TabsContent>
<TabsContent value="attributes" className="mt-0 min-h-0 flex-1">
<GermplasmAttributeTab />
</TabsContent>
<TabsContent value="attribute-values" className="mt-0 min-h-0 flex-1">
<GermplasmAttributeValueTab />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,54 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface GermplasmRecord {
id: string;
germplasmDbId: string;
germplasmName: string | null;
germplasm_name: string | null;
defaultDisplayName: string | null;
default_display_name: string | null;
accessionNumber: string | null;
accession_number: string | null;
germplasmPUI: string | null;
germplasmpui: string | null;
documentationURL: string | null;
documentationurl: string | null;
genus: string | null;
species: string | null;
speciesAuthority: string | null;
species_authority: string | null;
subtaxa: string | null;
subtaxaAuthority: string | null;
subtaxa_authority: string | null;
countryOfOriginCode: string | null;
country_of_origin_code: string | null;
acquisitionDate: string | null;
acquisition_date: string | null;
acquisitionSourceCode: number | null;
acquisition_source_code: number | null;
biologicalStatusOfAccessionCode: number | null;
biological_status_of_accession_code: number | null;
collection: string | null;
mlsStatus: number | null;
mls_status: number | null;
seedSource: string | null;
seed_source: string | null;
seedSourceDescription: string | null;
seed_source_description: string | null;
germplasmPreprocessing: string | null;
germplasm_preprocessing: string | null;
cropDbId: string | null;
crop_id: string | null;
commonCropName: string | null;
cropName: string | null;
crop_name: string | null;
breedingMethodDbId: string | null;
breeding_method_id: string | null;
breedingMethodName: string | null;
breeding_method_name: string | null;
}

View File

@@ -0,0 +1,460 @@
import { toBrapiIsoDateTime } from "@/lib/brapiIso";
import { getAuthToken } from "@/utils/token";
import {
createCachedLoader,
loadCrossOptions,
loadGermplasmOptions,
loadLocationOptions,
loadProgramOptions,
} from "@/services/dropdownCache";
import {
NONE_SELECT_VALUE,
type ContentMixtureRow,
type SeedLotContentMixtureRecord,
type SeedLotRecord,
type SeedLotTransactionRecord,
type SelectOption,
type TransactionActionType,
} 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 SeedLotPayload = Partial<Record<
| "id"
| "name"
| "seed_lot_description"
| "amount"
| "units"
| "created_date"
| "last_updated"
| "source_collection"
| "storage_location"
| "location_id"
| "program_id"
| "primary_germplasm_id"
| "content_mixture",
unknown
>>;
export interface TransactionPayload {
action: TransactionActionType;
amount: unknown;
description?: unknown;
timestamp?: unknown;
units?: unknown;
from_seed_lot_id?: unknown;
to_seed_lot_id?: 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>;
}
export 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 seedLotName = (payload: SeedLotPayload) => {
const name = optionalText(payload.name);
if (!name) throw new Error("请填写批次名称");
return name;
};
const parseMixtureRows = (value: unknown): ContentMixtureRow[] => {
if (!Array.isArray(value)) return [];
return value.map((row) => {
const item = row as Record<string, unknown>;
return {
germplasm_id: String(item.germplasm_id ?? NONE_SELECT_VALUE),
cross_id: String(item.cross_id ?? NONE_SELECT_VALUE),
mixture_percentage: String(item.mixture_percentage ?? ""),
};
});
};
export const mapContentMixtureToForm = (mixtures: SeedLotContentMixtureRecord[] | null | undefined): ContentMixtureRow[] => {
if (!mixtures?.length) {
return [{ germplasm_id: NONE_SELECT_VALUE, cross_id: NONE_SELECT_VALUE, mixture_percentage: "100" }];
}
return mixtures.map((mixture) => ({
germplasm_id: mixture.germplasmDbId || mixture.germplasm_id || NONE_SELECT_VALUE,
cross_id: mixture.crossDbId || mixture.cross_id || NONE_SELECT_VALUE,
mixture_percentage: String(mixture.mixturePercentage ?? mixture.mixture_percentage ?? ""),
}));
};
export const buildContentMixturePayload = (payload: SeedLotPayload) => {
let rows = parseMixtureRows(payload.content_mixture);
const primaryGermplasmId = optionalText(payload.primary_germplasm_id);
if (rows.length === 0 && primaryGermplasmId) {
rows = [{ germplasm_id: primaryGermplasmId, cross_id: NONE_SELECT_VALUE, mixture_percentage: "100" }];
}
if (rows.length === 0) {
throw new Error("请至少录入一条批次组成,或选择主材料");
}
const normalized = rows.map((row) => {
const germplasmDbId = optionalText(row.germplasm_id);
const crossDbId = optionalText(row.cross_id);
const mixturePercentage = optionalNumber(row.mixture_percentage);
if (!germplasmDbId && !crossDbId) {
throw new Error("批次组成每行需选择材料或杂交来源");
}
if (mixturePercentage === null || mixturePercentage < 0 || mixturePercentage > 100) {
throw new Error("批次组成占比需在 0 到 100 之间");
}
return {
...(germplasmDbId ? { germplasmDbId } : {}),
...(crossDbId ? { crossDbId } : {}),
mixturePercentage,
};
});
const totalPercentage = normalized.reduce((sum, row) => sum + (row.mixturePercentage ?? 0), 0);
if (Math.abs(totalPercentage - 100) > 0.01) {
throw new Error(`批次组成占比合计应为 100%,当前为 ${totalPercentage}%`);
}
return normalized;
};
const mapSeedLot = (seedLot: SeedLotRecord): SeedLotRecord => {
const contentMixture = seedLot.contentMixture ?? seedLot.content_mixture ?? [];
return {
...seedLot,
id: seedLot.seedLotDbId || seedLot.id,
name: seedLot.name || seedLot.seed_lot_name || seedLot.seedLotName,
seed_lot_name: seedLot.seed_lot_name || seedLot.seedLotName || seedLot.name,
seed_lot_description: seedLot.seed_lot_description || seedLot.seedLotDescription || seedLot.description,
created_date: seedLot.created_date || seedLot.createdDate,
last_updated: seedLot.last_updated || seedLot.lastUpdated,
source_collection: seedLot.source_collection || seedLot.sourceCollection,
storage_location: seedLot.storage_location || seedLot.storageLocation,
location_id: seedLot.location_id || seedLot.locationDbId,
location_name: seedLot.location_name || seedLot.locationName,
program_id: seedLot.program_id || seedLot.programDbId,
program_name: seedLot.program_name || seedLot.programName,
content_mixture: contentMixture,
contentMixture,
};
};
const mapTransaction = (
transaction: SeedLotTransactionRecord,
seedLotNameById: Map<string, string>,
): SeedLotTransactionRecord => {
const id = transaction.transactionDbId || transaction.id;
const fromId = transaction.fromSeedLotDbId || transaction.from_seed_lot_id || null;
const toId = transaction.toSeedLotDbId || transaction.to_seed_lot_id || null;
return {
...transaction,
id: id || "",
from_seed_lot_id: fromId,
to_seed_lot_id: toId,
description: transaction.transactionDescription || transaction.description,
timestamp: transaction.transactionTimestamp || transaction.timestamp,
from_seed_lot_name: fromId ? seedLotNameById.get(fromId) ?? fromId : null,
to_seed_lot_name: toId ? seedLotNameById.get(toId) ?? toId : null,
};
};
const toRequestBody = (payload: SeedLotPayload, options?: { includeAmount?: boolean }) => {
const body: Record<string, unknown> = {
seedLotName: seedLotName(payload),
units: optionalText(payload.units),
contentMixture: buildContentMixturePayload(payload).map((row) => ({
...row,
mixturePercentage: Math.round(row.mixturePercentage ?? 0),
})),
};
const seedLotDescription = optionalText(payload.seed_lot_description);
const createdDate = toBrapiIsoDateTime(payload.created_date);
const lastUpdated = toBrapiIsoDateTime(payload.last_updated);
const sourceCollection = optionalText(payload.source_collection);
const storageLocation = optionalText(payload.storage_location);
const locationDbId = optionalText(payload.location_id);
const programDbId = optionalText(payload.program_id);
const amount = optionalNumber(payload.amount);
if (options?.includeAmount !== false && amount !== null) body.amount = amount;
if (seedLotDescription) body.seedLotDescription = seedLotDescription;
if (createdDate) body.createdDate = createdDate;
if (lastUpdated) body.lastUpdated = lastUpdated;
if (sourceCollection) body.sourceCollection = sourceCollection;
if (storageLocation) body.storageLocation = storageLocation;
if (locationDbId) body.locationDbId = locationDbId;
if (programDbId) body.programDbId = programDbId;
return body;
};
export function normalizeSeedLotFormData(record: SeedLotRecord): Record<string, unknown> {
const mixtures = mapContentMixtureToForm(record.content_mixture ?? record.contentMixture);
const primaryGermplasm = mixtures.length === 1 ? mixtures[0].germplasm_id : NONE_SELECT_VALUE;
return {
id: record.id,
name: record.name ?? "",
amount: record.amount ?? "",
units: record.units && record.units !== NONE_SELECT_VALUE ? record.units : NONE_SELECT_VALUE,
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
location_id: record.location_id && record.location_id !== NONE_SELECT_VALUE ? record.location_id : NONE_SELECT_VALUE,
storage_location: record.storage_location ?? "",
source_collection: record.source_collection ?? "",
created_date: record.created_date ?? "",
last_updated: record.last_updated ?? "",
seed_lot_description: record.seed_lot_description ?? "",
primary_germplasm_id: primaryGermplasm,
content_mixture: mixtures,
};
}
const seedLotRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots?page=0&pageSize=1000");
return response.result.data.map(mapSeedLot);
});
export function invalidateSeedLotRowsCache() {
seedLotRowsLoader.invalidate();
}
export async function fetchSeedLotRows(force = false): Promise<SeedLotRecord[]> {
return seedLotRowsLoader.load(force);
}
export async function fetchSeedLotDetail(id: string): Promise<SeedLotRecord> {
const response = await request<BrapiSingleResponse<SeedLotRecord>>(
`/brapi/v2/seedlots/${encodeURIComponent(id)}`,
);
return mapSeedLot(response.result);
}
export async function fetchSeedLotOptions(force = false): Promise<{
locations: SelectOption[];
programs: SelectOption[];
germplasms: SelectOption[];
crosses: SelectOption[];
seedLots: SelectOption[];
}> {
const [locations, programs, germplasms, crosses, seedLots] = await Promise.all([
loadLocationOptions(force),
loadProgramOptions(force),
loadGermplasmOptions(force),
loadCrossOptions(force),
fetchSeedLotRows(force).catch(() => [] as SeedLotRecord[]),
]);
return {
locations,
programs,
germplasms,
crosses,
seedLots: seedLots.map((seedLot) => ({
value: seedLot.id,
label: seedLot.name || seedLot.id,
})),
};
}
export async function createSeedLotRow(payload: SeedLotPayload): Promise<SeedLotRecord> {
const units = optionalText(payload.units);
if (!units) throw new Error("请选择数量单位");
const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots", {
method: "POST",
body: JSON.stringify([toRequestBody(payload, { includeAmount: true })]),
});
invalidateSeedLotRowsCache();
return mapSeedLot(response.result.data[0]);
}
export async function updateSeedLotRow(id: string, payload: SeedLotPayload): Promise<SeedLotRecord> {
const units = optionalText(payload.units);
if (!units) throw new Error("请选择数量单位");
const response = await request<BrapiSingleResponse<SeedLotRecord>>(`/brapi/v2/seedlots/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload, { includeAmount: false })),
});
invalidateSeedLotRowsCache();
return mapSeedLot(response.result);
}
export async function deleteSeedLotRow(id: string): Promise<void> {
await request<BrapiSingleResponse<SeedLotRecord>>(`/brapi/v2/seedlots/${encodeURIComponent(id)}`, {
method: "DELETE",
});
invalidateSeedLotRowsCache();
}
export async function fetchSeedLotTransactions(seedLotDbId?: string): Promise<SeedLotTransactionRecord[]> {
const [transactionsResponse, seedLots] = await Promise.all([
request<BrapiListResponse<SeedLotTransactionRecord>>(
seedLotDbId
? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=1000`
: "/brapi/v2/seedlots/transactions?page=0&pageSize=1000",
),
fetchSeedLotRows().catch(() => [] as SeedLotRecord[]),
]);
const seedLotNameById = new Map(seedLots.map((row) => [row.id, row.name || row.id]));
return transactionsResponse.result.data.map((transaction) => mapTransaction(transaction, seedLotNameById));
}
export function inferTransactionAction(transaction: SeedLotTransactionRecord): TransactionActionType | "unknown" {
const fromId = transaction.from_seed_lot_id;
const toId = transaction.to_seed_lot_id;
if (!fromId && toId) return "in";
if (fromId && !toId) {
const description = String(transaction.description ?? "").toLowerCase();
if (description.includes("报废") || description.includes("消耗")) return "consume";
return "out";
}
if (fromId && toId) {
const description = String(transaction.description ?? "");
if (description.includes("分装")) return "split";
return "transfer";
}
return "unknown";
}
export async function createSeedLotTransaction(payload: TransactionPayload, seedLotMap: Map<string, SeedLotRecord>) {
const amount = optionalNumber(payload.amount);
if (amount === null || amount <= 0) {
throw new Error("流转数量必须大于 0");
}
const description = optionalText(payload.description);
const timestamp = optionalText(payload.timestamp) ?? new Date().toISOString();
const fromId = optionalText(payload.from_seed_lot_id);
const toId = optionalText(payload.to_seed_lot_id);
if (fromId && toId && fromId === toId) {
throw new Error("来源批次与目标批次不能相同");
}
let fromSeedLotDbId: string | undefined;
let toSeedLotDbId: string | undefined;
let units: string | null = optionalText(payload.units);
switch (payload.action) {
case "in":
if (!toId) throw new Error("入库需选择目标批次");
toSeedLotDbId = toId;
units = units || seedLotMap.get(toId)?.units || null;
break;
case "out":
case "consume":
if (!fromId) throw new Error("出库/消耗需选择来源批次");
if ((payload.action === "out" || payload.action === "consume") && !description) {
throw new Error("出库/消耗/报废建议填写流转说明");
}
fromSeedLotDbId = fromId;
units = units || seedLotMap.get(fromId)?.units || null;
break;
case "transfer":
case "split":
if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批次");
fromSeedLotDbId = fromId;
toSeedLotDbId = toId;
units = units || seedLotMap.get(fromId)?.units || seedLotMap.get(toId)?.units || null;
break;
default:
throw new Error("未知的库存动作");
}
if (!fromSeedLotDbId && !toSeedLotDbId) {
throw new Error("来源批次与目标批次至少填写一个");
}
const sourceLot = fromSeedLotDbId ? seedLotMap.get(fromSeedLotDbId) : undefined;
if (fromSeedLotDbId && sourceLot) {
const currentAmount = Number(sourceLot.amount ?? 0);
if (amount > currentAmount) {
throw new Error(`出库数量不能超过当前库存(${currentAmount}${sourceLot.units ? ` ${sourceLot.units}` : ""}`);
}
}
if (!units) throw new Error("请指定流转单位");
const transactionDescription = payload.action === "consume" && description && !description.includes("消耗")
? `消耗/报废:${description}`
: payload.action === "split" && description && !description.includes("分装")
? `分装:${description}`
: description;
const response = await request<BrapiListResponse<SeedLotTransactionRecord>>("/brapi/v2/seedlots/transactions", {
method: "POST",
body: JSON.stringify([{
amount,
units,
transactionTimestamp: timestamp,
...(transactionDescription ? { transactionDescription } : {}),
...(fromSeedLotDbId ? { fromSeedLotDbId } : {}),
...(toSeedLotDbId ? { toSeedLotDbId } : {}),
}]),
});
const seedLotNameById = new Map(
Array.from(seedLotMap.entries()).map(([key, row]) => [key, row.name || row.id]),
);
return mapTransaction(response.result.data[0], seedLotNameById);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
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 { NONE_SELECT_VALUE, type ContentMixtureRow, type SelectOption } from "../types";
interface ContentMixtureEditorProps {
rows: ContentMixtureRow[];
germplasmOptions: SelectOption[];
crossOptions: SelectOption[];
onChange: (rows: ContentMixtureRow[]) => void;
disabled?: boolean;
}
const emptyRow = (): ContentMixtureRow => ({
germplasm_id: NONE_SELECT_VALUE,
cross_id: NONE_SELECT_VALUE,
mixture_percentage: "",
});
export function ContentMixtureEditor({
rows,
germplasmOptions,
crossOptions,
onChange,
disabled = false,
}: ContentMixtureEditorProps) {
const totalPercentage = rows.reduce((sum, row) => {
const value = Number(row.mixture_percentage);
return sum + (Number.isNaN(value) ? 0 : value);
}, 0);
const updateRow = (index: number, patch: Partial<ContentMixtureRow>) => {
onChange(rows.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)));
};
const addRow = () => {
onChange([...rows, emptyRow()]);
};
const removeRow = (index: number) => {
if (rows.length <= 1) return;
onChange(rows.filter((_, rowIndex) => rowIndex !== index));
};
return (
<div className="md:col-span-2 space-y-3 rounded-lg border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
<div className="flex items-start justify-between gap-3">
<div>
<Label className="text-sm font-medium text-slate-800 dark:text-slate-100"></Label>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
100% 100%
</p>
</div>
{!disabled ? (
<Button type="button" variant="outline" size="sm" className="shrink-0 gap-1" onClick={addRow}>
<Plus className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
<div className="space-y-3">
{rows.map((row, index) => (
<div
key={`mixture-${index}`}
className="grid grid-cols-1 gap-3 rounded-md border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-950 md:grid-cols-[1fr_1fr_120px_auto]"
>
<div>
<Label className="mb-1.5 block text-xs text-slate-500"></Label>
<Select
value={row.germplasm_id}
onValueChange={(value) => updateRow(index, { germplasm_id: value })}
disabled={disabled}
>
<SelectTrigger><SelectValue placeholder="选择 germplasm" /></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 text-slate-500"></Label>
<Select
value={row.cross_id}
onValueChange={(value) => updateRow(index, { cross_id: value })}
disabled={disabled}
>
<SelectTrigger><SelectValue placeholder="选择 cross" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></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-xs text-slate-500"> (%)</Label>
<Input
type="number"
min={0}
max={100}
value={row.mixture_percentage}
onChange={(event) => updateRow(index, { mixture_percentage: event.target.value })}
placeholder="100"
disabled={disabled}
/>
</div>
{!disabled && rows.length > 1 ? (
<div className="flex items-end">
<Button
type="button"
variant="ghost"
size="icon"
className="text-slate-400 hover:text-red-500"
onClick={() => removeRow(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
))}
</div>
<p className={`text-xs ${Math.abs(totalPercentage - 100) <= 0.01 ? "text-emerald-600 dark:text-emerald-400" : "text-amber-600 dark:text-amber-400"}`}>
{totalPercentage}%
{Math.abs(totalPercentage - 100) > 0.01 ? "(建议调整为 100%" : ""}
</p>
</div>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Package } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
createSeedLotRow,
deleteSeedLotRow,
fetchSeedLotDetail,
fetchSeedLotOptions,
fetchSeedLotRows,
mapContentMixtureToForm,
normalizeSeedLotFormData,
updateSeedLotRow,
} from "../api";
import { ContentMixtureEditor } from "./ContentMixtureEditor";
import {
NONE_SELECT_VALUE,
STOCK_STATUS_LABEL,
resolveStockStatus,
type ContentMixtureRow,
type SelectOption,
} from "../types";
const unitOptions: SelectOption[] = [
{ value: "seeds", label: "seeds 粒" },
{ value: "g", label: "g 克" },
{ value: "kg", label: "kg 千克" },
{ value: "plants", label: "plants 株" },
{ value: "bag", label: "bag 袋" },
{ value: "tube", label: "tube 管" },
];
function StockStatusBadge({ amount }: { amount: unknown }) {
const status = resolveStockStatus(typeof amount === "number" ? amount : Number(amount ?? 0));
const className = status === "depleted"
? "bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-200"
: status === "low"
? "bg-amber-50 text-amber-700 dark:bg-amber-400/10 dark:text-amber-200"
: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200";
return <Badge variant="outline" className={className}>{STOCK_STATUS_LABEL[status]}</Badge>;
}
export function SeedLotTab() {
const [locationOptions, setLocationOptions] = useState<SelectOption[]>([]);
const [programOptions, setProgramOptions] = useState<SelectOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const [crossOptions, setCrossOptions] = useState<SelectOption[]>([]);
const applyOptions = useCallback((options: Awaited<ReturnType<typeof fetchSeedLotOptions>>) => {
setLocationOptions(options.locations);
setProgramOptions(options.programs);
setGermplasmOptions(options.germplasms);
setCrossOptions(options.crosses);
return options;
}, []);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchSeedLotOptions(), fetchSeedLotRows()]);
applyOptions(options);
return rows as unknown as Record<string, unknown>[];
}, [applyOptions]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchSeedLotDetail(id);
return normalizeSeedLotFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "name", label: "批次名称", type: "text", required: true, placeholder: "如 华占-2026-荆门-扩繁批" },
{
key: "units",
label: "数量单位",
type: "select",
required: true,
options: unitOptions,
},
{
key: "program_id",
label: "所属项目",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联项目" }, ...programOptions],
},
{
key: "location_id",
label: "存放地点",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联地点" }, ...locationOptions],
},
{ key: "storage_location", label: "库位描述", type: "text", placeholder: "如 冰箱A-2层-盒03" },
{ key: "source_collection", label: "来源集合", type: "text", placeholder: "如 野外采集 / nursery / 种质库集合" },
{ key: "created_date", label: "创建日期", type: "date" },
{ key: "seed_lot_description", label: "批次说明", type: "textarea", placeholder: "材料来源、处理方式、入库备注等", colSpan: 2 },
], [locationOptions, programOptions]);
const renderFormExtra = useCallback((props: {
formData: Record<string, unknown>;
updateForm: (key: string, value: string) => void;
updateFormBatch: (patch: Record<string, unknown>) => void;
editingRow: Record<string, unknown> | null;
}) => {
const isEditing = Boolean(props.editingRow);
const mixtureRows = Array.isArray(props.formData.content_mixture)
? props.formData.content_mixture as ContentMixtureRow[]
: mapContentMixtureToForm([]);
const handlePrimaryGermplasmChange = (value: string) => {
props.updateForm("primary_germplasm_id", value);
if (!isEditing && value !== NONE_SELECT_VALUE) {
props.updateFormBatch({
primary_germplasm_id: value,
content_mixture: [{ germplasm_id: value, cross_id: NONE_SELECT_VALUE, mixture_percentage: "100" }],
});
}
};
return (
<>
{isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200"> ID</Label>
<Input
value={String(props.formData.id ?? "—")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
</div>
) : null}
{!isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200">
</Label>
<Select
value={String(props.formData.primary_germplasm_id ?? NONE_SELECT_VALUE)}
onValueChange={handlePrimaryGermplasmChange}
>
<SelectTrigger><SelectValue placeholder="选择单个 germplasm 可自动生成 100% 组成" /></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>
<p className="mt-1 text-xs text-slate-500"> 100% </p>
</div>
) : null}
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200">
{isEditing ? "当前库存(只读)" : "初始库存数量"}
</Label>
{isEditing ? (
<Input
value={String(props.formData.amount ?? "0")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
) : (
<Input
type="number"
min={0}
value={String(props.formData.amount ?? "")}
onChange={(event) => props.updateForm("amount", event.target.value)}
placeholder="可选;建议后续通过「库存交易」入库"
/>
)}
<p className="mt-1 text-xs text-slate-500">
{isEditing ? "库存数量请通过「库存交易」Tab 的入库/出库/转移等动作更新。" : "也可创建后在「库存交易」中执行入库。"}
</p>
</div>
{isEditing ? (
<div>
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200"></Label>
<Input
value={String(props.formData.last_updated ?? "—")}
readOnly
className="bg-slate-50 dark:bg-slate-900"
/>
</div>
) : null}
<ContentMixtureEditor
rows={mixtureRows}
germplasmOptions={germplasmOptions}
crossOptions={crossOptions}
onChange={(rows) => props.updateFormBatch({ content_mixture: rows })}
/>
</>
);
}, [crossOptions, germplasmOptions]);
return (
<BrapiEntityPage
icon={Package}
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
title="SeedLot 种子批次"
description="维护种子或材料库存批次,记录存放地点、项目归属与批次组成;库存数量通过交易更新"
addLabel="新增批次"
useEnhancedDialog
fetchRecord={fetchRecord}
renderFormExtra={renderFormExtra}
columns={[
{ key: "id", label: "批次 ID" },
{ key: "name", label: "批次名称" },
{
key: "stock_status",
label: "库存状态",
render: (_, row) => <StockStatusBadge amount={row.amount} />,
},
{
key: "amount",
label: "库存",
render: (value, row) => {
const amount = value ?? "—";
const units = row.units ? ` ${String(row.units)}` : "";
return `${amount}${units}`;
},
},
{
key: "mixture_summary",
label: "批次组成",
render: (_, row) => {
const mixtures = (row.content_mixture ?? row.contentMixture) as Array<Record<string, unknown>> | undefined;
if (!mixtures?.length) return "—";
return mixtures.map((mixture) => {
const label = mixture.germplasmName || mixture.germplasm_name || mixture.crossName || mixture.cross_name || "未知";
const percentage = mixture.mixturePercentage ?? mixture.mixture_percentage;
return `${label}${percentage != null ? ` (${percentage}%)` : ""}`;
}).join("");
},
},
{ key: "program_name", label: "项目" },
{ key: "location_name", label: "地点" },
{ key: "storage_location", label: "库位" },
{ key: "source_collection", label: "来源集合" },
{ key: "last_updated", label: "最后更新" },
]}
fields={fields}
data={[]}
stats={[
{ label: "/brapi/v2/seedlots", value: "BrAPI", className: "bg-lime-50 text-lime-700 dark:bg-lime-400/10 dark:text-lime-200" },
{ label: "contentMixture", value: "PUT/POST", className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200" },
]}
loadData={loadRows}
createRecord={async (payload) => {
const normalized = {
...payload,
content_mixture: payload.content_mixture ?? mapContentMixtureToForm([]),
};
return createSeedLotRow(normalized) as unknown as Promise<Record<string, unknown>>;
}}
updateRecord={async (id, payload) => {
const normalized = {
...payload,
content_mixture: payload.content_mixture ?? mapContentMixtureToForm([]),
};
return updateSeedLotRow(id, normalized) as unknown as Promise<Record<string, unknown>>;
}}
deleteRecord={deleteSeedLotRow}
/>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeftRight, Plus, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
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 { Badge } from "@/components/ui/badge";
import { fetchSeedLotOptions, fetchSeedLotRows, fetchSeedLotTransactions, inferTransactionAction } from "../api";
import { TransactionActionDialog } from "./TransactionActionDialog";
import {
NONE_SELECT_VALUE,
TRANSACTION_ACTION_LABEL,
type SeedLotRecord,
type SeedLotTransactionRecord,
type SelectOption,
type TransactionActionType,
} from "../types";
function actionBadge(action: TransactionActionType | "unknown") {
if (action === "unknown") {
return <Badge variant="outline"></Badge>;
}
const className = action === "in"
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200"
: action === "out" || action === "consume"
? "bg-red-50 text-red-700 dark:bg-red-400/10 dark:text-red-200"
: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200";
return <Badge variant="outline" className={className}>{TRANSACTION_ACTION_LABEL[action]}</Badge>;
}
export function SeedLotTransactionTab() {
const [rows, setRows] = useState<SeedLotTransactionRecord[]>([]);
const [seedLots, setSeedLots] = useState<SeedLotRecord[]>([]);
const [seedLotOptions, setSeedLotOptions] = useState<SelectOption[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [filterSeedLotId, setFilterSeedLotId] = useState(NONE_SELECT_VALUE);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [dialogOpen, setDialogOpen] = useState(false);
const seedLotMap = useMemo(
() => new Map(seedLots.map((row) => [row.id, row])),
[seedLots],
);
const loadData = useCallback(async (force = false) => {
setLoading(true);
setError(null);
try {
const [options, lots, transactions] = await Promise.all([
fetchSeedLotOptions(force),
fetchSeedLotRows(force),
fetchSeedLotTransactions(filterSeedLotId !== NONE_SELECT_VALUE ? filterSeedLotId : undefined),
]);
setSeedLotOptions(options.seedLots);
setSeedLots(lots);
setRows(transactions);
} catch (event) {
setError(event instanceof Error ? event.message : "交易记录加载失败");
} finally {
setLoading(false);
}
}, [filterSeedLotId]);
useEffect(() => {
let mounted = true;
loadData().catch(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [loadData]);
const filteredRows = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) => [
row.id,
row.description,
row.from_seed_lot_name,
row.to_seed_lot_name,
row.from_seed_lot_id,
row.to_seed_lot_id,
].some((value) => String(value ?? "").toLowerCase().includes(keyword)));
}, [rows, search]);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
const pagedRows = filteredRows.slice((page - 1) * pageSize, page * pageSize);
useEffect(() => {
setPage(1);
}, [search, filterSeedLotId, pageSize]);
return (
<div className="flex min-h-full flex-col">
<div className="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-center gap-3">
<div className="rounded-xl bg-gradient-to-br from-cyan-500 to-blue-600 p-2.5">
<ArrowLeftRight className="h-5 w-5 text-white" />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50"></h2>
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
/ seed_lot_transaction
</p>
</div>
</div>
<Button className="shrink-0 gap-2" onClick={() => setDialogOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="mb-4 flex flex-wrap gap-2">
<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">
GET /brapi/v2/seedlots/transactions
</span>
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-700 dark:bg-blue-400/10 dark:text-blue-200">
POST /brapi/v2/seedlots/transactions
</span>
</div>
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-[240px_1fr]">
<Select value={filterSeedLotId} onValueChange={setFilterSeedLotId}>
<SelectTrigger><SelectValue placeholder="按批次筛选" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{seedLotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="搜索交易 ID、说明、来源/目标批次..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="bg-white pl-9 dark:bg-slate-950"
/>
</div>
</div>
{error ? (
<div className="mb-4 rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900">
<TableHead className="w-10 text-xs font-medium text-slate-400">#</TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
<TableHead className="text-xs font-medium text-slate-500"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 6 }).map((_, index) => (
<TableRow key={`loading-${index}`}>
<TableCell><Skeleton className="h-4 w-5" /></TableCell>
{Array.from({ length: 6 }).map((__, cellIndex) => (
<TableCell key={cellIndex}><Skeleton className="h-4 w-24" /></TableCell>
))}
</TableRow>
))
) : pagedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-16 text-center text-sm text-slate-400">
</TableCell>
</TableRow>
) : (
pagedRows.map((row, index) => {
const action = inferTransactionAction(row);
return (
<TableRow key={row.id || index}>
<TableCell className="text-xs text-slate-400">{(page - 1) * pageSize + index + 1}</TableCell>
<TableCell>{actionBadge(action)}</TableCell>
<TableCell className="text-sm">{row.from_seed_lot_name || row.from_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.to_seed_lot_name || row.to_seed_lot_id || "—"}</TableCell>
<TableCell className="text-sm">{row.amount ?? "—"}{row.units ? ` ${row.units}` : ""}</TableCell>
<TableCell className="text-sm">{row.timestamp ? String(row.timestamp).slice(0, 19).replace("T", " ") : "—"}</TableCell>
<TableCell className="max-w-[20rem] whitespace-normal break-words text-sm">{row.description || "—"}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-400"> {filteredRows.length} {page}/{totalPages} </p>
<div className="flex items-center gap-3">
<Select value={String(pageSize)} onValueChange={(value) => setPageSize(Number(value))}>
<SelectTrigger className="h-8 w-[110px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="10">10 / </SelectItem>
<SelectItem value="20">20 / </SelectItem>
<SelectItem value="50">50 / </SelectItem>
</SelectContent>
</Select>
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious onClick={() => setPage((prev) => Math.max(1, prev - 1))} disabled={page <= 1} />
</PaginationItem>
{Array.from({ length: totalPages }).slice(0, 5).map((_, idx) => {
const pageNumber = idx + 1;
return (
<PaginationItem key={pageNumber}>
<PaginationLink isActive={pageNumber === page} onClick={() => setPage(pageNumber)}>{pageNumber}</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))} disabled={page >= totalPages} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
<TransactionActionDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
seedLotOptions={seedLotOptions}
seedLotMap={seedLotMap}
defaultSeedLotId={filterSeedLotId !== NONE_SELECT_VALUE ? filterSeedLotId : undefined}
onSuccess={() => {
void loadData(true);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Dialog as EnhancedDialog,
DialogBody,
DialogContent as EnhancedDialogContent,
DialogFooter as EnhancedDialogFooter,
} from "@/components/common/shadcn-enhanced";
import { DateTimePicker } from "@/components/common/shadcn-enhanced/date-time-picker";
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 { Textarea } from "@/components/ui/textarea";
import { createSeedLotTransaction } from "../api";
import {
NONE_SELECT_VALUE,
TRANSACTION_ACTION_LABEL,
type SeedLotRecord,
type SelectOption,
type TransactionActionType,
} from "../types";
interface TransactionActionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
seedLotOptions: SelectOption[];
seedLotMap: Map<string, SeedLotRecord>;
defaultSeedLotId?: string;
onSuccess: () => void;
}
const actionOptions: TransactionActionType[] = ["in", "out", "transfer", "split", "consume"];
function parseDateTimeValue(value: string): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatDateTimeValue(date: Date | null): string {
if (!date) return "";
return date.toISOString();
}
export function TransactionActionDialog({
open,
onOpenChange,
seedLotOptions,
seedLotMap,
defaultSeedLotId,
onSuccess,
}: TransactionActionDialogProps) {
const [action, setAction] = useState<TransactionActionType>("in");
const [fromSeedLotId, setFromSeedLotId] = useState(NONE_SELECT_VALUE);
const [toSeedLotId, setToSeedLotId] = useState(NONE_SELECT_VALUE);
const [amount, setAmount] = useState("");
const [units, setUnits] = useState("");
const [description, setDescription] = useState("");
const [timestamp, setTimestamp] = useState(new Date().toISOString());
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const resetForm = useCallback(() => {
setAction("in");
setFromSeedLotId(NONE_SELECT_VALUE);
setToSeedLotId(defaultSeedLotId && defaultSeedLotId !== NONE_SELECT_VALUE ? defaultSeedLotId : NONE_SELECT_VALUE);
setAmount("");
setUnits("");
setDescription("");
setTimestamp(new Date().toISOString());
setError(null);
}, [defaultSeedLotId]);
useEffect(() => {
if (open) resetForm();
}, [open, resetForm]);
const relatedLot = useMemo(() => {
const lotId = action === "in" ? toSeedLotId : fromSeedLotId;
if (!lotId || lotId === NONE_SELECT_VALUE) return null;
return seedLotMap.get(lotId) ?? null;
}, [action, fromSeedLotId, seedLotMap, toSeedLotId]);
useEffect(() => {
if (relatedLot?.units) {
setUnits(relatedLot.units);
}
}, [relatedLot]);
const requiresFrom = action === "out" || action === "transfer" || action === "split" || action === "consume";
const requiresTo = action === "in" || action === "transfer" || action === "split";
const requiresDescription = action === "out" || action === "consume";
const handleSubmit = async () => {
setSaving(true);
setError(null);
try {
await createSeedLotTransaction(
{
action,
amount,
description,
timestamp,
units,
from_seed_lot_id: fromSeedLotId,
to_seed_lot_id: toSeedLotId,
},
seedLotMap,
);
onSuccess();
onOpenChange(false);
} catch (event) {
setError(event instanceof Error ? event.message : "创建交易失败");
} finally {
setSaving(false);
}
};
return (
<EnhancedDialog open={open} onOpenChange={onOpenChange}>
<EnhancedDialogContent title="新建库存交易" defaultWidth={720} defaultHeight={640} minHeight={520}>
<DialogBody className="space-y-4">
<p className="text-sm text-muted-foreground">
amount
</p>
{error ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label className="mb-1.5 block text-sm"></Label>
<Select value={action} onValueChange={(value) => setAction(value as TransactionActionType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="z-[110]">
{actionOptions.map((option) => (
<SelectItem key={option} value={option}>{TRANSACTION_ACTION_LABEL[option]}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{requiresFrom ? (
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select value={fromSeedLotId} onValueChange={setFromSeedLotId}>
<SelectTrigger><SelectValue placeholder="选择来源批次" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{seedLotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
{relatedLot && requiresFrom ? (
<p className="mt-1 text-xs text-slate-500">{relatedLot.amount ?? 0} {relatedLot.units ?? ""}</p>
) : null}
</div>
) : null}
{requiresTo ? (
<div>
<Label className="mb-1.5 block text-sm"></Label>
<Select value={toSeedLotId} onValueChange={setToSeedLotId}>
<SelectTrigger><SelectValue placeholder="选择目标批次" /></SelectTrigger>
<SelectContent position="popper" className="z-[110] max-h-60">
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{seedLotOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div>
<Label className="mb-1.5 block text-sm"><span className="ml-0.5 text-red-500">*</span></Label>
<Input type="number" min={0} value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="大于 0" />
</div>
<div>
<Label className="mb-1.5 block text-sm"><span className="ml-0.5 text-red-500">*</span></Label>
<Input value={units} onChange={(event) => setUnits(event.target.value)} placeholder="继承批次单位" />
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-sm"></Label>
<DateTimePicker
mode="datetime-minute"
value={parseDateTimeValue(timestamp)}
onChange={(date) => setTimestamp(formatDateTimeValue(date))}
className="w-full"
/>
</div>
<div className="md:col-span-2">
<Label className="mb-1.5 block text-sm">
{requiresDescription ? <span className="ml-0.5 text-red-500">*</span> : null}
</Label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder={action === "consume" ? "请填写消耗或报废原因" : "如用于某 study、分装原因等"}
rows={3}
className="resize-none"
/>
</div>
</div>
</DialogBody>
<EnhancedDialogFooter className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}></Button>
<Button onClick={() => void handleSubmit()} disabled={saving}>{saving ? "提交中..." : "提交交易"}</Button>
</EnhancedDialogFooter>
</EnhancedDialogContent>
</EnhancedDialog>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import { ArrowLeftRight, Package } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SeedLotTab } from "./components/SeedLotTab";
import { SeedLotTransactionTab } from "./components/SeedLotTransactionTab";
export default function SeedLotPage() {
const [tab, setTab] = useState("lots");
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="lots" className="gap-2">
<Package className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="transactions" className="gap-2">
<ArrowLeftRight className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{tab === "lots" ? (
<TabsContent value="lots" className="mt-0 min-h-0 flex-1">
<SeedLotTab />
</TabsContent>
) : null}
{tab === "transactions" ? (
<TabsContent value="transactions" className="mt-0 min-h-0 flex-1">
<SeedLotTransactionTab />
</TabsContent>
) : null}
</Tabs>
);
}

View File

@@ -0,0 +1,102 @@
export const NONE_SELECT_VALUE = "__none__";
/** 低库存阈值amount 低于此值且大于 0 时显示「低库存」 */
export const LOW_STOCK_THRESHOLD = 100;
export interface SelectOption {
value: string;
label: string;
}
export interface ContentMixtureRow {
germplasm_id: string;
cross_id: string;
mixture_percentage: string;
}
export type TransactionActionType = "in" | "out" | "transfer" | "split" | "consume";
export interface SeedLotContentMixtureRecord {
crossDbId?: string | null;
crossName?: string | null;
cross_id?: string | null;
cross_name?: string | null;
germplasmDbId?: string | null;
germplasmName?: string | null;
germplasm_id?: string | null;
germplasm_name?: string | null;
mixturePercentage?: number | null;
mixture_percentage?: number | null;
}
export interface SeedLotRecord {
id: string;
seedLotDbId: string;
seedLotName: string | null;
seed_lot_name: string | null;
name: string | null;
seedLotDescription: string | null;
seed_lot_description: string | null;
description: string | null;
amount: number | null;
units: string | null;
createdDate: string | null;
created_date: string | null;
lastUpdated: string | null;
last_updated: string | null;
sourceCollection: string | null;
source_collection: string | null;
storageLocation: string | null;
storage_location: string | null;
locationDbId: string | null;
location_id: string | null;
locationName: string | null;
location_name: string | null;
programDbId: string | null;
program_id: string | null;
programName: string | null;
program_name: string | null;
germplasmDbId?: string | null;
contentMixture?: SeedLotContentMixtureRecord[] | null;
content_mixture?: SeedLotContentMixtureRecord[] | null;
}
export interface SeedLotTransactionRecord {
id: string;
transactionDbId?: string | null;
amount: number | null;
units: string | null;
fromSeedLotDbId?: string | null;
from_seed_lot_id?: string | null;
toSeedLotDbId?: string | null;
to_seed_lot_id?: string | null;
transactionDescription?: string | null;
description?: string | null;
transactionTimestamp?: string | null;
timestamp?: string | null;
from_seed_lot_name?: string | null;
to_seed_lot_name?: string | null;
}
export type StockStatus = "depleted" | "low" | "sufficient";
export function resolveStockStatus(amount: number | null | undefined): StockStatus {
const value = Number(amount ?? 0);
if (value <= 0) return "depleted";
if (value < LOW_STOCK_THRESHOLD) return "low";
return "sufficient";
}
export const STOCK_STATUS_LABEL: Record<StockStatus, string> = {
depleted: "耗尽",
low: "低库存",
sufficient: "充足",
};
export const TRANSACTION_ACTION_LABEL: Record<TransactionActionType, string> = {
in: "入库",
out: "出库",
transfer: "转移",
split: "分装",
consume: "消耗/报废",
};

View File

@@ -0,0 +1,38 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { BrapiSidebar, BrapiTopbar } from "@/components/brapi/BrapiShell";
import { useMenuStore, useAuthStore } from "@/stores";
export default function AppLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const fetchMenus = useMenuStore((state) => state.fetchMenus);
const token = useAuthStore((state) => state.token);
const hydrated = useAuthStore((state) => state.hydrated);
useEffect(() => {
if (!hydrated) {
return;
}
if (!token) {
router.replace("/login");
return;
}
void fetchMenus();
}, [fetchMenus, hydrated, router, token]);
if (!hydrated || !token) {
return null;
}
return (
<div className="flex h-screen overflow-hidden bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<BrapiSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<BrapiTopbar />
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,278 @@
import { getAuthToken } from "@/utils/token";
import { NONE_SELECT_VALUE, type EventRecord, type ImageRecord, 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;
}
interface StudyResponse {
studyDbId: string;
studyName: string | null;
}
interface ObservationUnitResponse {
observationUnitDbId: string;
observationUnitName: string | null;
}
interface ObservationResponse {
observationDbId: string;
observationUnitName: string | null;
observationVariableName: string | null;
}
type EventPayload = Partial<Record<
| "id"
| "event_type"
| "event_type_db_id"
| "event_description"
| "study_db_id"
| "observation_unit_db_ids"
| "start_date"
| "end_date",
unknown
>>;
type ImagePayload = Partial<Record<
| "id"
| "image_name"
| "image_file_name"
| "image_url"
| "mime_type"
| "description"
| "study_db_id"
| "observation_unit_db_id"
| "observation_db_ids"
| "image_time_stamp",
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 || `Request failed: ${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 requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const csvToArray = (value: unknown) => {
const normalized = optionalText(value);
if (!normalized) return [];
return normalized
.split(",")
.map((part) => part.trim())
.filter(Boolean);
};
const arrayToCsv = (value: unknown) => {
if (!Array.isArray(value)) return null;
return value.map((item) => String(item)).filter(Boolean).join(", ");
};
const mapEvent = (event: EventRecord): EventRecord => {
const startDate = event.start_date || event.eventDateRange?.startDate || null;
const endDate = event.end_date || event.eventDateRange?.endDate || null;
const unitIds = Array.isArray(event.observationUnitDbIds) ? event.observationUnitDbIds : [];
return {
...event,
id: event.eventDbId || event.id,
event_type: event.event_type || event.eventType || null,
event_type_db_id: event.event_type_db_id || event.eventTypeDbId || null,
event_description: event.event_description || event.eventDescription || null,
study_db_id: event.study_db_id || event.studyDbId || null,
observation_unit_db_ids: event.observation_unit_db_ids || arrayToCsv(unitIds),
start_date: startDate,
end_date: endDate,
};
};
const mapImage = (image: ImageRecord): ImageRecord => {
const observationIds = Array.isArray(image.observationDbIds) ? image.observationDbIds : [];
return {
...image,
id: image.imageDbId || image.id,
image_name: image.image_name || image.imageName || null,
image_file_name: image.image_file_name || image.imageFileName || null,
image_url: image.image_url || image.imageURL || null,
mime_type: image.mime_type || image.mimeType || null,
study_db_id: image.study_db_id || image.studyDbId || null,
observation_unit_db_id: image.observation_unit_db_id || image.observationUnitDbId || null,
observation_db_ids: image.observation_db_ids || arrayToCsv(observationIds),
image_time_stamp: image.image_time_stamp || image.imageTimeStamp || null,
image_file_size: image.image_file_size ?? image.imageFileSize ?? null,
image_width: image.image_width ?? image.imageWidth ?? null,
image_height: image.image_height ?? image.imageHeight ?? null,
};
};
const eventBody = (payload: EventPayload) => ({
eventType: requiredText(payload.event_type, "Event type is required"),
eventTypeDbId: optionalText(payload.event_type_db_id),
eventDescription: optionalText(payload.event_description),
studyDbId: optionalText(payload.study_db_id),
observationUnitDbIds: csvToArray(payload.observation_unit_db_ids),
eventDateRange: {
startDate: optionalText(payload.start_date),
endDate: optionalText(payload.end_date),
},
});
const imageBody = (payload: ImagePayload) => ({
imageName: requiredText(payload.image_name, "Image name is required"),
imageFileName: optionalText(payload.image_file_name),
imageURL: optionalText(payload.image_url),
mimeType: optionalText(payload.mime_type),
description: optionalText(payload.description),
studyDbId: optionalText(payload.study_db_id),
observationUnitDbId: optionalText(payload.observation_unit_db_id),
observationDbIds: csvToArray(payload.observation_db_ids),
imageTimeStamp: optionalText(payload.image_time_stamp),
imageFileSize: null,
imageWidth: null,
imageHeight: null,
});
export async function fetchEventRows(): Promise<EventRecord[]> {
const response = await request<BrapiListResponse<EventRecord>>("/brapi/v2/events?page=0&pageSize=1000");
return response.result.data.map(mapEvent);
}
export async function fetchImageRows(): Promise<ImageRecord[]> {
const response = await request<BrapiListResponse<ImageRecord>>("/brapi/v2/images?page=0&pageSize=1000");
return response.result.data.map(mapImage);
}
export async function fetchEventImageOptions(): Promise<{
studies: SelectOption[];
observationUnits: SelectOption[];
observations: SelectOption[];
}> {
const [studies, observationUnits, observations] = await Promise.all([
request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=1000"),
request<BrapiListResponse<ObservationUnitResponse>>("/brapi/v2/observationunits?page=0&pageSize=1000"),
request<BrapiListResponse<ObservationResponse>>("/brapi/v2/observations?page=0&pageSize=1000"),
]);
return {
studies: studies.result.data.map((study) => ({
value: study.studyDbId,
label: study.studyName || study.studyDbId,
})),
observationUnits: observationUnits.result.data.map((unit) => ({
value: unit.observationUnitDbId,
label: unit.observationUnitName || unit.observationUnitDbId,
})),
observations: observations.result.data.map((observation) => ({
value: observation.observationDbId,
label: `${observation.observationDbId}${observation.observationUnitName ? ` / ${observation.observationUnitName}` : ""}${observation.observationVariableName ? ` / ${observation.observationVariableName}` : ""}`,
})),
};
}
export async function createEventRow(payload: EventPayload): Promise<EventRecord> {
const response = await request<BrapiListResponse<EventRecord>>("/brapi/v2/events", {
method: "POST",
body: JSON.stringify({
eventDbId: requiredText(payload.id, "Event ID is required"),
...eventBody(payload),
}),
});
return mapEvent(response.result.data[0]);
}
export async function updateEventRow(id: string, payload: EventPayload): Promise<EventRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("Event ID is immutable. Create a new record instead.");
}
const response = await request<BrapiListResponse<EventRecord>>(`/brapi/v2/events/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(eventBody(payload)),
});
return mapEvent(response.result.data[0]);
}
export async function deleteEventRow(id: string): Promise<void> {
await request<BrapiSingleResponse<EventRecord>>(`/brapi/v2/events/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
export async function createImageRow(payload: ImagePayload): Promise<ImageRecord> {
const response = await request<BrapiListResponse<ImageRecord>>("/brapi/v2/images", {
method: "POST",
body: JSON.stringify({
imageDbId: requiredText(payload.id, "Image ID is required"),
...imageBody(payload),
}),
});
return mapImage(response.result.data[0]);
}
export async function updateImageRow(id: string, payload: ImagePayload): Promise<ImageRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("Image ID is immutable. Create a new record instead.");
}
const response = await request<BrapiListResponse<ImageRecord>>(`/brapi/v2/images/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(imageBody(payload)),
});
return mapImage(response.result.data[0]);
}
export async function deleteImageRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ImageRecord>>(`/brapi/v2/images/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,88 @@
"use client";
import { ImageUploader, type UploadedImage } from "@/components/common/image-uploader";
import { Label } from "@/components/ui/label";
function stripExtension(filename: string): string {
const lastDot = filename.lastIndexOf(".");
return lastDot > 0 ? filename.slice(0, lastDot) : filename;
}
function toImageId(baseName: string): string {
const slug = baseName
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || `image-${Date.now()}`;
}
function formatFileDate(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function buildAutoFillPatch(
image: UploadedImage,
file: File,
current: Record<string, unknown>,
): Record<string, string> {
const baseName = stripExtension(image.filename);
const patch: Record<string, string> = {
image_file_name: image.filename,
image_url: image.url,
mime_type: image.contentType,
};
if (!String(current.id ?? "").trim()) {
patch.id = toImageId(baseName);
}
if (!String(current.image_name ?? "").trim()) {
patch.image_name = baseName;
}
if (!String(current.image_time_stamp ?? "").trim()) {
patch.image_time_stamp = formatFileDate(file.lastModified);
}
return patch;
}
type ImageFormUploadProps = {
formData: Record<string, unknown>;
updateForm: (key: string, value: string) => void;
updateFormBatch: (patch: Record<string, unknown>) => void;
};
export function ImageFormUpload({ formData, updateForm, updateFormBatch }: ImageFormUploadProps) {
const imageUrl = String(formData.image_url ?? "").trim();
const handleUploaded = (image: UploadedImage, file: File) => {
updateFormBatch(buildAutoFillPatch(image, file, formData));
};
const handleRemoved = () => {
updateForm("image_url", "");
};
return (
<div className="md:col-span-2">
<Label className="mb-1.5 block text-sm text-slate-700 dark:text-slate-200">
</Label>
<p className="mb-2 text-xs text-muted-foreground">
Image IDImage NameFile NameImage URLMIME Type Image Date
</p>
<ImageUploader
maxFiles={1}
showPreview
onUploaded={handleUploaded}
onRemoved={handleRemoved}
value={imageUrl ? [imageUrl] : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { CalendarClock, Camera } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
createEventRow,
createImageRow,
deleteEventRow,
deleteImageRow,
fetchEventImageOptions,
fetchEventRows,
fetchImageRows,
updateEventRow,
updateImageRow,
} from "./api";
import { ImageFormUpload } from "./components/ImageFormUpload";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const eventTypeOptions: SelectOption[] = [
{ value: "observation", label: "observation / 观察测量" },
{ value: "planting", label: "planting / 播种" },
{ value: "fertilizer", label: "fertilizer / 施肥" },
{ value: "irrigation", label: "irrigation / 灌溉" },
{ value: "tillage", label: "tillage / 耕作整地" },
{ value: "chemicals", label: "chemicals / 药剂处理" },
{ value: "weeding", label: "weeding / 除草" },
{ value: "harvest", label: "harvest / 收获" },
{ value: "other", label: "other / 其他" },
];
const mimeTypeOptions: SelectOption[] = [
{ value: "image/jpeg", label: "image/jpeg" },
{ value: "image/png", label: "image/png" },
{ value: "image/gif", label: "image/gif" },
{ value: "image/webp", label: "image/webp" },
{ value: "image/bmp", label: "image/bmp" },
];
const formatDateRange = (start: unknown, end: unknown) => {
const startText = String(start ?? "").trim();
const endText = String(end ?? "").trim();
if (!startText && !endText) return "N/A";
if (startText && endText) return `${startText} ~ ${endText}`;
return startText || endText;
};
const eventTypeLabel = (value: unknown) => {
const text = String(value ?? "").trim();
const match = eventTypeOptions.find((option) => option.value === text);
return match?.label || text || "N/A";
};
export default function EventImagePage() {
const [studyOptions, setStudyOptions] = useState<SelectOption[]>([]);
const [observationUnitOptions, setObservationUnitOptions] = useState<SelectOption[]>([]);
const [observationOptions, setObservationOptions] = useState<SelectOption[]>([]);
const loadOptions = useCallback(async () => {
const options = await fetchEventImageOptions();
setStudyOptions(options.studies);
setObservationUnitOptions(options.observationUnits);
setObservationOptions(options.observations);
}, []);
const loadEvents = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchEventRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const loadImages = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchImageRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const eventFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Event ID", type: "text", required: true, placeholder: "event-001" },
{ key: "event_type", label: "Event Type", type: "select", required: true, options: eventTypeOptions },
{ key: "event_type_db_id", label: "Event Type DbId", type: "text", placeholder: "CO:0000000" },
{
key: "study_db_id",
label: "Study",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "No study" }, ...studyOptions],
},
{ key: "start_date", label: "Start Date", type: "date" },
{ key: "end_date", label: "End Date", type: "date" },
{
key: "observation_unit_db_ids",
label: "ObservationUnit IDs",
type: "text",
placeholder: "ou-plot-001, ou-plot-002",
colSpan: 2,
},
{ key: "event_description", label: "Description", type: "textarea", colSpan: 2 },
], [studyOptions]);
const imageFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Image ID", type: "text", required: true, placeholder: "image-001" },
{ key: "image_name", label: "Image Name", type: "text", required: true, placeholder: "plot-001-canopy" },
{ key: "image_file_name", label: "File Name", type: "text", placeholder: "image-001.jpg" },
{ key: "image_url", label: "Image URL", type: "text", placeholder: "https://example.org/image.jpg", colSpan: 2 },
{ key: "mime_type", label: "MIME Type", type: "select", options: mimeTypeOptions },
{ key: "image_time_stamp", label: "Image Date", type: "date" },
{
key: "study_db_id",
label: "Study",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "No study" }, ...studyOptions],
},
{
key: "observation_unit_db_id",
label: "ObservationUnit",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "No observation unit" }, ...observationUnitOptions],
},
{
key: "observation_db_ids",
label: "Observation IDs",
type: "text",
placeholder: observationOptions.slice(0, 3).map((option) => option.value).join(", ") || "obs-001, obs-002",
colSpan: 2,
},
{ key: "description", label: "Description", type: "textarea", colSpan: 2 },
], [observationOptions, observationUnitOptions, studyOptions]);
return (
<Tabs defaultValue="events" 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="events" className="gap-2"><CalendarClock className="h-4 w-4" />Events</TabsTrigger>
<TabsTrigger value="images" className="gap-2"><Camera className="h-4 w-4" />Images</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
icon={CalendarClock}
iconBg="bg-gradient-to-br from-fuchsia-500 to-pink-600"
title="Event Management"
description="Manage phenotyping and field operation events through /brapi/v2/events."
addLabel="New Event"
columns={[
{ key: "eventDbId", label: "Event ID" },
{ key: "event_type", label: "Event Type", render: eventTypeLabel },
{ key: "study_db_id", label: "Study" },
{
key: "start_date",
label: "Date Range",
render: (_value, row) => formatDateRange(row.start_date, row.end_date),
},
{ key: "event_description", label: "Description" },
{ key: "observation_unit_db_ids", label: "Observation Units" },
]}
fields={eventFields}
data={[]}
stats={[{ label: "/brapi/v2/events", value: "BrAPI", className: "bg-fuchsia-50 text-fuchsia-700 dark:bg-fuchsia-400/10 dark:text-fuchsia-200" }]}
loadData={loadEvents}
createRecord={(payload) => createEventRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateEventRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteEventRow}
/>
</TabsContent>
<TabsContent value="images" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
icon={Camera}
iconBg="bg-gradient-to-br from-purple-500 to-indigo-600"
title="Image Management"
description="Manage field image metadata and links through /brapi/v2/images."
addLabel="New Image"
columns={[
{ key: "imageDbId", label: "Image ID" },
{ key: "image_name", label: "Image Name" },
{ key: "study_db_id", label: "Study" },
{ key: "observation_unit_db_id", label: "Observation Unit" },
{ key: "mime_type", label: "MIME Type" },
{ key: "image_time_stamp", label: "Image Date" },
{ key: "image_url", label: "Image URL" },
]}
fields={imageFields}
data={[]}
stats={[{ label: "/brapi/v2/images", value: "BrAPI", className: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-200" }]}
loadData={loadImages}
renderFormExtra={(props) => <ImageFormUpload {...props} />}
createRecord={(payload) => createImageRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateImageRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteImageRow}
/>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,59 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface EventDateRange {
startDate?: string | null;
endDate?: string | null;
discreteDates?: string[] | null;
}
export interface EventRecord {
id: string;
eventDbId: string;
eventType: string | null;
eventTypeDbId: string | null;
eventDescription: string | null;
studyDbId: string | null;
studyName: string | null;
observationUnitDbIds: string[];
eventDateRange: EventDateRange | null;
event_type: string | null;
event_type_db_id: string | null;
event_description: string | null;
study_db_id: string | null;
observation_unit_db_ids: string | null;
start_date: string | null;
end_date: string | null;
}
export interface ImageRecord {
id: string;
imageDbId: string;
imageName: string | null;
imageFileName: string | null;
imageURL: string | null;
mimeType: string | null;
description: string | null;
studyDbId: string | null;
observationUnitDbId: string | null;
observationDbIds: string[];
imageTimeStamp: string | null;
imageFileSize: number | null;
imageWidth: number | null;
imageHeight: number | null;
image_name: string | null;
image_file_name: string | null;
image_url: string | null;
mime_type: string | null;
study_db_id: string | null;
observation_unit_db_id: string | null;
observation_db_ids: string | null;
image_time_stamp: string | null;
image_file_size: number | null;
image_width: number | null;
image_height: number | null;
}

View File

@@ -0,0 +1,157 @@
import { getAuthToken } from "@/utils/token";
import { loadGermplasmOptions, loadStudyOptions } from "@/services/dropdownCache";
import { NONE_SELECT_VALUE, type ObservationUnitRecord, 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 StudyResponse {
studyDbId: string;
studyName: string | null;
}
interface GermplasmResponse {
germplasmDbId: string;
germplasmName: string | null;
}
type ObservationUnitPayload = Partial<Record<
| "id"
| "observation_unit_name"
| "study_id"
| "germplasm_id"
| "level"
| "level_order"
| "row"
| "col",
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 requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const optionalNumber = (value: unknown) => {
const text = optionalText(value);
if (!text) return null;
const number = Number(text);
if (Number.isNaN(number)) return null;
return number;
};
const mapObservationUnit = (unit: ObservationUnitRecord): ObservationUnitRecord => {
const levelName = unit.observationLevel?.levelName ?? null;
const levelOrder = unit.observationLevel?.levelOrder ?? null;
return {
...unit,
id: unit.observationUnitDbId || unit.id,
observation_unit_name: unit.observation_unit_name || unit.observationUnitName,
study_id: unit.study_id || unit.studyDbId,
germplasm_id: unit.germplasm_id || unit.germplasmDbId,
level: unit.level || levelName,
level_order: unit.level_order ?? levelOrder,
row: unit.row || unit.positionCoordinateY,
col: unit.col || unit.positionCoordinateX,
};
};
const toRequestBody = (payload: ObservationUnitPayload) => ({
observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名称"),
studyDbId: optionalText(payload.study_id),
germplasmDbId: optionalText(payload.germplasm_id),
observationLevel: {
levelName: requiredText(payload.level, "请选择层级"),
levelOrder: optionalNumber(payload.level_order) ?? 0,
},
positionCoordinateX: optionalText(payload.col),
positionCoordinateY: optionalText(payload.row),
});
export async function fetchObservationUnitRows(): Promise<ObservationUnitRecord[]> {
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits?page=0&pageSize=1000");
return response.result.data.map(mapObservationUnit);
}
export async function fetchObservationUnitOptions(force = false): Promise<{
studies: SelectOption[];
germplasms: SelectOption[];
}> {
const [studies, germplasms] = await Promise.all([
loadStudyOptions(force),
loadGermplasmOptions(force),
]);
return { studies, germplasms };
}
export async function createObservationUnitRow(payload: ObservationUnitPayload): Promise<ObservationUnitRecord> {
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", {
method: "POST",
body: JSON.stringify({
observationUnitDbId: requiredText(payload.id, "请填写观测单元 ID"),
...toRequestBody(payload),
}),
});
return mapObservationUnit(response.result.data[0]);
}
export async function updateObservationUnitRow(id: string, payload: ObservationUnitPayload): Promise<ObservationUnitRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("观测单元 ID 不支持直接修改,请删除后重新新增");
}
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", {
method: "PUT",
body: JSON.stringify({
observationUnitDbId: id,
...toRequestBody(payload),
}),
});
return mapObservationUnit(response.result.data[0]);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Database } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createObservationUnitRow,
fetchObservationUnitOptions,
fetchObservationUnitRows,
updateObservationUnitRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const levelOptions: SelectOption[] = [
{ value: "field", label: "field 田块" },
{ value: "block", label: "block 区组" },
{ value: "plot", label: "plot 小区" },
{ value: "plant", label: "plant 单株" },
{ value: "sample", label: "sample 样品" },
];
export default function ObservationUnitPage() {
const [studyOptions, setStudyOptions] = useState<SelectOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchObservationUnitOptions(), fetchObservationUnitRows()]);
setStudyOptions(options.studies);
setGermplasmOptions(options.germplasms);
return rows as unknown as Record<string, unknown>[];
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "观测单元 ID", type: "text", required: true, placeholder: "如 ou-plot-001" },
{ key: "observation_unit_name", label: "单元名称", type: "text", required: true, placeholder: "如 Plot 001" },
{
key: "study_id",
label: "Study",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联 Study" }, ...studyOptions],
},
{
key: "germplasm_id",
label: "材料",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联材料" }, ...germplasmOptions],
},
{ key: "level", label: "层级", type: "select", required: true, options: levelOptions },
{ key: "level_order", label: "层级顺序", type: "number", placeholder: "如 plot=1, plant=2" },
{ key: "row", label: "行坐标", type: "text", placeholder: "如 1" },
{ key: "col", label: "列坐标", type: "text", placeholder: "如 1" },
], [germplasmOptions, studyOptions]);
return (
<BrapiEntityPage
icon={Database}
iconBg="bg-gradient-to-br from-violet-500 to-purple-600"
title="ObservationUnit 观测单元"
description="管理田间观测对象,关联 Study、材料、层级和行列坐标"
addLabel="新增观测单元"
columns={[
{ key: "observationUnitDbId", label: "单元 ID" },
{ key: "observation_unit_name", label: "单元名称" },
{ key: "study_id", label: "Study" },
{ key: "germplasm_id", label: "材料" },
{ key: "level", label: "层级" },
{ key: "row", label: "行" },
{ key: "col", label: "列" },
]}
fields={fields}
data={[]}
stats={[{ label: "/brapi/v2/observationunits", value: "BrAPI", className: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-200" }]}
loadData={loadRows}
createRecord={(payload) => createObservationUnitRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateObservationUnitRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/>
);
}

View File

@@ -0,0 +1,29 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface ObservationLevel {
levelName: string | null;
levelOrder: number | null;
}
export interface ObservationUnitRecord {
id: string;
observationUnitDbId: string;
observationUnitName: string | null;
observation_unit_name: string | null;
studyDbId: string | null;
study_id: string | null;
germplasmDbId: string | null;
germplasm_id: string | null;
observationLevel: ObservationLevel | null;
level: string | null;
level_order: number | null;
positionCoordinateX: string | null;
positionCoordinateY: string | null;
row: string | null;
col: string | null;
}

View File

@@ -0,0 +1,228 @@
import { getAuthToken } from "@/utils/token";
import { NONE_SELECT_VALUE, type ObservationVariableRecord, 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;
}
interface CropResponse {
id: string;
crop_name: string | null;
}
interface OntologyResponse {
ontologyDbId: string;
ontologyName: string | null;
ontology_name: string | null;
version: string | null;
}
interface TraitResponse {
traitDbId: string;
traitName: string | null;
trait_name: string | null;
}
interface MethodResponse {
methodDbId: string;
methodName: string | null;
name: string | null;
}
interface ScaleResponse {
scaleDbId: string;
scaleName: string | null;
scale_name: string | null;
units: string | null;
}
type ObservationVariablePayload = Partial<Record<
| "id"
| "name"
| "pui"
| "default_value"
| "documentationurl"
| "growth_stage"
| "institution"
| "language"
| "scientist"
| "status"
| "submission_timestamp"
| "crop_id"
| "ontology_id"
| "trait_id"
| "method_id"
| "scale_id",
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 requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const mapObservationVariable = (variable: ObservationVariableRecord): ObservationVariableRecord => ({
...variable,
id: variable.observationVariableDbId || variable.id,
name: variable.name || variable.observation_variable_name || variable.observationVariableName,
observation_variable_name: variable.observation_variable_name || variable.observationVariableName || variable.name,
pui: variable.pui || variable.observationVariablePUI,
default_value: variable.default_value || variable.defaultValue,
documentationurl: variable.documentationurl || variable.documentationURL,
growth_stage: variable.growth_stage || variable.growthStage,
submission_timestamp: variable.submission_timestamp || variable.submissionTimestamp,
crop_id: variable.crop_id || variable.cropDbId,
ontology_id: variable.ontology_id || variable.ontologyDbId,
ontology_name: variable.ontology_name || variable.ontologyName,
trait_id: variable.trait_id || variable.traitDbId,
trait_name: variable.trait_name || variable.traitName,
method_id: variable.method_id || variable.methodDbId,
method_name: variable.method_name || variable.methodName,
scale_id: variable.scale_id || variable.scaleDbId,
scale_name: variable.scale_name || variable.scaleName,
});
const toRequestBody = (payload: ObservationVariablePayload) => ({
observationVariableName: requiredText(payload.name, "请填写变量名称"),
pui: optionalText(payload.pui),
defaultValue: optionalText(payload.default_value),
documentationurl: optionalText(payload.documentationurl),
growthStage: optionalText(payload.growth_stage),
institution: optionalText(payload.institution),
language: optionalText(payload.language),
scientist: optionalText(payload.scientist),
status: optionalText(payload.status),
submissionTimestamp: optionalText(payload.submission_timestamp),
crop_id: optionalText(payload.crop_id),
ontology_id: optionalText(payload.ontology_id),
trait_id: requiredText(payload.trait_id, "请选择 Trait"),
method_id: requiredText(payload.method_id, "请选择 Method"),
scale_id: requiredText(payload.scale_id, "请选择 Scale"),
});
export async function fetchObservationVariableRows(): Promise<ObservationVariableRecord[]> {
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables?page=0&pageSize=1000");
return response.result.data.map(mapObservationVariable);
}
export async function fetchObservationVariableOptions(): Promise<{
crops: SelectOption[];
ontologies: SelectOption[];
traits: SelectOption[];
methods: SelectOption[];
scales: SelectOption[];
}> {
const [crops, ontologies, traits, methods, scales] = await Promise.all([
request<CropResponse[]>("/api/dictionaries/crops"),
request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=1000"),
request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"),
request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"),
request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"),
]);
return {
crops: crops.map((crop) => ({
value: crop.id,
label: crop.crop_name || crop.id,
})),
ontologies: ontologies.result.data.map((ontology) => ({
value: ontology.ontologyDbId,
label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`,
})),
traits: traits.result.data.map((trait) => ({
value: trait.traitDbId,
label: trait.traitName || trait.trait_name || trait.traitDbId,
})),
methods: methods.result.data.map((method) => ({
value: method.methodDbId,
label: method.methodName || method.name || method.methodDbId,
})),
scales: scales.result.data.map((scale) => ({
value: scale.scaleDbId,
label: `${scale.scaleName || scale.scale_name || scale.scaleDbId}${scale.units ? ` / ${scale.units}` : ""}`,
})),
};
}
export async function createObservationVariableRow(payload: ObservationVariablePayload): Promise<ObservationVariableRecord> {
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables", {
method: "POST",
body: JSON.stringify({
id: requiredText(payload.id, "请填写变量 ID"),
...toRequestBody(payload),
}),
});
return mapObservationVariable(response.result.data[0]);
}
export async function updateObservationVariableRow(id: string, payload: ObservationVariablePayload): Promise<ObservationVariableRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("变量 ID 不支持直接修改,请删除后重新新增");
}
const response = await request<BrapiSingleResponse<ObservationVariableRecord>>(`/brapi/v2/variables/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
});
return mapObservationVariable(response.result);
}
export async function deleteObservationVariableRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ObservationVariableRecord>>(`/brapi/v2/variables/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { ClipboardList } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import {
createObservationVariableRow,
deleteObservationVariableRow,
fetchObservationVariableOptions,
fetchObservationVariableRows,
updateObservationVariableRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const statusOptions: SelectOption[] = [
{ value: NONE_SELECT_VALUE, label: "不指定状态" },
{ value: "recommended", label: "recommended 推荐" },
{ value: "active", label: "active 启用" },
{ value: "draft", label: "draft 草稿" },
{ value: "obsolete", label: "obsolete 废弃" },
{ value: "legacy", label: "legacy 历史" },
];
export default function ObservationVariablePage() {
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
const [ontologyOptions, setOntologyOptions] = useState<SelectOption[]>([]);
const [traitOptions, setTraitOptions] = useState<SelectOption[]>([]);
const [methodOptions, setMethodOptions] = useState<SelectOption[]>([]);
const [scaleOptions, setScaleOptions] = useState<SelectOption[]>([]);
const loadRows = useCallback(async () => {
const [options, rows] = await Promise.all([fetchObservationVariableOptions(), fetchObservationVariableRows()]);
setCropOptions(options.crops);
setOntologyOptions(options.ontologies);
setTraitOptions(options.traits);
setMethodOptions(options.methods);
setScaleOptions(options.scales);
return rows as unknown as Record<string, unknown>[];
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "变量 ID", type: "text", required: true, placeholder: "如 PH_cm 或 CO_322:0000994" },
{ key: "name", label: "变量名称", type: "text", required: true, placeholder: "如 Plant Height by Ruler in cm" },
{ key: "trait_id", label: "Trait 性状", type: "select", required: true, options: traitOptions },
{ key: "method_id", label: "Method 方法", type: "select", required: true, options: methodOptions },
{ key: "scale_id", label: "Scale 尺度", type: "select", required: true, options: scaleOptions },
{
key: "crop_id",
label: "作物",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联作物" }, ...cropOptions],
},
{
key: "ontology_id",
label: "本体",
type: "select",
options: [{ value: NONE_SELECT_VALUE, label: "不关联本体" }, ...ontologyOptions],
},
{ key: "status", label: "状态", type: "select", options: statusOptions },
{ key: "default_value", label: "默认值", type: "text", placeholder: "采集表默认填充值" },
{ key: "growth_stage", label: "采集生育期", type: "text", placeholder: "如 flowering / V6 / R1" },
{ key: "scientist", label: "提交人", type: "text", placeholder: "维护人或科学家" },
{ key: "institution", label: "机构", type: "text", placeholder: "提交机构" },
{ key: "language", label: "语言", type: "text", placeholder: "如 zh / en" },
{ key: "submission_timestamp", label: "提交日期", type: "date" },
{ key: "pui", label: "变量 PUI", type: "text", placeholder: "永久唯一标识或 URI", colSpan: 2 },
{ key: "documentationurl", label: "文档地址", type: "text", placeholder: "https://...", colSpan: 2 },
], [cropOptions, methodOptions, ontologyOptions, scaleOptions, traitOptions]);
return (
<BrapiEntityPage
icon={ClipboardList}
iconBg="bg-gradient-to-br from-purple-500 to-indigo-600"
title="ObservationVariable 采集变量"
description="配置 Trait + Method + Scale 的采集变量,供田间采集模板和观测值引用"
addLabel="新增变量"
columns={[
{ key: "id", label: "变量 ID" },
{ key: "name", label: "变量名称" },
{ key: "trait_name", label: "Trait" },
{ key: "method_name", label: "Method" },
{
key: "scale_name",
label: "Scale",
render: (value, row) => `${value || "—"}${row.units ? ` / ${String(row.units)}` : ""}`,
},
{ key: "commonCropName", label: "作物" },
{ key: "status", label: "状态" },
{ key: "growth_stage", label: "采集阶段" },
]}
fields={fields}
data={[]}
stats={[{ label: "/brapi/v2/variables", value: "BrAPI", className: "bg-purple-50 text-purple-700 dark:bg-purple-400/10 dark:text-purple-200" }]}
loadData={loadRows}
createRecord={(payload) => createObservationVariableRow(payload) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => updateObservationVariableRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteObservationVariableRow}
/>
);
}

View File

@@ -0,0 +1,48 @@
export const NONE_SELECT_VALUE = "__none__";
export interface SelectOption {
value: string;
label: string;
}
export interface ObservationVariableRecord {
id: string;
observationVariableDbId: string;
observationVariableName: string | null;
observation_variable_name: string | null;
name: string | null;
observationVariablePUI: string | null;
pui: string | null;
defaultValue: string | null;
default_value: string | null;
documentationURL: string | null;
documentationurl: string | null;
growthStage: string | null;
growth_stage: string | null;
institution: string | null;
language: string | null;
scientist: string | null;
status: string | null;
submissionTimestamp: string | null;
submission_timestamp: string | null;
commonCropName: string | null;
cropDbId: string | null;
crop_id: string | null;
ontologyDbId: string | null;
ontology_id: string | null;
ontologyName: string | null;
ontology_name: string | null;
traitDbId: string | null;
trait_id: string | null;
traitName: string | null;
trait_name: string | null;
methodDbId: string | null;
method_id: string | null;
methodName: string | null;
method_name: string | null;
scaleDbId: string | null;
scale_id: string | null;
scaleName: string | null;
scale_name: string | null;
units: string | null;
}

View File

@@ -0,0 +1,8 @@
"use client";
import { BrapiEntityPage } from "@/components/brapi/BrapiEntityPage";
import { brapiEntityConfigs } from "@/components/brapi/entityConfigs";
export default function Page() {
return <BrapiEntityPage {...brapiEntityConfigs.observation} />;
}

View File

@@ -0,0 +1,148 @@
import { getAuthToken } from "@/utils/token";
import { loadPersonOptions } from "@/services/dropdownCache";
import { NONE_SELECT_VALUE, type ProgramRecord, 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;
}
interface CropResponse {
id: string;
crop_name: string | null;
}
type ProgramPayload = Partial<Record<
"id" | "name" | "abbreviation" | "objective" | "documentationurl" | "funding_information" | "program_type" | "crop_id" | "lead_person_id",
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 programName = (payload: ProgramPayload) => {
const name = optionalText(payload.name);
if (!name) throw new Error("请填写项目名称");
return name;
};
const mapProgram = (program: ProgramRecord): ProgramRecord => ({
...program,
id: program.programDbId,
name: program.name || program.programName,
crop_id: program.crop_id || program.cropDbId,
crop_name: program.crop_name || program.cropName,
lead_person_id: program.lead_person_id || program.leadPersonDbId,
lead_person_name: program.lead_person_name || program.leadPersonName,
funding_information: program.funding_information || program.fundingInformation,
documentationurl: program.documentationurl || program.documentationURL,
program_type: program.program_type ?? program.programType,
});
const toRequestBody = (payload: ProgramPayload) => ({
name: programName(payload),
abbreviation: optionalText(payload.abbreviation),
objective: optionalText(payload.objective),
documentationurl: optionalText(payload.documentationurl),
funding_information: optionalText(payload.funding_information),
program_type: optionalNumber(payload.program_type),
crop_id: optionalText(payload.crop_id),
lead_person_id: optionalText(payload.lead_person_id),
});
export async function fetchProgramRows(): Promise<ProgramRecord[]> {
const response = await request<BrapiListResponse<ProgramRecord>>("/brapi/v2/programs?page=0&pageSize=1000");
return response.result.data.map(mapProgram);
}
export async function fetchProgramOptions(force = false): Promise<{ crops: SelectOption[]; people: SelectOption[] }> {
const [crops, people] = await Promise.all([
request<CropResponse[]>("/api/dictionaries/crops"),
loadPersonOptions(force),
]);
return {
crops: crops.map((crop) => ({
value: crop.id,
label: crop.crop_name || crop.id,
})),
people,
};
}
export async function createProgramRow(payload: ProgramPayload): Promise<ProgramRecord> {
const response = await request<BrapiListResponse<ProgramRecord>>("/brapi/v2/programs", {
method: "POST",
body: JSON.stringify([toRequestBody(payload)]),
});
return mapProgram(response.result.data[0]);
}
export async function updateProgramRow(id: string, payload: ProgramPayload): Promise<ProgramRecord> {
const response = await request<BrapiSingleResponse<ProgramRecord>>(`/brapi/v2/programs/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
});
return mapProgram(response.result);
}
export async function deleteProgramRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ProgramRecord>>(`/brapi/v2/programs/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

Some files were not shown because too many files have changed in this diff Show More