fix:sample/plate 之前的开发

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,157 @@
import { getAuthToken } from "@/utils/token";
import { loadGermplasmOptions, loadStudyOptions } from "@/services/dropdownCache";
import { NONE_SELECT_VALUE, type ObservationUnitRecord, type SelectOption } from "./types";
interface BrapiPagination {
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface BrapiListResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface StudyResponse {
studyDbId: string;
studyName: string | null;
}
interface GermplasmResponse {
germplasmDbId: string;
germplasmName: string | null;
}
type ObservationUnitPayload = Partial<Record<
| "id"
| "observation_unit_name"
| "study_id"
| "germplasm_id"
| "level"
| "level_order"
| "row"
| "col",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`);
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const optionalNumber = (value: unknown) => {
const text = optionalText(value);
if (!text) return null;
const number = Number(text);
if (Number.isNaN(number)) return null;
return number;
};
const mapObservationUnit = (unit: ObservationUnitRecord): ObservationUnitRecord => {
const levelName = unit.observationLevel?.levelName ?? null;
const levelOrder = unit.observationLevel?.levelOrder ?? null;
return {
...unit,
id: unit.observationUnitDbId || unit.id,
observation_unit_name: unit.observation_unit_name || unit.observationUnitName,
study_id: unit.study_id || unit.studyDbId,
germplasm_id: unit.germplasm_id || unit.germplasmDbId,
level: unit.level || levelName,
level_order: unit.level_order ?? levelOrder,
row: unit.row || unit.positionCoordinateY,
col: unit.col || unit.positionCoordinateX,
};
};
const toRequestBody = (payload: ObservationUnitPayload) => ({
observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名称"),
studyDbId: optionalText(payload.study_id),
germplasmDbId: optionalText(payload.germplasm_id),
observationLevel: {
levelName: requiredText(payload.level, "请选择层级"),
levelOrder: optionalNumber(payload.level_order) ?? 0,
},
positionCoordinateX: optionalText(payload.col),
positionCoordinateY: optionalText(payload.row),
});
export async function fetchObservationUnitRows(): Promise<ObservationUnitRecord[]> {
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits?page=0&pageSize=1000");
return response.result.data.map(mapObservationUnit);
}
export async function fetchObservationUnitOptions(force = false): Promise<{
studies: SelectOption[];
germplasms: SelectOption[];
}> {
const [studies, germplasms] = await Promise.all([
loadStudyOptions(force),
loadGermplasmOptions(force),
]);
return { studies, germplasms };
}
export async function createObservationUnitRow(payload: ObservationUnitPayload): Promise<ObservationUnitRecord> {
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", {
method: "POST",
body: JSON.stringify({
observationUnitDbId: requiredText(payload.id, "请填写观测单元 ID"),
...toRequestBody(payload),
}),
});
return mapObservationUnit(response.result.data[0]);
}
export async function updateObservationUnitRow(id: string, payload: ObservationUnitPayload): Promise<ObservationUnitRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("观测单元 ID 不支持直接修改,请删除后重新新增");
}
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", {
method: "PUT",
body: JSON.stringify({
observationUnitDbId: id,
...toRequestBody(payload),
}),
});
return mapObservationUnit(response.result.data[0]);
}

View File

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

View File

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

View File

@@ -0,0 +1,228 @@
import { getAuthToken } from "@/utils/token";
import { NONE_SELECT_VALUE, type ObservationVariableRecord, type SelectOption } from "./types";
interface BrapiPagination {
currentPage: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
interface BrapiListResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface BrapiSingleResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: T;
}
interface CropResponse {
id: string;
crop_name: string | null;
}
interface OntologyResponse {
ontologyDbId: string;
ontologyName: string | null;
ontology_name: string | null;
version: string | null;
}
interface TraitResponse {
traitDbId: string;
traitName: string | null;
trait_name: string | null;
}
interface MethodResponse {
methodDbId: string;
methodName: string | null;
name: string | null;
}
interface ScaleResponse {
scaleDbId: string;
scaleName: string | null;
scale_name: string | null;
units: string | null;
}
type ObservationVariablePayload = Partial<Record<
| "id"
| "name"
| "pui"
| "default_value"
| "documentationurl"
| "growth_stage"
| "institution"
| "language"
| "scientist"
| "status"
| "submission_timestamp"
| "crop_id"
| "ontology_id"
| "trait_id"
| "method_id"
| "scale_id",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`);
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const mapObservationVariable = (variable: ObservationVariableRecord): ObservationVariableRecord => ({
...variable,
id: variable.observationVariableDbId || variable.id,
name: variable.name || variable.observation_variable_name || variable.observationVariableName,
observation_variable_name: variable.observation_variable_name || variable.observationVariableName || variable.name,
pui: variable.pui || variable.observationVariablePUI,
default_value: variable.default_value || variable.defaultValue,
documentationurl: variable.documentationurl || variable.documentationURL,
growth_stage: variable.growth_stage || variable.growthStage,
submission_timestamp: variable.submission_timestamp || variable.submissionTimestamp,
crop_id: variable.crop_id || variable.cropDbId,
ontology_id: variable.ontology_id || variable.ontologyDbId,
ontology_name: variable.ontology_name || variable.ontologyName,
trait_id: variable.trait_id || variable.traitDbId,
trait_name: variable.trait_name || variable.traitName,
method_id: variable.method_id || variable.methodDbId,
method_name: variable.method_name || variable.methodName,
scale_id: variable.scale_id || variable.scaleDbId,
scale_name: variable.scale_name || variable.scaleName,
});
const toRequestBody = (payload: ObservationVariablePayload) => ({
observationVariableName: requiredText(payload.name, "请填写变量名称"),
pui: optionalText(payload.pui),
defaultValue: optionalText(payload.default_value),
documentationurl: optionalText(payload.documentationurl),
growthStage: optionalText(payload.growth_stage),
institution: optionalText(payload.institution),
language: optionalText(payload.language),
scientist: optionalText(payload.scientist),
status: optionalText(payload.status),
submissionTimestamp: optionalText(payload.submission_timestamp),
crop_id: optionalText(payload.crop_id),
ontology_id: optionalText(payload.ontology_id),
trait_id: requiredText(payload.trait_id, "请选择 Trait"),
method_id: requiredText(payload.method_id, "请选择 Method"),
scale_id: requiredText(payload.scale_id, "请选择 Scale"),
});
export async function fetchObservationVariableRows(): Promise<ObservationVariableRecord[]> {
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables?page=0&pageSize=1000");
return response.result.data.map(mapObservationVariable);
}
export async function fetchObservationVariableOptions(): Promise<{
crops: SelectOption[];
ontologies: SelectOption[];
traits: SelectOption[];
methods: SelectOption[];
scales: SelectOption[];
}> {
const [crops, ontologies, traits, methods, scales] = await Promise.all([
request<CropResponse[]>("/api/dictionaries/crops"),
request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=1000"),
request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"),
request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"),
request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"),
]);
return {
crops: crops.map((crop) => ({
value: crop.id,
label: crop.crop_name || crop.id,
})),
ontologies: ontologies.result.data.map((ontology) => ({
value: ontology.ontologyDbId,
label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`,
})),
traits: traits.result.data.map((trait) => ({
value: trait.traitDbId,
label: trait.traitName || trait.trait_name || trait.traitDbId,
})),
methods: methods.result.data.map((method) => ({
value: method.methodDbId,
label: method.methodName || method.name || method.methodDbId,
})),
scales: scales.result.data.map((scale) => ({
value: scale.scaleDbId,
label: `${scale.scaleName || scale.scale_name || scale.scaleDbId}${scale.units ? ` / ${scale.units}` : ""}`,
})),
};
}
export async function createObservationVariableRow(payload: ObservationVariablePayload): Promise<ObservationVariableRecord> {
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables", {
method: "POST",
body: JSON.stringify({
id: requiredText(payload.id, "请填写变量 ID"),
...toRequestBody(payload),
}),
});
return mapObservationVariable(response.result.data[0]);
}
export async function updateObservationVariableRow(id: string, payload: ObservationVariablePayload): Promise<ObservationVariableRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("变量 ID 不支持直接修改,请删除后重新新增");
}
const response = await request<BrapiSingleResponse<ObservationVariableRecord>>(`/brapi/v2/variables/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(toRequestBody(payload)),
});
return mapObservationVariable(response.result);
}
export async function deleteObservationVariableRow(id: string): Promise<void> {
await request<BrapiSingleResponse<ObservationVariableRecord>>(`/brapi/v2/variables/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}

View File

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

View File

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

View File

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