diff --git a/frontend/src/app/(app)/phenotyping/event-image/api.ts b/frontend/src/app/(app)/phenotyping/event-image/api.ts index 25c7a05..9ac399a 100644 --- a/frontend/src/app/(app)/phenotyping/event-image/api.ts +++ b/frontend/src/app/(app)/phenotyping/event-image/api.ts @@ -168,9 +168,9 @@ const eventBody = (payload: EventPayload) => ({ }); const imageBody = (payload: ImagePayload) => ({ + imageURL: requiredText(payload.image_url, "请先上传图片,上传成功后系统会自动填充图片字段"), 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), diff --git a/frontend/src/app/(app)/phenotyping/event-image/components/ImageFormUpload.tsx b/frontend/src/app/(app)/phenotyping/event-image/components/ImageFormUpload.tsx index 96b8207..2293010 100644 --- a/frontend/src/app/(app)/phenotyping/event-image/components/ImageFormUpload.tsx +++ b/frontend/src/app/(app)/phenotyping/event-image/components/ImageFormUpload.tsx @@ -29,26 +29,17 @@ function formatFileDate(timestamp: number): string { function buildAutoFillPatch( image: UploadedImage, file: File, - current: Record, ): Record { - const baseName = stripExtension(image.filename); - const patch: Record = { + const baseName = stripExtension(image.filename).trim(); + const imageId = toImageId(baseName); + return { + id: imageId, + image_name: baseName || imageId, image_file_name: image.filename, image_url: image.url, mime_type: image.contentType, + image_time_stamp: formatFileDate(file.lastModified), }; - - 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 = { @@ -57,15 +48,22 @@ type ImageFormUploadProps = { updateFormBatch: (patch: Record) => void; }; -export function ImageFormUpload({ formData, updateForm, updateFormBatch }: ImageFormUploadProps) { +export function ImageFormUpload({ formData, updateFormBatch }: ImageFormUploadProps) { const imageUrl = String(formData.image_url ?? "").trim(); const handleUploaded = (image: UploadedImage, file: File) => { - updateFormBatch(buildAutoFillPatch(image, file, formData)); + updateFormBatch(buildAutoFillPatch(image, file)); }; const handleRemoved = () => { - updateForm("image_url", ""); + updateFormBatch({ + id: "", + image_name: "", + image_file_name: "", + image_url: "", + mime_type: "", + image_time_stamp: "", + }); }; return ( diff --git a/frontend/src/app/(app)/phenotyping/event-image/page.tsx b/frontend/src/app/(app)/phenotyping/event-image/page.tsx index f4a90a6..28f597f 100644 --- a/frontend/src/app/(app)/phenotyping/event-image/page.tsx +++ b/frontend/src/app/(app)/phenotyping/event-image/page.tsx @@ -32,6 +32,7 @@ const eventTypeOptions: SelectOption[] = [ const mimeTypeOptions: SelectOption[] = [ { value: "image/jpeg", label: "image/jpeg" }, + { value: "image/jpg", label: "image/jpg" }, { value: "image/png", label: "image/png" }, { value: "image/gif", label: "image/gif" }, { value: "image/webp", label: "image/webp" }, @@ -97,12 +98,12 @@ export default function EventImagePage() { ], [studyOptions]); const imageFields = useMemo(() => [ - { 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: "id", label: "Image ID", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动生成" }, + { key: "image_name", label: "Image Name", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动填写" }, + { key: "image_file_name", label: "File Name", type: "text", readOnly: true, placeholder: "上传图片后自动填写" }, + { key: "image_url", label: "Image URL", type: "text", required: true, readOnly: true, placeholder: "上传图片后自动填写", colSpan: 2 }, + { key: "mime_type", label: "MIME Type", type: "select", readOnly: true, options: mimeTypeOptions }, + { key: "image_time_stamp", label: "Image Date", type: "date", readOnly: true }, { key: "study_db_id", label: "Study", @@ -125,6 +126,14 @@ export default function EventImagePage() { { key: "description", label: "Description", type: "textarea", colSpan: 2 }, ], [observationOptions, observationUnitOptions, studyOptions]); + const validateImageForm = useCallback((payload: Record) => { + const imageUrl = String(payload.image_url ?? "").trim(); + if (!imageUrl) { + return "请先上传图片,上传成功后系统会自动填充带 * 的图片字段。"; + } + return null; + }, []); + return ( @@ -182,6 +191,7 @@ export default function EventImagePage() { 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) => } + validateForm={validateImageForm} createRecord={(payload) => createImageRow(payload) as unknown as Promise>} updateRecord={(id, payload) => updateImageRow(id, payload) as unknown as Promise>} deleteRecord={deleteImageRow} diff --git a/frontend/src/components/brapi/BrapiEntityPage.tsx b/frontend/src/components/brapi/BrapiEntityPage.tsx index 6e3d67c..9b18806 100644 --- a/frontend/src/components/brapi/BrapiEntityPage.tsx +++ b/frontend/src/components/brapi/BrapiEntityPage.tsx @@ -86,12 +86,14 @@ function YearFormField({ onFieldChange, placeholder, required, + readOnly, }: { fieldKey: string; value: unknown; onFieldChange: (key: string, value: string) => void; placeholder?: string; required?: boolean; + readOnly?: boolean; }) { const yearValue = useMemo(() => parseYearValue(value), [value]); const handleChange = useCallback( @@ -108,6 +110,7 @@ function YearFormField({ placeholder={placeholder ?? "请选择年份"} clearable={!required} required={required} + readOnly={readOnly} className="w-full" /> ); @@ -119,12 +122,14 @@ function DateFormField({ onFieldChange, placeholder, required, + readOnly, }: { fieldKey: string; value: unknown; onFieldChange: (key: string, value: string) => void; placeholder?: string; required?: boolean; + readOnly?: boolean; }) { const dateValue = useMemo(() => parseDateValue(value), [value]); const handleChange = useCallback( @@ -141,6 +146,7 @@ function DateFormField({ placeholder={placeholder ?? "请选择日期"} clearable={!required} required={required} + readOnly={readOnly} showToday className="w-full" /> @@ -186,6 +192,7 @@ interface BrapiEntityPageProps { createRecord?: (payload: Record) => Promise>; updateRecord?: (id: string, payload: Record) => Promise>; deleteRecord?: (id: string) => Promise; + validateForm?: (payload: Record, editingRow: Record | null) => string | null | undefined; renderQueryForm?: (props: { keyword: string; onKeywordChange: (value: string) => void }) => ReactNode; renderFormExtra?: (props: { formData: Record; @@ -214,6 +221,7 @@ export function BrapiEntityPage({ createRecord, updateRecord, deleteRecord, + validateForm, renderQueryForm, renderFormExtra, useEnhancedDialog = false, @@ -225,6 +233,7 @@ export function BrapiEntityPage({ const [open, setOpen] = useState(false); const [editingRow, setEditingRow] = useState | null>(null); const [formData, setFormData] = useState>({}); + const [formError, setFormError] = useState(null); const [search, setSearch] = useState(""); const [deletingRow, setDeletingRow] = useState | null>(null); const [saving, setSaving] = useState(false); @@ -313,6 +322,7 @@ export function BrapiEntityPage({ const openAdd = () => { setEditingRow(null); + setFormError(null); setFormData({ ...fields.reduce>((defaults, field) => { if (field.type === "year") { @@ -334,6 +344,7 @@ export function BrapiEntityPage({ const detail = await resolveRecord(row); setEditingRow(detail); setFormData({ ...detail }); + setFormError(null); setOpen(true); setError(null); } catch (event) { @@ -361,7 +372,15 @@ export function BrapiEntityPage({ }; const handleSave = async () => { + const validationError = validateForm?.(formData, editingRow); + if (validationError) { + setFormError(validationError); + setError(null); + return; + } + setSaving(true); + setFormError(null); try { if (editingRow) { const rowId = resolveRowId(editingRow); @@ -396,7 +415,9 @@ export function BrapiEntityPage({ setOpen(false); setError(null); } catch (event) { - setError(event instanceof Error ? event.message : "保存失败"); + const message = event instanceof Error ? event.message : "保存失败"; + setFormError(message); + setError(message); } finally { setSaving(false); } @@ -428,10 +449,12 @@ export function BrapiEntityPage({ }; const updateForm = useCallback((key: string, value: string) => { + setFormError(null); setFormData((current) => ({ ...current, [key]: value })); }, []); const updateFormBatch = useCallback((patch: Record) => { + setFormError(null); setFormData((current) => ({ ...current, ...patch })); }, []); @@ -462,6 +485,7 @@ export function BrapiEntityPage({ onFieldChange={updateForm} placeholder={field.placeholder} required={field.required} + readOnly={field.readOnly} /> ) : field.type === "date" ? ( ) : field.type === "select" ? (