fix:sample/plate 之前的开发
This commit is contained in:
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user