fix:sample/plate 之前的开发
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { PageHeader } from "@/components/common/PageHeader";
|
||||
|
||||
export default function CatalogPage() {
|
||||
return <PageHeader title="资产目录" description="三级菜单页面占位,一级与二级菜单目前为示例占位结构。" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageHeader } from "@/components/common/PageHeader";
|
||||
|
||||
export default function SupplierPage() {
|
||||
return <PageHeader title="供应商档案" description="三级菜单页面占位,一级与二级菜单目前为示例占位结构。" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">同一作物后续会关联 Program、Trial、Study。</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>
|
||||
);
|
||||
}
|
||||
@@ -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> 表。这里录入的作物会成为 Program、Trial、Study、Germplasm 等数据的基础入口。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
43
frontend/src/app/(app)/basic-dictionary/base/crop/page.tsx
Normal file
43
frontend/src/app/(app)/basic-dictionary/base/crop/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
205
frontend/src/app/(app)/basic-dictionary/base/list/api.ts
Normal file
205
frontend/src/app/(app)/basic-dictionary/base/list/api.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
}
|
||||
85
frontend/src/app/(app)/basic-dictionary/base/list/page.tsx
Normal file
85
frontend/src/app/(app)/basic-dictionary/base/list/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/(app)/basic-dictionary/base/list/types.ts
Normal file
40
frontend/src/app/(app)/basic-dictionary/base/list/types.ts
Normal 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 || "—");
|
||||
}
|
||||
111
frontend/src/app/(app)/basic-dictionary/base/location/api.ts
Normal file
111
frontend/src/app/(app)/basic-dictionary/base/location/api.ts
Normal 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" },
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
68
frontend/src/app/(app)/basic-dictionary/base/person/page.tsx
Normal file
68
frontend/src/app/(app)/basic-dictionary/base/person/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/(app)/basic-dictionary/base/season/page.tsx
Normal file
49
frontend/src/app/(app)/basic-dictionary/base/season/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
frontend/src/app/(app)/basic-dictionary/ontology/api.ts
Normal file
111
frontend/src/app/(app)/basic-dictionary/ontology/api.ts
Normal 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" },
|
||||
);
|
||||
}
|
||||
70
frontend/src/app/(app)/basic-dictionary/ontology/page.tsx
Normal file
70
frontend/src/app/(app)/basic-dictionary/ontology/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageHeader } from "@/components/common/PageHeader";
|
||||
|
||||
export default function EmployeePage() {
|
||||
return <PageHeader title="员工管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
|
||||
}
|
||||
5
frontend/src/app/(app)/central-config/user/menu/page.tsx
Normal file
5
frontend/src/app/(app)/central-config/user/menu/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PageHeader } from "@/components/common/PageHeader";
|
||||
|
||||
export default function MenuPage() {
|
||||
return <PageHeader title="菜单管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
|
||||
}
|
||||
5
frontend/src/app/(app)/central-config/user/role/page.tsx
Normal file
5
frontend/src/app/(app)/central-config/user/role/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PageHeader } from "@/components/common/PageHeader";
|
||||
|
||||
export default function RolePage() {
|
||||
return <PageHeader title="角色管理" description="三级菜单页面占位,后续可直接接入你的业务组件。" />;
|
||||
}
|
||||
5
frontend/src/app/(app)/dashboard/page.tsx
Normal file
5
frontend/src/app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { BrapiDashboard } from "@/components/brapi/BrapiDashboard";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <BrapiDashboard />;
|
||||
}
|
||||
@@ -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="date",提交格式为 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>
|
||||
);
|
||||
}
|
||||
255
frontend/src/app/(app)/frontend-components/general-form/page.tsx
Normal file
255
frontend/src/app/(app)/frontend-components/general-form/page.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
180
frontend/src/app/(app)/frontend-components/page-header/page.tsx
Normal file
180
frontend/src/app/(app)/frontend-components/page-header/page.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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_base;target = 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 g,10000 粒 ={" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
401
frontend/src/app/(app)/genotyping/reference-set/api.ts
Normal file
401
frontend/src/app/(app)/genotyping/reference-set/api.ts
Normal 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();
|
||||
}
|
||||
281
frontend/src/app/(app)/genotyping/reference-set/page.tsx
Normal file
281
frontend/src/app/(app)/genotyping/reference-set/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/src/app/(app)/genotyping/reference-set/types.ts
Normal file
58
frontend/src/app/(app)/genotyping/reference-set/types.ts
Normal 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;
|
||||
}
|
||||
689
frontend/src/app/(app)/genotyping/sample-plate/api.ts
Normal file
689
frontend/src/app/(app)/genotyping/sample-plate/api.ts
Normal 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,请在后端扩展删除能力后再启用");
|
||||
}
|
||||
@@ -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 孔板后,下属样本的行/列/孔位将在录入时校验(A–H,列 1–12)。
|
||||
</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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 层级会交叉校验。
|
||||
上板样本的行/列/孔位按板规格校验,同一板内孔位不可重复;仅填行+列时保存时自动生成孔位。
|
||||
删除前会检查是否已有 CallSet(BrAPI 暂不支持 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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: "A–H(96 孔板)" },
|
||||
{ key: "column", label: "板列", type: "number", placeholder: "1–12" },
|
||||
{ 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,
|
||||
};
|
||||
};
|
||||
}
|
||||
45
frontend/src/app/(app)/genotyping/sample-plate/page.tsx
Normal file
45
frontend/src/app/(app)/genotyping/sample-plate/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/app/(app)/genotyping/sample-plate/plateUtils.ts
Normal file
130
frontend/src/app/(app)/genotyping/sample-plate/plateUtils.ts
Normal 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 孔板行号需在 A–H,当前为 ${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 孔板规格 (A01–H12)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
157
frontend/src/app/(app)/genotyping/sample-plate/sampleUtils.ts
Normal file
157
frontend/src/app/(app)/genotyping/sample-plate/sampleUtils.ts
Normal 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;
|
||||
}
|
||||
90
frontend/src/app/(app)/genotyping/sample-plate/types.ts
Normal file
90
frontend/src/app/(app)/genotyping/sample-plate/types.ts
Normal 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;
|
||||
}
|
||||
286
frontend/src/app/(app)/genotyping/variant/api.ts
Normal file
286
frontend/src/app/(app)/genotyping/variant/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
190
frontend/src/app/(app)/genotyping/variant/page.tsx
Normal file
190
frontend/src/app/(app)/genotyping/variant/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/app/(app)/genotyping/variant/types.ts
Normal file
79
frontend/src/app/(app)/genotyping/variant/types.ts
Normal 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;
|
||||
}
|
||||
127
frontend/src/app/(app)/germplasm/breeding-method/api.ts
Normal file
127
frontend/src/app/(app)/germplasm/breeding-method/api.ts
Normal 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);
|
||||
}
|
||||
73
frontend/src/app/(app)/germplasm/breeding-method/page.tsx
Normal file
73
frontend/src/app/(app)/germplasm/breeding-method/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface BreedingMethodRecord {
|
||||
id: string;
|
||||
breedingMethodDbId: string;
|
||||
breedingMethodName: string | null;
|
||||
name: string | null;
|
||||
abbreviation: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
getCrossPedigreeSnapshot,
|
||||
invalidateCrossPedigreeCache,
|
||||
loadCrossPedigreeSnapshot,
|
||||
type CrossPedigreeSnapshot,
|
||||
} from "./crossPedigreeCache";
|
||||
|
||||
interface CrossPedigreeContextValue {
|
||||
snapshot: CrossPedigreeSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: (force?: boolean) => Promise<CrossPedigreeSnapshot>;
|
||||
invalidate: () => void;
|
||||
}
|
||||
|
||||
const CrossPedigreeContext = createContext<CrossPedigreeContextValue | null>(null);
|
||||
|
||||
export function CrossPedigreeProvider({ children }: { children: ReactNode }) {
|
||||
const [snapshot, setSnapshot] = useState<CrossPedigreeSnapshot | null>(() => getCrossPedigreeSnapshot());
|
||||
const [loading, setLoading] = useState(() => !getCrossPedigreeSnapshot());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async (force = false) => {
|
||||
const cached = getCrossPedigreeSnapshot();
|
||||
if (!force && cached) {
|
||||
setSnapshot(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await loadCrossPedigreeSnapshot(force);
|
||||
setSnapshot(next);
|
||||
return next;
|
||||
} catch (event) {
|
||||
const message = event instanceof Error ? event.message : "数据加载失败";
|
||||
setError(message);
|
||||
throw event;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
invalidateCrossPedigreeCache();
|
||||
setSnapshot(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (getCrossPedigreeSnapshot()) return;
|
||||
refresh(false).catch(() => {});
|
||||
}, [refresh]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ snapshot, loading, error, refresh, invalidate }),
|
||||
[snapshot, loading, error, refresh, invalidate],
|
||||
);
|
||||
|
||||
return (
|
||||
<CrossPedigreeContext.Provider value={value}>
|
||||
{children}
|
||||
</CrossPedigreeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCrossPedigree() {
|
||||
const context = useContext(CrossPedigreeContext);
|
||||
if (!context) {
|
||||
throw new Error("useCrossPedigree must be used within CrossPedigreeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
576
frontend/src/app/(app)/germplasm/cross-pedigree/api.ts
Normal file
576
frontend/src/app/(app)/germplasm/cross-pedigree/api.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import type { Cross, CrossParent, CrossingProject, PedigreeNode, PlannedCross } from "@/lib/api/types.gen";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
invalidateCrossPedigreeCache,
|
||||
loadCrossPedigreeSnapshot,
|
||||
} from "./crossPedigreeCache";
|
||||
import { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type CrossParentFormState,
|
||||
type CrossParentRow,
|
||||
type CrossRecord,
|
||||
type CrossingProjectRecord,
|
||||
type PedigreeEdgeFormState,
|
||||
type PedigreeEdgeRow,
|
||||
type PedigreeRecord,
|
||||
type PlannedCrossRecord,
|
||||
type SelectOption,
|
||||
} from "./types";
|
||||
|
||||
interface BrapiPagination {
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
totalCount: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface BrapiListResponse<T> {
|
||||
metadata: {
|
||||
pagination: BrapiPagination;
|
||||
status: Array<Record<string, unknown>>;
|
||||
datafiles: Array<Record<string, unknown>>;
|
||||
};
|
||||
result: {
|
||||
data: T[];
|
||||
};
|
||||
}
|
||||
|
||||
interface BrapiSingleResponse<T> {
|
||||
metadata: {
|
||||
pagination: BrapiPagination;
|
||||
status: Array<Record<string, unknown>>;
|
||||
datafiles: Array<Record<string, unknown>>;
|
||||
};
|
||||
result: T;
|
||||
}
|
||||
|
||||
type CrossingProjectPayload = Partial<Record<"name" | "description" | "program_id", unknown>>;
|
||||
type PlannedCrossPayload = Partial<Record<"name" | "cross_type" | "status" | "crossing_project_id", unknown>>;
|
||||
type CrossPayload = Partial<Record<"name" | "cross_type" | "crossing_project_id" | "planned_cross_id", unknown>>;
|
||||
type PedigreePayload = Partial<Record<
|
||||
"germplasm_id" | "crossing_project_id" | "crossing_year" | "family_code" | "pedigree_string",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
const apiBase = () => {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `请求失败:${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const optionalText = (value: unknown) => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const optionalNumber = (value: unknown) => {
|
||||
const normalized = optionalText(value);
|
||||
if (normalized === null) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const requiredText = (value: unknown, message: string) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) throw new Error(message);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const buildCrossParent = (
|
||||
parentType: unknown,
|
||||
germplasmId: unknown,
|
||||
observationUnitId: unknown,
|
||||
): CrossParent | null => {
|
||||
const parentTypeValue = optionalText(parentType);
|
||||
const germplasmDbId = optionalText(germplasmId);
|
||||
const observationUnitDbId = optionalText(observationUnitId);
|
||||
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
|
||||
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
|
||||
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm 或 observation_unit 至少一项");
|
||||
return {
|
||||
parentType: parentTypeValue as CrossParent["parentType"],
|
||||
...(germplasmDbId ? { germplasmDbId } : {}),
|
||||
...(observationUnitDbId ? { observationUnitDbId } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export { mapCross, mapCrossingProject, mapPlannedCross } from "./mappers";
|
||||
export { invalidateCrossPedigreeCache, loadCrossPedigreeSnapshot } from "./crossPedigreeCache";
|
||||
|
||||
const invalidateAfterMutation = () => {
|
||||
invalidateCrossPedigreeCache();
|
||||
};
|
||||
|
||||
export function normalizeCrossingProjectForm(record: CrossingProjectRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
description: record.description ?? "",
|
||||
program_id: record.program_id && record.program_id !== NONE_SELECT_VALUE ? record.program_id : NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePlannedCrossForm(record: PlannedCrossRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
|
||||
status: record.status && record.status !== NONE_SELECT_VALUE ? record.status : "TODO",
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCrossForm(record: CrossRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
name: record.name ?? "",
|
||||
cross_type: record.cross_type && record.cross_type !== NONE_SELECT_VALUE ? record.cross_type : NONE_SELECT_VALUE,
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
planned_cross_id:
|
||||
record.planned_cross_id && record.planned_cross_id !== NONE_SELECT_VALUE
|
||||
? record.planned_cross_id
|
||||
: NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCrossParentFormState(
|
||||
crossId: string,
|
||||
planned: boolean,
|
||||
crossingProjectId: string | null,
|
||||
crossingProjectName: string | null,
|
||||
parent1: CrossParent | null | undefined,
|
||||
parent2: CrossParent | null | undefined,
|
||||
): CrossParentFormState {
|
||||
return {
|
||||
cross_id: crossId,
|
||||
planned,
|
||||
crossing_project_id: crossingProjectId,
|
||||
crossing_project_name: crossingProjectName,
|
||||
parent1_type: parent1?.parentType ?? NONE_SELECT_VALUE,
|
||||
parent1_germplasm_id: parent1?.germplasmDbId ?? NONE_SELECT_VALUE,
|
||||
parent1_observation_unit_id: parent1?.observationUnitDbId ?? NONE_SELECT_VALUE,
|
||||
parent2_type: parent2?.parentType ?? NONE_SELECT_VALUE,
|
||||
parent2_germplasm_id: parent2?.germplasmDbId ?? NONE_SELECT_VALUE,
|
||||
parent2_observation_unit_id: parent2?.observationUnitDbId ?? NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
const crossingProjectBody = (payload: CrossingProjectPayload) => {
|
||||
const programDbId = requiredText(payload.program_id, "请选择所属 Program");
|
||||
return {
|
||||
crossingProjectName: requiredText(payload.name, "请填写杂交项目名称"),
|
||||
crossingProjectDescription: optionalText(payload.description),
|
||||
programDbId,
|
||||
};
|
||||
};
|
||||
|
||||
const plannedCrossBody = (payload: PlannedCrossPayload) => ({
|
||||
plannedCrossName: requiredText(payload.name, "请填写计划杂交名称"),
|
||||
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
|
||||
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
|
||||
...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }),
|
||||
});
|
||||
|
||||
const crossBody = (payload: CrossPayload) => ({
|
||||
crossName: requiredText(payload.name, "请填写实际杂交名称"),
|
||||
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
|
||||
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
|
||||
...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}),
|
||||
});
|
||||
|
||||
const mapPedigree = (node: PedigreeRecord & PedigreeNode): PedigreeRecord => {
|
||||
const germplasmId = node.germplasmDbId || node.germplasm_id || null;
|
||||
return {
|
||||
...node,
|
||||
id: germplasmId || node.pedigreeNodeDbId || node.id,
|
||||
germplasmDbId: germplasmId,
|
||||
germplasm_id: germplasmId,
|
||||
germplasm_name: node.germplasm_name || node.germplasmName || null,
|
||||
crossing_project_id: node.crossing_project_id || node.crossingProjectDbId || null,
|
||||
crossing_project_name: node.crossing_project_name || node.crossingProjectName || null,
|
||||
crossing_year: node.crossing_year ?? node.crossingYear ?? null,
|
||||
family_code: node.family_code || node.familyCode || null,
|
||||
pedigree_string: node.pedigree_string || node.pedigreeString || null,
|
||||
parents: node.parents?.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
germplasmName: parent.germplasmName,
|
||||
parentType: parent.parentType,
|
||||
})),
|
||||
siblings: node.siblings?.map((sibling) => ({
|
||||
germplasmDbId: sibling.germplasmDbId,
|
||||
germplasmName: sibling.germplasmName,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const pedigreeBody = (payload: PedigreePayload, requireGermplasm = false) => ({
|
||||
germplasmDbId: requireGermplasm
|
||||
? requiredText(payload.germplasm_id, "请选择 Germplasm")
|
||||
: optionalText(payload.germplasm_id),
|
||||
crossingProjectDbId: optionalText(payload.crossing_project_id),
|
||||
crossingYear: optionalNumber(payload.crossing_year),
|
||||
familyCode: optionalText(payload.family_code),
|
||||
pedigreeString: optionalText(payload.pedigree_string),
|
||||
});
|
||||
|
||||
export function normalizePedigreeForm(record: PedigreeRecord): Record<string, unknown> {
|
||||
return {
|
||||
...record,
|
||||
germplasm_id:
|
||||
record.germplasm_id && record.germplasm_id !== NONE_SELECT_VALUE ? record.germplasm_id : NONE_SELECT_VALUE,
|
||||
crossing_project_id:
|
||||
record.crossing_project_id && record.crossing_project_id !== NONE_SELECT_VALUE
|
||||
? record.crossing_project_id
|
||||
: NONE_SELECT_VALUE,
|
||||
crossing_year: record.crossing_year ?? "",
|
||||
family_code: record.family_code ?? "",
|
||||
pedigree_string: record.pedigree_string ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function flattenPedigreeEdges(nodes: PedigreeRecord[]): PedigreeEdgeRow[] {
|
||||
const rows: PedigreeEdgeRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const thisNodeId = node.germplasm_id;
|
||||
if (!thisNodeId) continue;
|
||||
|
||||
for (const parent of node.parents ?? []) {
|
||||
const connectedNodeId = parent.germplasmDbId;
|
||||
if (!connectedNodeId) continue;
|
||||
const key = `parent:${thisNodeId}:${connectedNodeId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push({
|
||||
id: key,
|
||||
edge_type: "parent",
|
||||
parent_type: parent.parentType ?? null,
|
||||
this_node_id: thisNodeId,
|
||||
this_node_name: node.germplasm_name,
|
||||
connected_node_id: connectedNodeId,
|
||||
connected_node_name: parent.germplasmName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const sibling of node.siblings ?? []) {
|
||||
const connectedNodeId = sibling.germplasmDbId;
|
||||
if (!connectedNodeId) continue;
|
||||
const [left, right] = [thisNodeId, connectedNodeId].sort();
|
||||
const key = `sibling:${left}:${right}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
rows.push({
|
||||
id: key,
|
||||
edge_type: "sibling",
|
||||
parent_type: null,
|
||||
this_node_id: thisNodeId,
|
||||
this_node_name: node.germplasm_name,
|
||||
connected_node_id: connectedNodeId,
|
||||
connected_node_name: sibling.germplasmName ?? null,
|
||||
read_only: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const buildParentPayload = (
|
||||
germplasmDbId: string,
|
||||
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
|
||||
) => ({
|
||||
germplasmDbId,
|
||||
parents: parents
|
||||
.filter((parent) => parent.germplasmDbId)
|
||||
.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
})),
|
||||
});
|
||||
|
||||
async function updatePedigreeParents(
|
||||
germplasmDbId: string,
|
||||
parents: Array<{ germplasmDbId?: string; parentType?: string }>,
|
||||
): Promise<void> {
|
||||
await request<BrapiListResponse<PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
[germplasmDbId]: buildParentPayload(germplasmDbId, parents),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchCrossPedigreeOptions(): Promise<{
|
||||
programs: SelectOption[];
|
||||
germplasm: SelectOption[];
|
||||
crossingProjects: SelectOption[];
|
||||
plannedCrosses: SelectOption[];
|
||||
observationUnits: SelectOption[];
|
||||
crosses: SelectOption[];
|
||||
}> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return {
|
||||
programs: snapshot.programs,
|
||||
germplasm: snapshot.germplasm,
|
||||
crossingProjects: snapshot.crossingProjectOptions,
|
||||
plannedCrosses: snapshot.plannedCrossOptions,
|
||||
observationUnits: snapshot.observationUnits,
|
||||
crosses: snapshot.crossOptions,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCrossingProjectRows(): Promise<CrossingProjectRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.crossingProjects;
|
||||
}
|
||||
|
||||
export async function fetchCrossingProjectDetail(id: string): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiSingleResponse<CrossingProject>>(
|
||||
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
|
||||
);
|
||||
return mapCrossingProject(response.result);
|
||||
}
|
||||
|
||||
export async function createCrossingProjectRow(payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([crossingProjectBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCrossingProject(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateCrossingProjectRow(id: string, payload: CrossingProjectPayload): Promise<CrossingProjectRecord> {
|
||||
const response = await request<BrapiSingleResponse<CrossingProject>>(
|
||||
`/brapi/v2/crossingprojects/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(crossingProjectBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateAfterMutation();
|
||||
return mapCrossingProject(response.result);
|
||||
}
|
||||
|
||||
export async function fetchPlannedCrossRows(): Promise<PlannedCrossRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.plannedCrosses;
|
||||
}
|
||||
|
||||
export async function createPlannedCrossRow(payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
|
||||
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([plannedCrossBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapPlannedCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updatePlannedCrossRow(id: string, payload: PlannedCrossPayload): Promise<PlannedCrossRecord> {
|
||||
const response = await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [id]: plannedCrossBody(payload) }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapPlannedCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchCrossRows(): Promise<CrossRecord[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.actualCrosses;
|
||||
}
|
||||
|
||||
export async function createCrossRow(payload: CrossPayload): Promise<CrossRecord> {
|
||||
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([crossBody(payload)]),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateCrossRow(id: string, payload: CrossPayload): Promise<CrossRecord> {
|
||||
const response = await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [id]: crossBody(payload) }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return mapCross(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
|
||||
const snapshot = await loadCrossPedigreeSnapshot();
|
||||
return snapshot.parentRows;
|
||||
}
|
||||
|
||||
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
|
||||
const crossId = requiredText(payload.cross_id, "请选择所属 Cross");
|
||||
const parent1 = buildCrossParent(
|
||||
payload.parent1_type,
|
||||
payload.parent1_germplasm_id,
|
||||
payload.parent1_observation_unit_id,
|
||||
);
|
||||
const parent2 = buildCrossParent(
|
||||
payload.parent2_type,
|
||||
payload.parent2_germplasm_id,
|
||||
payload.parent2_observation_unit_id,
|
||||
);
|
||||
|
||||
const body = {
|
||||
crossingProjectDbId: payload.crossing_project_id ?? undefined,
|
||||
...(parent1 ? { parent1 } : {}),
|
||||
...(parent2 ? { parent2 } : {}),
|
||||
};
|
||||
|
||||
if (payload.planned) {
|
||||
await request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [crossId]: body }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
return;
|
||||
}
|
||||
|
||||
await request<BrapiListResponse<Cross>>("/brapi/v2/crosses", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ [crossId]: body }),
|
||||
});
|
||||
invalidateAfterMutation();
|
||||
}
|
||||
|
||||
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
|
||||
"/brapi/v2/pedigree?page=0&pageSize=1000",
|
||||
);
|
||||
return response.result.data.map(mapPedigree);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
|
||||
"/brapi/v2/pedigree?page=0&pageSize=1000&includeParents=true&includeProgeny=false&includeSiblings=true",
|
||||
);
|
||||
return response.result.data.map(mapPedigree);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
|
||||
const rows = await fetchPedigreeRows();
|
||||
const found = rows.find((row) => row.id === id || row.germplasm_id === id);
|
||||
if (!found) throw new Error("系谱节点不存在");
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function createPedigreeRow(payload: PedigreePayload): Promise<PedigreeRecord> {
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "POST",
|
||||
body: JSON.stringify([pedigreeBody(payload, true)]),
|
||||
});
|
||||
return mapPedigree(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updatePedigreeRow(id: string, payload: PedigreePayload): Promise<PedigreeRecord> {
|
||||
const germplasmDbId = optionalText(payload.germplasm_id) || id;
|
||||
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>("/brapi/v2/pedigree", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
[germplasmDbId]: {
|
||||
...pedigreeBody({ ...payload, germplasm_id: germplasmDbId }),
|
||||
germplasmDbId,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return mapPedigree(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function fetchPedigreeEdgeRows(): Promise<PedigreeEdgeRow[]> {
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
return flattenPedigreeEdges(nodes);
|
||||
}
|
||||
|
||||
export function buildPedigreeEdgeFormState(row?: PedigreeEdgeRow): PedigreeEdgeFormState {
|
||||
return {
|
||||
edge_type: row?.edge_type ?? "parent",
|
||||
parent_type: row?.parent_type && row.parent_type !== NONE_SELECT_VALUE ? row.parent_type : NONE_SELECT_VALUE,
|
||||
this_node_id: row?.this_node_id ?? NONE_SELECT_VALUE,
|
||||
connected_node_id: row?.connected_node_id ?? NONE_SELECT_VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, originalId?: string): Promise<void> {
|
||||
const edgeType = requiredText(payload.edge_type, "请选择关系类型");
|
||||
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
|
||||
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
|
||||
if (thisNodeId === connectedNodeId) {
|
||||
throw new Error("当前材料与关联材料不能相同");
|
||||
}
|
||||
if (edgeType === "sibling") {
|
||||
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
|
||||
}
|
||||
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
const childGermplasmId = edgeType === "parent" ? thisNodeId : connectedNodeId;
|
||||
const parentGermplasmId = edgeType === "parent" ? connectedNodeId : thisNodeId;
|
||||
const parentType = requiredText(payload.parent_type, "parent/child 关系请选择 parent_type");
|
||||
|
||||
const childNode = nodes.find((node) => node.germplasm_id === childGermplasmId);
|
||||
const existingParents = childNode?.parents ?? [];
|
||||
let nextParents = existingParents.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
}));
|
||||
|
||||
if (originalId) {
|
||||
const [, , oldConnectedNodeId] = originalId.split(":");
|
||||
if (oldConnectedNodeId) {
|
||||
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== oldConnectedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
nextParents = nextParents.filter((parent) => parent.germplasmDbId !== parentGermplasmId);
|
||||
nextParents.push({ germplasmDbId: parentGermplasmId, parentType });
|
||||
|
||||
await updatePedigreeParents(childGermplasmId, nextParents);
|
||||
}
|
||||
|
||||
export async function removePedigreeEdge(edgeId: string): Promise<void> {
|
||||
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
|
||||
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
|
||||
throw new Error("仅支持删除 parent 关系");
|
||||
}
|
||||
|
||||
const nodes = await fetchPedigreeRowsWithRelations();
|
||||
const childNode = nodes.find((node) => node.germplasm_id === thisNodeId);
|
||||
const nextParents = (childNode?.parents ?? [])
|
||||
.filter((parent) => parent.germplasmDbId !== connectedNodeId)
|
||||
.map((parent) => ({
|
||||
germplasmDbId: parent.germplasmDbId,
|
||||
parentType: parent.parentType,
|
||||
}));
|
||||
|
||||
await updatePedigreeParents(thisNodeId, nextParents);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { GitFork } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
CROSS_TYPE_OPTIONS,
|
||||
PLANNED_CROSS_STATUS_OPTIONS,
|
||||
crossTypeLabel,
|
||||
plannedStatusLabel,
|
||||
} from "../constants";
|
||||
import {
|
||||
createCrossRow,
|
||||
createPlannedCrossRow,
|
||||
normalizeCrossForm,
|
||||
normalizePlannedCrossForm,
|
||||
updateCrossRow,
|
||||
updatePlannedCrossRow,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
export function CrossEntityTab() {
|
||||
const { snapshot, refresh } = useCrossPedigree();
|
||||
const [subTab, setSubTab] = useState("planned");
|
||||
|
||||
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
|
||||
const plannedCrossOptions = snapshot?.plannedCrossOptions ?? [];
|
||||
|
||||
const loadPlannedRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.plannedCrosses as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const loadActualRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.actualCrosses as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const fetchPlannedRecord = useCallback(async (id: string) => {
|
||||
const data = await refresh(false);
|
||||
const row = data.plannedCrosses.find((item) => item.id === id);
|
||||
if (!row) throw new Error("计划杂交不存在");
|
||||
return normalizePlannedCrossForm(row);
|
||||
}, [refresh]);
|
||||
|
||||
const fetchActualRecord = useCallback(async (id: string) => {
|
||||
const data = await refresh(false);
|
||||
const row = data.actualCrosses.find((item) => item.id === id);
|
||||
if (!row) throw new Error("实际杂交不存在");
|
||||
return normalizeCrossForm(row);
|
||||
}, [refresh]);
|
||||
|
||||
const plannedFields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "计划杂交名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 B73 x Mo17 / Cross-2026-001",
|
||||
},
|
||||
{
|
||||
key: "crossing_project_id",
|
||||
label: "杂交项目",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
|
||||
},
|
||||
{
|
||||
key: "cross_type",
|
||||
label: "杂交类型",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
type: "select",
|
||||
options: PLANNED_CROSS_STATUS_OPTIONS,
|
||||
},
|
||||
], [crossingProjectOptions]);
|
||||
|
||||
const actualFields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "实际杂交名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 B73 x Mo17 实际杂交",
|
||||
},
|
||||
{
|
||||
key: "crossing_project_id",
|
||||
label: "杂交项目",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择杂交项目" }, ...crossingProjectOptions],
|
||||
},
|
||||
{
|
||||
key: "cross_type",
|
||||
label: "杂交类型",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不指定类型" }, ...CROSS_TYPE_OPTIONS],
|
||||
},
|
||||
{
|
||||
key: "planned_cross_id",
|
||||
label: "来源计划杂交",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不关联计划杂交" }, ...plannedCrossOptions],
|
||||
},
|
||||
], [crossingProjectOptions, plannedCrossOptions]);
|
||||
|
||||
return (
|
||||
<Tabs value={subTab} onValueChange={setSubTab} className="flex min-h-full flex-col gap-4">
|
||||
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
||||
<TabsTrigger value="planned">计划杂交 (planned=true)</TabsTrigger>
|
||||
<TabsTrigger value="actual">实际杂交 (planned=false)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{subTab === "planned" ? (
|
||||
<TabsContent value="planned" className="mt-0 min-h-0 flex-1">
|
||||
<BrapiEntityPage
|
||||
icon={GitFork}
|
||||
iconBg="bg-gradient-to-br from-emerald-500 to-green-600"
|
||||
title="计划杂交"
|
||||
description="cross_entity(planned=true):录入杂交计划,亲本请在「杂交亲本」Tab 维护"
|
||||
addLabel="新增计划杂交"
|
||||
useEnhancedDialog
|
||||
columns={[
|
||||
{ key: "plannedCrossDbId", label: "Cross ID" },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "crossing_project_name", label: "杂交项目" },
|
||||
{ key: "cross_type", label: "类型", render: crossTypeLabel },
|
||||
{ key: "status", label: "状态", render: plannedStatusLabel },
|
||||
]}
|
||||
fields={plannedFields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/plannedcrosses",
|
||||
value: "BrAPI",
|
||||
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadPlannedRows}
|
||||
fetchRecord={fetchPlannedRecord}
|
||||
createRecord={(payload) => createPlannedCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updatePlannedCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{subTab === "actual" ? (
|
||||
<TabsContent value="actual" className="mt-0 min-h-0 flex-1">
|
||||
<BrapiEntityPage
|
||||
icon={GitFork}
|
||||
iconBg="bg-gradient-to-br from-green-600 to-emerald-700"
|
||||
title="实际杂交"
|
||||
description="cross_entity(planned=false):完成实际杂交后可关联来源计划杂交;亲本请在「杂交亲本」Tab 维护"
|
||||
addLabel="新增实际杂交"
|
||||
useEnhancedDialog
|
||||
columns={[
|
||||
{ key: "crossDbId", label: "Cross ID" },
|
||||
{ key: "name", label: "名称" },
|
||||
{ key: "crossing_project_name", label: "杂交项目" },
|
||||
{ key: "plannedCrossName", label: "来源计划杂交" },
|
||||
{ key: "cross_type", label: "类型", render: crossTypeLabel },
|
||||
]}
|
||||
fields={actualFields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/crosses",
|
||||
value: "BrAPI",
|
||||
className: "bg-green-50 text-green-700 dark:bg-green-400/10 dark:text-green-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadActualRows}
|
||||
fetchRecord={fetchActualRecord}
|
||||
createRecord={(payload) => createCrossRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCrossRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Users } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from "@/components/common/shadcn-enhanced";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PARENT_TYPE_OPTIONS, parentTypeLabel } from "../constants";
|
||||
import {
|
||||
buildCrossParentFormState,
|
||||
updateCrossParents,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE, type CrossParentFormState, type CrossParentRow, type SelectOption } from "../types";
|
||||
|
||||
function ParentSlotEditor({
|
||||
title,
|
||||
slot,
|
||||
parentType,
|
||||
germplasmId,
|
||||
observationUnitId,
|
||||
germplasmOptions,
|
||||
observationUnitOptions,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
slot: "parent1" | "parent2";
|
||||
parentType: string;
|
||||
germplasmId: string;
|
||||
observationUnitId: string;
|
||||
germplasmOptions: SelectOption[];
|
||||
observationUnitOptions: SelectOption[];
|
||||
onChange: (patch: Partial<CrossParentFormState>) => void;
|
||||
}) {
|
||||
const prefix = slot;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 p-4 dark:border-slate-800">
|
||||
<h4 className="mb-3 text-sm font-medium text-slate-800 dark:text-slate-100">{title}</h4>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs">parent_type</Label>
|
||||
<Select
|
||||
value={parentType || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => onChange({ [`${prefix}_type`]: value } as Partial<CrossParentFormState>)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择亲本角色" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>不指定</SelectItem>
|
||||
{PARENT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs">germplasm_id</Label>
|
||||
<Select
|
||||
value={germplasmId || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => onChange({ [`${prefix}_germplasm_id`]: value } as Partial<CrossParentFormState>)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择种质" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>不指定种质</SelectItem>
|
||||
{germplasmOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs">observation_unit_id</Label>
|
||||
<Select
|
||||
value={observationUnitId || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => onChange({ [`${prefix}_observation_unit_id`]: value } as Partial<CrossParentFormState>)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择观测单元" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>不指定观测单元</SelectItem>
|
||||
{observationUnitOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrossParentTab() {
|
||||
const { snapshot, loading: pageLoading, refresh } = useCrossPedigree();
|
||||
const [rows, setRows] = useState<CrossParentRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<CrossParentFormState | null>(null);
|
||||
|
||||
const germplasmOptions = snapshot?.germplasm ?? [];
|
||||
const observationUnitOptions = snapshot?.observationUnits ?? [];
|
||||
const crossOptions = snapshot?.crossOptions ?? [];
|
||||
|
||||
const syncRowsFromSnapshot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await refresh(false);
|
||||
setRows(data.parentRows);
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
syncRowsFromSnapshot();
|
||||
}, [syncRowsFromSnapshot]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return rows;
|
||||
return rows.filter((row) =>
|
||||
[row.cross_name, row.germplasm_name, row.observation_unit_name, row.crossing_project_name, row.parent_type]
|
||||
.some((value) => String(value ?? "").toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [rows, search]);
|
||||
|
||||
const openEditorForCross = useCallback((crossId: string) => {
|
||||
if (!snapshot) throw new Error("数据尚未加载");
|
||||
const plannedCross = snapshot.plannedCrosses.find((item) => item.id === crossId);
|
||||
if (plannedCross) {
|
||||
setForm(buildCrossParentFormState(
|
||||
plannedCross.id,
|
||||
true,
|
||||
plannedCross.crossing_project_id,
|
||||
plannedCross.crossing_project_name,
|
||||
plannedCross.parent1,
|
||||
plannedCross.parent2,
|
||||
));
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
const actualCross = snapshot.actualCrosses.find((item) => item.id === crossId);
|
||||
if (!actualCross) throw new Error("Cross 不存在");
|
||||
setForm(buildCrossParentFormState(
|
||||
actualCross.id,
|
||||
false,
|
||||
actualCross.crossing_project_id,
|
||||
actualCross.crossing_project_name,
|
||||
actualCross.parent1,
|
||||
actualCross.parent2,
|
||||
));
|
||||
setDialogOpen(true);
|
||||
}, [snapshot]);
|
||||
|
||||
const openCreate = () => {
|
||||
if (crossOptions.length === 0) {
|
||||
setError("请先在「Cross 杂交」Tab 创建计划或实际杂交");
|
||||
return;
|
||||
}
|
||||
setForm({
|
||||
cross_id: NONE_SELECT_VALUE,
|
||||
planned: false,
|
||||
crossing_project_id: null,
|
||||
crossing_project_name: null,
|
||||
parent1_type: NONE_SELECT_VALUE,
|
||||
parent1_germplasm_id: NONE_SELECT_VALUE,
|
||||
parent1_observation_unit_id: NONE_SELECT_VALUE,
|
||||
parent2_type: NONE_SELECT_VALUE,
|
||||
parent2_germplasm_id: NONE_SELECT_VALUE,
|
||||
parent2_observation_unit_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCrossChange = (crossId: string) => {
|
||||
if (crossId === NONE_SELECT_VALUE || !form) return;
|
||||
try {
|
||||
openEditorForCross(crossId);
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "加载 Cross 失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateCrossParents(form);
|
||||
setDialogOpen(false);
|
||||
await syncRowsFromSnapshot();
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const crossIds = useMemo(() => Array.from(new Set(rows.map((row) => row.cross_id))), [rows]);
|
||||
const showLoading = loading || pageLoading;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-violet-500 to-purple-600")}>
|
||||
<Users className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Cross Parent 杂交亲本</h2>
|
||||
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
cross_parent:通过 Cross 的 parent1/parent2 维护亲本(parent_type + germplasm / observation_unit)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="shrink-0">维护亲本</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<span className="rounded-full bg-violet-50 px-3 py-1 text-xs text-violet-700 dark:bg-violet-400/10 dark:text-violet-200">
|
||||
BrAPI /brapi/v2/crosses · /brapi/v2/plannedcrosses (PUT parent1/parent2)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索 Cross / 种质 / 观测单元 / 项目"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
{showLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-slate-900">
|
||||
<TableHead>Cross</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>杂交项目</TableHead>
|
||||
<TableHead>亲本位</TableHead>
|
||||
<TableHead>parent_type</TableHead>
|
||||
<TableHead>种质</TableHead>
|
||||
<TableHead>观测单元</TableHead>
|
||||
<TableHead className="w-24 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center text-sm text-slate-400">
|
||||
暂无亲本记录。请先在 Cross Tab 创建杂交,再点击「维护亲本」录入 parent1/parent2。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.cross_name || row.cross_id}</TableCell>
|
||||
<TableCell>{row.planned ? "计划杂交" : "实际杂交"}</TableCell>
|
||||
<TableCell>{row.crossing_project_name || "—"}</TableCell>
|
||||
<TableCell>{row.parent_slot === "parent1" ? "Parent 1" : "Parent 2"}</TableCell>
|
||||
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
|
||||
<TableCell>{row.germplasm_name || row.germplasm_id || "—"}</TableCell>
|
||||
<TableCell>{row.observation_unit_name || row.observation_unit_id || "—"}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEditorForCross(row.cross_id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{!showLoading && crossIds.length > 0 ? (
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
共 {crossIds.length} 个 Cross 已录入亲本信息(BrAPI 每个 Cross 支持 parent1 / parent2 两个亲本槽位)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-3xl" title="维护杂交亲本 (cross_parent)">
|
||||
<DialogBody className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">所属 Cross</Label>
|
||||
<Select
|
||||
value={form?.cross_id || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, cross_id: value });
|
||||
handleCrossChange(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择 Cross" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>请选择 Cross</SelectItem>
|
||||
{crossOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">crossing_project_id(只读)</Label>
|
||||
<Input readOnly value={form?.crossing_project_name || form?.crossing_project_id || "—"} />
|
||||
</div>
|
||||
|
||||
{form ? (
|
||||
<>
|
||||
<ParentSlotEditor
|
||||
title="Parent 1"
|
||||
slot="parent1"
|
||||
parentType={form.parent1_type}
|
||||
germplasmId={form.parent1_germplasm_id}
|
||||
observationUnitId={form.parent1_observation_unit_id}
|
||||
germplasmOptions={germplasmOptions}
|
||||
observationUnitOptions={observationUnitOptions}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
/>
|
||||
<ParentSlotEditor
|
||||
title="Parent 2"
|
||||
slot="parent2"
|
||||
parentType={form.parent2_type}
|
||||
germplasmId={form.parent2_germplasm_id}
|
||||
observationUnitId={form.parent2_observation_unit_id}
|
||||
germplasmOptions={germplasmOptions}
|
||||
observationUnitOptions={observationUnitOptions}
|
||||
onChange={(patch) => setForm({ ...form, ...patch })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form || form.cross_id === NONE_SELECT_VALUE}>
|
||||
{saving ? "保存中..." : "保存亲本"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import {
|
||||
createCrossingProjectRow,
|
||||
fetchCrossingProjectDetail,
|
||||
normalizeCrossingProjectForm,
|
||||
updateCrossingProjectRow,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
export function CrossingProjectTab() {
|
||||
const { snapshot, refresh } = useCrossPedigree();
|
||||
const programOptions = snapshot?.programs ?? [];
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const data = await refresh(false);
|
||||
return data.crossingProjects as unknown as Record<string, unknown>[];
|
||||
}, [refresh]);
|
||||
|
||||
const fetchRecord = useCallback(async (id: string) => {
|
||||
const detail = await fetchCrossingProjectDetail(id);
|
||||
return normalizeCrossingProjectForm(detail);
|
||||
}, []);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "杂交项目名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 2026 抗倒伏杂交项目",
|
||||
},
|
||||
{
|
||||
key: "program_id",
|
||||
label: "所属 Program",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Program" }, ...programOptions],
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "项目说明",
|
||||
type: "textarea",
|
||||
placeholder: "杂交项目目标、范围、批次安排等",
|
||||
colSpan: 2,
|
||||
},
|
||||
], [programOptions]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
icon={Network}
|
||||
iconBg="bg-gradient-to-br from-lime-500 to-green-600"
|
||||
title="CrossingProject 杂交项目"
|
||||
description="crossing_project:某 Program 下的一组杂交任务集合(杂交工作台)。ID 由系统自动生成。"
|
||||
addLabel="新增杂交项目"
|
||||
useEnhancedDialog
|
||||
fetchRecord={fetchRecord}
|
||||
columns={[
|
||||
{ key: "crossingProjectDbId", label: "项目 ID" },
|
||||
{ key: "name", label: "项目名称" },
|
||||
{ key: "program_name", label: "Program" },
|
||||
{
|
||||
key: "description",
|
||||
label: "说明",
|
||||
render: (value) => {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "—";
|
||||
return text.length > 48 ? `${text.slice(0, 48)}…` : text;
|
||||
},
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/crossingprojects",
|
||||
value: "BrAPI",
|
||||
className: "bg-lime-50 text-lime-700 dark:bg-lime-400/10 dark:text-lime-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadRows}
|
||||
createRecord={(payload) => createCrossingProjectRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCrossingProjectRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { GitBranch, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
} from "@/components/common/shadcn-enhanced";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EDGE_TYPE_OPTIONS, PARENT_TYPE_OPTIONS, edgeTypeLabel, parentTypeLabel } from "../constants";
|
||||
import {
|
||||
buildPedigreeEdgeFormState,
|
||||
fetchPedigreeEdgeRows,
|
||||
fetchPedigreeRows,
|
||||
removePedigreeEdge,
|
||||
upsertPedigreeEdge,
|
||||
} from "../api";
|
||||
import { NONE_SELECT_VALUE, type PedigreeEdgeFormState, type PedigreeEdgeRow, type SelectOption } from "../types";
|
||||
|
||||
export function PedigreeEdgeTab() {
|
||||
const [rows, setRows] = useState<PedigreeEdgeRow[]>([]);
|
||||
const [nodeOptions, setNodeOptions] = useState<SelectOption[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<PedigreeEdgeFormState | null>(null);
|
||||
const [editingEdgeId, setEditingEdgeId] = useState<string | null>(null);
|
||||
const [deletingEdge, setDeletingEdge] = useState<PedigreeEdgeRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [edgeRows, nodes] = await Promise.all([fetchPedigreeEdgeRows(), fetchPedigreeRows()]);
|
||||
setRows(edgeRows);
|
||||
setNodeOptions(
|
||||
nodes
|
||||
.filter((node) => node.germplasm_id)
|
||||
.map((node) => ({
|
||||
value: node.germplasm_id as string,
|
||||
label: node.germplasm_name || node.germplasm_id || "",
|
||||
})),
|
||||
);
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRows();
|
||||
}, [loadRows]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
if (!keyword) return rows;
|
||||
return rows.filter((row) =>
|
||||
[
|
||||
row.this_node_name,
|
||||
row.connected_node_name,
|
||||
row.edge_type,
|
||||
row.parent_type,
|
||||
row.this_node_id,
|
||||
row.connected_node_id,
|
||||
].some((value) => String(value ?? "").toLowerCase().includes(keyword)),
|
||||
);
|
||||
}, [rows, search]);
|
||||
|
||||
const openCreate = () => {
|
||||
if (nodeOptions.length < 2) {
|
||||
setError("请先在「Pedigree Node 系谱节点」Tab 创建至少两个节点");
|
||||
return;
|
||||
}
|
||||
setEditingEdgeId(null);
|
||||
setForm(buildPedigreeEdgeFormState());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: PedigreeEdgeRow) => {
|
||||
if (row.read_only || row.edge_type === "sibling") {
|
||||
setError("同胞关系为只读展示,请通过 parent 关系维护");
|
||||
return;
|
||||
}
|
||||
setEditingEdgeId(row.id);
|
||||
setForm(buildPedigreeEdgeFormState(row));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await upsertPedigreeEdge(form, editingEdgeId ?? undefined);
|
||||
setDialogOpen(false);
|
||||
await loadRows();
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletingEdge) return;
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await removePedigreeEdge(deletingEdge.id);
|
||||
setDeletingEdge(null);
|
||||
await loadRows();
|
||||
} catch (event) {
|
||||
setError(event instanceof Error ? event.message : "删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showParentType = form?.edge_type === "parent" || form?.edge_type === "child";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("rounded-xl p-2.5", "bg-gradient-to-br from-cyan-500 to-teal-600")}>
|
||||
<GitBranch className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-50">Pedigree Edge 系谱边</h2>
|
||||
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
pedigree_edge:维护节点之间的 parent / child 关系;sibling 由 BrAPI 根据共享亲本自动推断(只读展示)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="shrink-0 gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
新增系谱边
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<span className="rounded-full bg-cyan-50 px-3 py-1 text-xs text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200">
|
||||
BrAPI PUT /brapi/v2/pedigree(parents 字段)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="搜索当前材料 / 关联材料 / 关系类型"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <p className="mb-3 text-sm text-red-500">{error}</p> : null}
|
||||
|
||||
{loading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-slate-900">
|
||||
<TableHead>关系类型</TableHead>
|
||||
<TableHead>当前材料</TableHead>
|
||||
<TableHead>关联材料</TableHead>
|
||||
<TableHead>parent_type</TableHead>
|
||||
<TableHead className="w-28 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-sm text-slate-400">
|
||||
暂无系谱边。请先创建系谱节点,再维护 parent / child 关系。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
{edgeTypeLabel(row.edge_type)}
|
||||
{row.read_only ? (
|
||||
<span className="ml-2 text-xs text-slate-400">只读</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>{row.this_node_name || row.this_node_id}</TableCell>
|
||||
<TableCell>{row.connected_node_name || row.connected_node_id}</TableCell>
|
||||
<TableCell>{parentTypeLabel(row.parent_type)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!row.read_only ? (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={() => openEdit(row)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1 text-red-600 hover:text-red-600"
|
||||
onClick={() => setDeletingEdge(row)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-xl" title={editingEdgeId ? "编辑系谱边" : "新增系谱边"}>
|
||||
<DialogBody className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">edge_type 关系类型</Label>
|
||||
<Select
|
||||
value={form?.edge_type || "parent"}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, edge_type: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择关系类型" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110]">
|
||||
{EDGE_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">当前材料</Label>
|
||||
<Select
|
||||
value={form?.this_node_id || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, this_node_id: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择当前材料" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>请选择当前材料</SelectItem>
|
||||
{nodeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">关联材料</Label>
|
||||
<Select
|
||||
value={form?.connected_node_id || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, connected_node_id: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择关联材料" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110] max-h-60">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>请选择关联材料</SelectItem>
|
||||
{nodeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showParentType ? (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-sm">parent_type 亲本类型</Label>
|
||||
<Select
|
||||
value={form?.parent_type || NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
if (!form) return;
|
||||
setForm({ ...form, parent_type: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="选择亲本类型" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="z-[110]">
|
||||
<SelectItem value={NONE_SELECT_VALUE}>请选择 parent_type</SelectItem>
|
||||
{PARENT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
parent:当前材料为子代,关联材料为亲本;child:当前材料为亲本,关联材料为子代。
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>取消</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving
|
||||
|| !form
|
||||
|| form.this_node_id === NONE_SELECT_VALUE
|
||||
|| form.connected_node_id === NONE_SELECT_VALUE
|
||||
|| (showParentType && form.parent_type === NONE_SELECT_VALUE)
|
||||
}
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={Boolean(deletingEdge)} onOpenChange={(open) => !open && setDeletingEdge(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除系谱边?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将移除
|
||||
{" "}
|
||||
{deletingEdge ? edgeTypeLabel(deletingEdge.edge_type) : ""}
|
||||
{" "}
|
||||
关系:
|
||||
{deletingEdge?.this_node_name || deletingEdge?.this_node_id}
|
||||
{" → "}
|
||||
{deletingEdge?.connected_node_name || deletingEdge?.connected_node_id}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? "删除中..." : "确认删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Share2 } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import {
|
||||
createPedigreeRow,
|
||||
fetchPedigreeDetail,
|
||||
fetchPedigreeRows,
|
||||
normalizePedigreeForm,
|
||||
updatePedigreeRow,
|
||||
} from "../api";
|
||||
import { useCrossPedigree } from "../CrossPedigreeContext";
|
||||
import { NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
export function PedigreeNodeTab() {
|
||||
const { snapshot } = useCrossPedigree();
|
||||
const germplasmOptions = snapshot?.germplasm ?? [];
|
||||
const crossingProjectOptions = snapshot?.crossingProjectOptions ?? [];
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const rows = await fetchPedigreeRows();
|
||||
const projectNameById = new Map(
|
||||
(snapshot?.crossingProjects ?? []).map((project) => [project.id, project.name || project.id]),
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
crossing_project_name:
|
||||
row.crossing_project_name || (row.crossing_project_id ? projectNameById.get(row.crossing_project_id) : null),
|
||||
})) as unknown as Record<string, unknown>[];
|
||||
}, [snapshot?.crossingProjects]);
|
||||
|
||||
const fetchRecord = useCallback(async (id: string) => {
|
||||
const detail = await fetchPedigreeDetail(id);
|
||||
return normalizePedigreeForm(detail);
|
||||
}, []);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{
|
||||
key: "germplasm_id",
|
||||
label: "Germplasm 材料",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "请选择 Germplasm" }, ...germplasmOptions],
|
||||
},
|
||||
{
|
||||
key: "crossing_project_id",
|
||||
label: "CrossingProject 杂交项目",
|
||||
type: "select",
|
||||
options: [{ value: NONE_SELECT_VALUE, label: "不指定杂交项目" }, ...crossingProjectOptions],
|
||||
},
|
||||
{
|
||||
key: "crossing_year",
|
||||
label: "crossing_year 杂交年份",
|
||||
type: "year",
|
||||
placeholder: "四位年份",
|
||||
},
|
||||
{
|
||||
key: "family_code",
|
||||
label: "family_code 家系编号",
|
||||
type: "text",
|
||||
placeholder: "同一 crossing_project 下建议唯一",
|
||||
},
|
||||
{
|
||||
key: "pedigree_string",
|
||||
label: "pedigree_string 系谱字符串",
|
||||
type: "text",
|
||||
placeholder: "如 A/B//C,支持 Purdy notation",
|
||||
colSpan: 2,
|
||||
},
|
||||
], [crossingProjectOptions, germplasmOptions]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
icon={Share2}
|
||||
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
|
||||
title="Pedigree Node 系谱节点"
|
||||
description="pedigree_node:系谱树中的节点,通常对应一个 germplasm。BrAPI 以 germplasmDbId 作为更新主键。"
|
||||
addLabel="新增系谱节点"
|
||||
useEnhancedDialog
|
||||
fetchRecord={fetchRecord}
|
||||
columns={[
|
||||
{ key: "germplasm_name", label: "材料" },
|
||||
{ key: "germplasm_id", label: "Germplasm ID" },
|
||||
{ key: "crossing_project_name", label: "杂交项目" },
|
||||
{ key: "crossing_year", label: "杂交年份" },
|
||||
{ key: "family_code", label: "家系编号" },
|
||||
{
|
||||
key: "pedigree_string",
|
||||
label: "系谱字符串",
|
||||
render: (value) => {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "—";
|
||||
return text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
||||
},
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[
|
||||
{
|
||||
label: "/brapi/v2/pedigree",
|
||||
value: "BrAPI",
|
||||
className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200",
|
||||
},
|
||||
]}
|
||||
loadData={loadRows}
|
||||
createRecord={(payload) => createPedigreeRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updatePedigreeRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
47
frontend/src/app/(app)/germplasm/cross-pedigree/constants.ts
Normal file
47
frontend/src/app/(app)/germplasm/cross-pedigree/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SelectOption } from "./types";
|
||||
|
||||
export const CROSS_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "BIPARENTAL", label: "BIPARENTAL 双亲杂交" },
|
||||
{ value: "SELF", label: "SELF 自交" },
|
||||
{ value: "OPEN_POLLINATED", label: "OPEN_POLLINATED 开放授粉" },
|
||||
{ value: "BULK", label: "BULK bulk" },
|
||||
{ value: "BULK_SELFED", label: "BULK_SELFED" },
|
||||
{ value: "BULK_OPEN_POLLINATED", label: "BULK_OPEN_POLLINATED" },
|
||||
{ value: "DOUBLE_HAPLOID", label: "DOUBLE_HAPLOID 双单倍体" },
|
||||
];
|
||||
|
||||
export const PLANNED_CROSS_STATUS_OPTIONS: SelectOption[] = [
|
||||
{ value: "TODO", label: "TODO 待执行" },
|
||||
{ value: "DONE", label: "DONE 已完成" },
|
||||
{ value: "SKIPPED", label: "SKIPPED 已跳过" },
|
||||
];
|
||||
|
||||
export const PARENT_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "FEMALE", label: "FEMALE 母本" },
|
||||
{ value: "MALE", label: "MALE 父本" },
|
||||
{ value: "SELF", label: "SELF 自交" },
|
||||
{ value: "POPULATION", label: "POPULATION 群体" },
|
||||
{ value: "CLONAL", label: "CLONAL 克隆" },
|
||||
];
|
||||
|
||||
export const crossTypeLabel = (value: unknown) =>
|
||||
CROSS_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const plannedStatusLabel = (value: unknown) =>
|
||||
PLANNED_CROSS_STATUS_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const parentTypeLabel = (value: unknown) =>
|
||||
PARENT_TYPE_OPTIONS.find((option) => option.value === String(value))?.label ?? (value ? String(value) : "—");
|
||||
|
||||
export const EDGE_TYPE_OPTIONS: SelectOption[] = [
|
||||
{ value: "parent", label: "parent 亲本" },
|
||||
{ value: "child", label: "child 后代" },
|
||||
];
|
||||
|
||||
export const edgeTypeLabel = (value: unknown) => {
|
||||
const text = String(value ?? "");
|
||||
if (text === "parent") return "parent 亲本";
|
||||
if (text === "child") return "child 后代";
|
||||
if (text === "sibling") return "sibling 同胞";
|
||||
return text || "—";
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { Cross, CrossParent, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
|
||||
import {
|
||||
loadGermplasmOptions,
|
||||
loadObservationUnitOptions,
|
||||
loadProgramOptions,
|
||||
} from "@/services/dropdownCache";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
mapCross,
|
||||
mapCrossingProject,
|
||||
mapPlannedCross,
|
||||
} from "./mappers";
|
||||
import type {
|
||||
CrossParentRow,
|
||||
CrossRecord,
|
||||
CrossingProjectRecord,
|
||||
PlannedCrossRecord,
|
||||
SelectOption,
|
||||
} from "./types";
|
||||
|
||||
interface BrapiListResponse<T> {
|
||||
result: { data: T[] };
|
||||
}
|
||||
|
||||
export interface CrossPedigreeSnapshot {
|
||||
programs: SelectOption[];
|
||||
germplasm: SelectOption[];
|
||||
observationUnits: SelectOption[];
|
||||
crossingProjectOptions: SelectOption[];
|
||||
plannedCrossOptions: SelectOption[];
|
||||
crossOptions: SelectOption[];
|
||||
crossingProjects: CrossingProjectRecord[];
|
||||
plannedCrosses: PlannedCrossRecord[];
|
||||
actualCrosses: CrossRecord[];
|
||||
parentRows: CrossParentRow[];
|
||||
}
|
||||
|
||||
const apiBase = () => {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `请求失败:${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const flattenCrossParents = (
|
||||
crossId: string,
|
||||
crossName: string | null,
|
||||
planned: boolean,
|
||||
crossingProjectId: string | null,
|
||||
crossingProjectName: string | null,
|
||||
parent: CrossParent | null | undefined,
|
||||
slot: "parent1" | "parent2",
|
||||
): CrossParentRow | null => {
|
||||
if (!parent) return null;
|
||||
const hasData = parent.parentType || parent.germplasmDbId || parent.observationUnitDbId;
|
||||
if (!hasData) return null;
|
||||
return {
|
||||
id: `${crossId}:${slot}`,
|
||||
cross_id: crossId,
|
||||
cross_name: crossName,
|
||||
planned,
|
||||
parent_slot: slot,
|
||||
parent_type: parent.parentType ?? null,
|
||||
germplasm_id: parent.germplasmDbId ?? null,
|
||||
germplasm_name: parent.germplasmName ?? null,
|
||||
observation_unit_id: parent.observationUnitDbId ?? null,
|
||||
observation_unit_name: parent.observationUnitName ?? null,
|
||||
crossing_project_id: crossingProjectId,
|
||||
crossing_project_name: crossingProjectName,
|
||||
};
|
||||
};
|
||||
|
||||
function buildParentRows(planned: PlannedCrossRecord[], actual: CrossRecord[]): CrossParentRow[] {
|
||||
const rows: CrossParentRow[] = [];
|
||||
|
||||
for (const cross of planned) {
|
||||
const p1 = flattenCrossParents(
|
||||
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
|
||||
);
|
||||
const p2 = flattenCrossParents(
|
||||
cross.id, cross.name, true, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
|
||||
);
|
||||
if (p1) rows.push(p1);
|
||||
if (p2) rows.push(p2);
|
||||
}
|
||||
|
||||
for (const cross of actual) {
|
||||
const p1 = flattenCrossParents(
|
||||
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent1, "parent1",
|
||||
);
|
||||
const p2 = flattenCrossParents(
|
||||
cross.id, cross.name, false, cross.crossing_project_id, cross.crossing_project_name, cross.parent2, "parent2",
|
||||
);
|
||||
if (p1) rows.push(p1);
|
||||
if (p2) rows.push(p2);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function fetchSnapshotFromNetwork(): Promise<CrossPedigreeSnapshot> {
|
||||
const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([
|
||||
loadProgramOptions(),
|
||||
loadGermplasmOptions(),
|
||||
request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects?page=0&pageSize=1000"),
|
||||
request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses?page=0&pageSize=1000"),
|
||||
request<BrapiListResponse<Cross>>("/brapi/v2/crosses?page=0&pageSize=1000"),
|
||||
loadObservationUnitOptions().catch(() => [] as SelectOption[]),
|
||||
]);
|
||||
|
||||
const crossingProjectRows = (crossingProjects.result?.data ?? []).map(mapCrossingProject);
|
||||
const plannedRows = (plannedCrosses.result?.data ?? []).map(mapPlannedCross);
|
||||
const actualRows = (actualCrosses.result?.data ?? []).map(mapCross);
|
||||
|
||||
return {
|
||||
programs,
|
||||
germplasm,
|
||||
observationUnits,
|
||||
crossingProjectOptions: crossingProjectRows.map((project) => ({
|
||||
value: project.id,
|
||||
label: project.name || project.id,
|
||||
})),
|
||||
plannedCrossOptions: plannedRows.map((cross) => ({
|
||||
value: cross.id,
|
||||
label: cross.name || cross.id,
|
||||
})),
|
||||
crossOptions: [...plannedRows, ...actualRows].map((cross) => ({
|
||||
value: cross.id,
|
||||
label: `${cross.planned ? "[计划] " : "[实际] "}${cross.name || cross.id}`,
|
||||
})),
|
||||
crossingProjects: crossingProjectRows,
|
||||
plannedCrosses: plannedRows,
|
||||
actualCrosses: actualRows,
|
||||
parentRows: buildParentRows(plannedRows, actualRows),
|
||||
};
|
||||
}
|
||||
|
||||
let cachedSnapshot: CrossPedigreeSnapshot | null = null;
|
||||
let inflightSnapshot: Promise<CrossPedigreeSnapshot> | null = null;
|
||||
|
||||
export function invalidateCrossPedigreeCache() {
|
||||
cachedSnapshot = null;
|
||||
inflightSnapshot = null;
|
||||
}
|
||||
|
||||
export async function loadCrossPedigreeSnapshot(force = false): Promise<CrossPedigreeSnapshot> {
|
||||
if (!force && cachedSnapshot) return cachedSnapshot;
|
||||
if (!force && inflightSnapshot) return inflightSnapshot;
|
||||
|
||||
inflightSnapshot = fetchSnapshotFromNetwork()
|
||||
.then((snapshot) => {
|
||||
cachedSnapshot = snapshot;
|
||||
inflightSnapshot = null;
|
||||
return snapshot;
|
||||
})
|
||||
.catch((error) => {
|
||||
inflightSnapshot = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return inflightSnapshot;
|
||||
}
|
||||
|
||||
export function getCrossPedigreeSnapshot(): CrossPedigreeSnapshot | null {
|
||||
return cachedSnapshot;
|
||||
}
|
||||
51
frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts
Normal file
51
frontend/src/app/(app)/germplasm/cross-pedigree/mappers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Cross, CrossingProject, PlannedCross } from "@/lib/api/types.gen";
|
||||
import type { CrossRecord, CrossingProjectRecord, PlannedCrossRecord } from "./types";
|
||||
|
||||
export const mapCrossingProject = (project: CrossingProject): CrossingProjectRecord => ({
|
||||
id: project.crossingProjectDbId || "",
|
||||
crossingProjectDbId: project.crossingProjectDbId || "",
|
||||
crossingProjectName: project.crossingProjectName ?? null,
|
||||
name: project.crossingProjectName ?? null,
|
||||
crossingProjectDescription: project.crossingProjectDescription ?? null,
|
||||
description: project.crossingProjectDescription ?? null,
|
||||
programDbId: project.programDbId ?? null,
|
||||
program_id: project.programDbId ?? null,
|
||||
programName: project.programName ?? null,
|
||||
program_name: project.programName ?? null,
|
||||
});
|
||||
|
||||
export const mapPlannedCross = (cross: PlannedCross): PlannedCrossRecord => ({
|
||||
id: cross.plannedCrossDbId || "",
|
||||
plannedCrossDbId: cross.plannedCrossDbId || "",
|
||||
plannedCrossName: cross.plannedCrossName ?? null,
|
||||
name: cross.plannedCrossName ?? null,
|
||||
crossType: cross.crossType ?? null,
|
||||
cross_type: cross.crossType ?? null,
|
||||
status: cross.status ?? null,
|
||||
crossingProjectDbId: cross.crossingProjectDbId ?? null,
|
||||
crossing_project_id: cross.crossingProjectDbId ?? null,
|
||||
crossingProjectName: cross.crossingProjectName ?? null,
|
||||
crossing_project_name: cross.crossingProjectName ?? null,
|
||||
planned: true,
|
||||
parent1: cross.parent1 ?? null,
|
||||
parent2: cross.parent2 ?? null,
|
||||
});
|
||||
|
||||
export const mapCross = (cross: Cross): CrossRecord => ({
|
||||
id: cross.crossDbId || "",
|
||||
crossDbId: cross.crossDbId || "",
|
||||
crossName: cross.crossName ?? null,
|
||||
name: cross.crossName ?? null,
|
||||
crossType: cross.crossType ?? null,
|
||||
cross_type: cross.crossType ?? null,
|
||||
crossingProjectDbId: cross.crossingProjectDbId ?? null,
|
||||
crossing_project_id: cross.crossingProjectDbId ?? null,
|
||||
crossingProjectName: cross.crossingProjectName ?? null,
|
||||
crossing_project_name: cross.crossingProjectName ?? null,
|
||||
plannedCrossDbId: cross.plannedCrossDbId ?? null,
|
||||
planned_cross_id: cross.plannedCrossDbId ?? null,
|
||||
plannedCrossName: cross.plannedCrossName ?? null,
|
||||
planned: false,
|
||||
parent1: cross.parent1 ?? null,
|
||||
parent2: cross.parent2 ?? null,
|
||||
});
|
||||
80
frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx
Normal file
80
frontend/src/app/(app)/germplasm/cross-pedigree/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { GitBranch, GitFork, Network, Share2, Users } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CrossPedigreeProvider } from "./CrossPedigreeContext";
|
||||
import { CrossingProjectTab } from "./components/CrossingProjectTab";
|
||||
import { CrossEntityTab } from "./components/CrossEntityTab";
|
||||
import { CrossParentTab } from "./components/CrossParentTab";
|
||||
import { PedigreeEdgeTab } from "./components/PedigreeEdgeTab";
|
||||
import { PedigreeNodeTab } from "./components/PedigreeNodeTab";
|
||||
|
||||
function CrossPedigreePageContent() {
|
||||
const [tab, setTab] = useState("projects");
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
|
||||
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
|
||||
<TabsTrigger value="projects" className="gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
CrossingProject
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="crosses" className="gap-2">
|
||||
<GitFork className="h-4 w-4" />
|
||||
Cross 杂交
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="parents" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Cross Parent 亲本
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pedigree-nodes" className="gap-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
Pedigree Node
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pedigree-edges" className="gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Pedigree Edge
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{tab === "projects" ? (
|
||||
<TabsContent value="projects" className="mt-0 min-h-0 flex-1">
|
||||
<CrossingProjectTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "crosses" ? (
|
||||
<TabsContent value="crosses" className="mt-0 min-h-0 flex-1">
|
||||
<CrossEntityTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "parents" ? (
|
||||
<TabsContent value="parents" className="mt-0 min-h-0 flex-1">
|
||||
<CrossParentTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "pedigree-nodes" ? (
|
||||
<TabsContent value="pedigree-nodes" className="mt-0 min-h-0 flex-1">
|
||||
<PedigreeNodeTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "pedigree-edges" ? (
|
||||
<TabsContent value="pedigree-edges" className="mt-0 min-h-0 flex-1">
|
||||
<PedigreeEdgeTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrossPedigreePage() {
|
||||
return (
|
||||
<CrossPedigreeProvider>
|
||||
<CrossPedigreePageContent />
|
||||
</CrossPedigreeProvider>
|
||||
);
|
||||
}
|
||||
137
frontend/src/app/(app)/germplasm/cross-pedigree/types.ts
Normal file
137
frontend/src/app/(app)/germplasm/cross-pedigree/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { CrossParent } from "@/lib/api/types.gen";
|
||||
|
||||
export const NONE_SELECT_VALUE = "__none__";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CrossingProjectRecord {
|
||||
id: string;
|
||||
crossingProjectDbId: string;
|
||||
crossingProjectName: string | null;
|
||||
name: string | null;
|
||||
crossingProjectDescription: string | null;
|
||||
description: string | null;
|
||||
programDbId: string | null;
|
||||
program_id: string | null;
|
||||
programName: string | null;
|
||||
program_name: string | null;
|
||||
}
|
||||
|
||||
export interface PlannedCrossRecord {
|
||||
id: string;
|
||||
plannedCrossDbId: string;
|
||||
plannedCrossName: string | null;
|
||||
name: string | null;
|
||||
crossType: string | null;
|
||||
cross_type: string | null;
|
||||
status: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
planned: true;
|
||||
parent1: CrossParent | null;
|
||||
parent2: CrossParent | null;
|
||||
}
|
||||
|
||||
export interface CrossRecord {
|
||||
id: string;
|
||||
crossDbId: string;
|
||||
crossName: string | null;
|
||||
name: string | null;
|
||||
crossType: string | null;
|
||||
cross_type: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
plannedCrossDbId: string | null;
|
||||
planned_cross_id: string | null;
|
||||
plannedCrossName: string | null;
|
||||
planned: false;
|
||||
parent1: CrossParent | null;
|
||||
parent2: CrossParent | null;
|
||||
}
|
||||
|
||||
export interface CrossParentRow {
|
||||
id: string;
|
||||
cross_id: string;
|
||||
cross_name: string | null;
|
||||
planned: boolean;
|
||||
parent_slot: "parent1" | "parent2";
|
||||
parent_type: string | null;
|
||||
germplasm_id: string | null;
|
||||
germplasm_name: string | null;
|
||||
observation_unit_id: string | null;
|
||||
observation_unit_name: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossing_project_name: string | null;
|
||||
}
|
||||
|
||||
export interface CrossParentFormState {
|
||||
cross_id: string;
|
||||
planned: boolean;
|
||||
crossing_project_id: string | null;
|
||||
crossing_project_name: string | null;
|
||||
parent1_type: string;
|
||||
parent1_germplasm_id: string;
|
||||
parent1_observation_unit_id: string;
|
||||
parent2_type: string;
|
||||
parent2_germplasm_id: string;
|
||||
parent2_observation_unit_id: string;
|
||||
}
|
||||
|
||||
export interface PedigreeNodeParentRef {
|
||||
germplasmDbId?: string;
|
||||
germplasmName?: string;
|
||||
parentType?: string;
|
||||
}
|
||||
|
||||
export interface PedigreeNodeSiblingRef {
|
||||
germplasmDbId?: string;
|
||||
germplasmName?: string;
|
||||
}
|
||||
|
||||
export interface PedigreeRecord {
|
||||
id: string;
|
||||
pedigreeNodeDbId?: string;
|
||||
germplasmDbId: string | null;
|
||||
germplasm_id: string | null;
|
||||
germplasmName: string | null;
|
||||
germplasm_name: string | null;
|
||||
crossingProjectDbId: string | null;
|
||||
crossing_project_id: string | null;
|
||||
crossingProjectName: string | null;
|
||||
crossing_project_name: string | null;
|
||||
crossingYear: number | null;
|
||||
crossing_year: number | null;
|
||||
familyCode: string | null;
|
||||
family_code: string | null;
|
||||
pedigreeString: string | null;
|
||||
pedigree_string: string | null;
|
||||
parents?: PedigreeNodeParentRef[];
|
||||
siblings?: PedigreeNodeSiblingRef[];
|
||||
}
|
||||
|
||||
export type PedigreeEdgeType = "parent" | "child" | "sibling";
|
||||
|
||||
export interface PedigreeEdgeRow {
|
||||
id: string;
|
||||
edge_type: PedigreeEdgeType;
|
||||
parent_type: string | null;
|
||||
this_node_id: string;
|
||||
this_node_name: string | null;
|
||||
connected_node_id: string;
|
||||
connected_node_name: string | null;
|
||||
read_only?: boolean;
|
||||
}
|
||||
|
||||
export interface PedigreeEdgeFormState {
|
||||
edge_type: string;
|
||||
parent_type: string;
|
||||
this_node_id: string;
|
||||
connected_node_id: string;
|
||||
}
|
||||
217
frontend/src/app/(app)/germplasm/germplasm/api.ts
Normal file
217
frontend/src/app/(app)/germplasm/germplasm/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
179
frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts
Normal file
179
frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts
Normal 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);
|
||||
}
|
||||
48
frontend/src/app/(app)/germplasm/germplasm/attributeTypes.ts
Normal file
48
frontend/src/app/(app)/germplasm/germplasm/attributeTypes.ts
Normal 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;
|
||||
};
|
||||
152
frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts
Normal file
152
frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts
Normal 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);
|
||||
}
|
||||
@@ -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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
180
frontend/src/app/(app)/germplasm/germplasm/page.tsx
Normal file
180
frontend/src/app/(app)/germplasm/germplasm/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/src/app/(app)/germplasm/germplasm/types.ts
Normal file
54
frontend/src/app/(app)/germplasm/germplasm/types.ts
Normal 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;
|
||||
}
|
||||
460
frontend/src/app/(app)/germplasm/seed-lot/api.ts
Normal file
460
frontend/src/app/(app)/germplasm/seed-lot/api.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
38
frontend/src/app/(app)/germplasm/seed-lot/page.tsx
Normal file
38
frontend/src/app/(app)/germplasm/seed-lot/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/app/(app)/germplasm/seed-lot/types.ts
Normal file
102
frontend/src/app/(app)/germplasm/seed-lot/types.ts
Normal 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: "消耗/报废",
|
||||
};
|
||||
38
frontend/src/app/(app)/layout.tsx
Normal file
38
frontend/src/app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
278
frontend/src/app/(app)/phenotyping/event-image/api.ts
Normal file
278
frontend/src/app/(app)/phenotyping/event-image/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
@@ -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 ID、Image Name、File Name、Image URL、MIME Type 与 Image Date
|
||||
</p>
|
||||
<ImageUploader
|
||||
maxFiles={1}
|
||||
showPreview
|
||||
onUploaded={handleUploaded}
|
||||
onRemoved={handleRemoved}
|
||||
value={imageUrl ? [imageUrl] : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/src/app/(app)/phenotyping/event-image/page.tsx
Normal file
192
frontend/src/app/(app)/phenotyping/event-image/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
frontend/src/app/(app)/phenotyping/event-image/types.ts
Normal file
59
frontend/src/app/(app)/phenotyping/event-image/types.ts
Normal 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;
|
||||
}
|
||||
157
frontend/src/app/(app)/phenotyping/observation-unit/api.ts
Normal file
157
frontend/src/app/(app)/phenotyping/observation-unit/api.ts
Normal 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]);
|
||||
}
|
||||
78
frontend/src/app/(app)/phenotyping/observation-unit/page.tsx
Normal file
78
frontend/src/app/(app)/phenotyping/observation-unit/page.tsx
Normal 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>>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
29
frontend/src/app/(app)/phenotyping/observation-unit/types.ts
Normal file
29
frontend/src/app/(app)/phenotyping/observation-unit/types.ts
Normal 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;
|
||||
}
|
||||
228
frontend/src/app/(app)/phenotyping/observation-variable/api.ts
Normal file
228
frontend/src/app/(app)/phenotyping/observation-variable/api.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
100
frontend/src/app/(app)/phenotyping/observation-variable/page.tsx
Normal file
100
frontend/src/app/(app)/phenotyping/observation-variable/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
8
frontend/src/app/(app)/phenotyping/observation/page.tsx
Normal file
8
frontend/src/app/(app)/phenotyping/observation/page.tsx
Normal 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} />;
|
||||
}
|
||||
148
frontend/src/app/(app)/project/program/api.ts
Normal file
148
frontend/src/app/(app)/project/program/api.ts
Normal 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
Reference in New Issue
Block a user