From 50879a71da6d2b71930b589d2e4a9373c169ee8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BD=AD=E5=B8=85?= <616120679@qq.com> Date: Thu, 28 May 2026 16:53:53 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E7=AC=AC=E4=B8=89=E7=AB=A0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E7=BB=93=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 13 +- docs/dev/03-genotyping/08-callset.md | 4 + docs/dev/03-genotyping/12-marker_position.md | 4 + .../03-genotyping/13-callset_variant_sets.md | 4 + .../03-genotyping/14-variantset_analysis.md | 4 + .../dev/03-genotyping/15-variantset_format.md | 4 + .../src/app/(app)/genotyping/call-set/api.ts | 318 ++++++++++ .../call-set/components/AlleleCallTab.tsx | 182 ++++++ .../call-set/components/CallSetTab.tsx | 233 +++++++ .../app/(app)/genotyping/call-set/page.tsx | 56 ++ .../app/(app)/genotyping/call-set/types.ts | 57 ++ .../app/(app)/genotyping/genome-map/api.ts | 568 ++++++++++++++++++ .../genome-map/components/GenomeMapTab.tsx | 196 ++++++ .../components/LinkageGroupEntityPage.tsx | 221 +++++++ .../components/LinkageGroupPanel.tsx | 12 + .../genome-map/components/LinkageGroupTab.tsx | 104 ++++ .../components/MarkerPositionEntityPage.tsx | 222 +++++++ .../components/MarkerPositionPanel.tsx | 12 + .../components/MarkerPositionTab.tsx | 153 +++++ .../genotyping/genome-map/genomeMapUtils.ts | 15 + .../[linkageGroupDbId]/page.tsx | 129 ++++ .../genome-map/maps/[mapDbId]/page.tsx | 123 ++++ .../app/(app)/genotyping/genome-map/page.tsx | 71 +++ .../app/(app)/genotyping/genome-map/types.ts | 84 +++ .../app/(app)/genotyping/variant-set/api.ts | 180 ++++++ .../components/VariantSetAnalysisPanel.tsx | 115 ++++ .../components/VariantSetFormatPanel.tsx | 132 ++++ .../app/(app)/genotyping/variant-set/types.ts | 45 +- .../variant-sets/[variantSetDbId]/page.tsx | 228 ++++--- .../src/app/(app)/genotyping/variant/api.ts | 120 +--- .../components/VariantMarkerPositionCard.tsx | 116 ++++ .../src/app/(app)/genotyping/variant/page.tsx | 98 +-- .../variant/variants/[variantDbId]/page.tsx | 34 +- .../src/components/brapi/BrapiEntityPage.tsx | 36 +- frontend/src/components/brapi/navigation.ts | 11 +- frontend/src/constants/api.ts | 6 + frontend/src/constants/menu.ts | 6 +- .../GenotypingCallSetWriteController.java | 80 +++ .../geno/GenotypingCallWriteController.java | 105 ++++ .../GenotypingGenomeMapWriteController.java | 176 ++++++ .../GenotypingVariantWriteController.java | 141 ++++- .../model/dto/geno/CallSetWriteRequest.java | 42 ++ .../model/dto/geno/CallWriteRequest.java | 67 +++ .../model/dto/geno/GenomeMapWriteRequest.java | 94 +++ .../dto/geno/LinkageGroupWriteRequest.java | 31 + .../dto/geno/MarkerPositionWriteRequest.java | 40 ++ .../geno/VariantSetAnalysisListResponse.java | 54 ++ .../geno/VariantSetAnalysisWriteRequest.java | 78 +++ ...VariantSetAvailableFormatListResponse.java | 55 ++ .../geno/VariantSetAvailableFormatRecord.java | 85 +++ ...VariantSetAvailableFormatWriteRequest.java | 85 +++ .../repository/geno/CallRepository.java | 8 +- .../geno/VariantSetAnalysisRepository.java | 10 + .../VariantSetAvailableFormatRepository.java | 10 + .../service/geno/CallService.java | 147 ++++- .../service/geno/CallSetService.java | 115 +++- .../service/geno/GenomeMapService.java | 202 ++++++- .../service/geno/MarkerPositionService.java | 96 ++- .../geno/VariantSetAnalysisService.java | 134 +++++ .../VariantSetAvailableFormatService.java | 148 +++++ 60 files changed, 5558 insertions(+), 361 deletions(-) create mode 100644 frontend/src/app/(app)/genotyping/call-set/api.ts create mode 100644 frontend/src/app/(app)/genotyping/call-set/components/AlleleCallTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/call-set/components/CallSetTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/call-set/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/call-set/types.ts create mode 100644 frontend/src/app/(app)/genotyping/genome-map/api.ts create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupEntityPage.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupPanel.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionEntityPage.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionPanel.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/genomeMapUtils.ts create mode 100644 frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/linkage-groups/[linkageGroupDbId]/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/genome-map/types.ts create mode 100644 frontend/src/app/(app)/genotyping/variant-set/components/VariantSetAnalysisPanel.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant-set/components/VariantSetFormatPanel.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant/components/VariantMarkerPositionCard.tsx create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingCallSetWriteController.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingCallWriteController.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingGenomeMapWriteController.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/CallSetWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/CallWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/GenomeMapWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/LinkageGroupWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/MarkerPositionWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetAnalysisListResponse.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetAnalysisWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetAvailableFormatListResponse.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetAvailableFormatRecord.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetAvailableFormatWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantSetAnalysisRepository.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantSetAvailableFormatRepository.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetAnalysisService.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetAvailableFormatService.java diff --git a/AGENTS.md b/AGENTS.md index dd858bf..668ca6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,4 +2,15 @@ 2.一般我只会让你加前端,或者加一些删除等简单的后端接口,千万不要破坏原接口的路径,入参定义,出参定义。 3.下拉框的数据要缓存下来,同一选项源不要重复请求接口。 4.前端首页(及同类入口页)加载首页数据时,副作用只触发一次查询,避免 Strict Mode 或重复 mount 导致多次请求。 -5.保存、提交类操作要做防抖(debounce),防止连续点击重复提交。 \ No newline at end of file +5.保存、提交类操作要做防抖(debounce),防止连续点击重复提交。 +6.每完成一份 `docs/dev/**` 下的开发文档对应功能(页面、接口、校验等与文档要求一致)后,在该文档末尾追加完成标注,格式如下: + +```markdown +--- + +**状态:已完成** +``` + +- 仅在该文档描述的功能全部落地时标注;部分实现(如仅后端、缺页面或缺校验)不要标注。 +- 若文档末尾已有「状态:已完成」,不要重复追加。 +- `docs/dev/**/README.md` 等索引/说明类文档无需标注。 \ No newline at end of file diff --git a/docs/dev/03-genotyping/08-callset.md b/docs/dev/03-genotyping/08-callset.md index c4097fb..810a477 100644 --- a/docs/dev/03-genotyping/08-callset.md +++ b/docs/dev/03-genotyping/08-callset.md @@ -38,3 +38,7 @@ 1. `sample_id` 必须存在。 2. 删除 callset 前检查 `allele_call` 和 `callset_variant_sets`。 3. 如果 callset 绑定多个 variantset,查询和导出时要明确当前 variantset 范围。 + +--- + +**状态:已完成** diff --git a/docs/dev/03-genotyping/12-marker_position.md b/docs/dev/03-genotyping/12-marker_position.md index a0f2307..f40041a 100644 --- a/docs/dev/03-genotyping/12-marker_position.md +++ b/docs/dev/03-genotyping/12-marker_position.md @@ -37,3 +37,7 @@ 1. `linkage_group_id` 和 `variant_id` 必须存在。 2. 同一 linkage group 下同一 variant 不应重复。 3. `position` 不应超过 linkage group 的 `max_marker_position`。 + +--- + +**状态:已完成** diff --git a/docs/dev/03-genotyping/13-callset_variant_sets.md b/docs/dev/03-genotyping/13-callset_variant_sets.md index 5d60b2e..90b01f5 100644 --- a/docs/dev/03-genotyping/13-callset_variant_sets.md +++ b/docs/dev/03-genotyping/13-callset_variant_sets.md @@ -32,3 +32,7 @@ 1. `call_sets_id` 和 `variant_sets_id` 必须存在。 2. 同一 callset 与 variantset 关系不应重复。 3. 删除关系不应删除 callset 或 variantset 主数据。 + +--- + +**状态:已完成** diff --git a/docs/dev/03-genotyping/14-variantset_analysis.md b/docs/dev/03-genotyping/14-variantset_analysis.md index 90f4f9e..d2918f3 100644 --- a/docs/dev/03-genotyping/14-variantset_analysis.md +++ b/docs/dev/03-genotyping/14-variantset_analysis.md @@ -43,3 +43,7 @@ 1. `variant_set_id` 必须存在。 2. 删除 variantset 时需要先处理或级联处理 analysis。 3. `software` 如果是 URL,前端可做 URL 格式提示。 + +--- + +**状态:已完成** diff --git a/docs/dev/03-genotyping/15-variantset_format.md b/docs/dev/03-genotyping/15-variantset_format.md index 8bade35..e4748d8 100644 --- a/docs/dev/03-genotyping/15-variantset_format.md +++ b/docs/dev/03-genotyping/15-variantset_format.md @@ -40,3 +40,7 @@ 1. `variant_set_id` 必须存在。 2. `fileurl` 如填写需通过 URL 格式校验。 3. 对矩阵格式,`sep_phased/sep_unphased/unknown_string` 会影响解析,应在导入预览时展示。 + +--- + +**状态:已完成** diff --git a/frontend/src/app/(app)/genotyping/call-set/api.ts b/frontend/src/app/(app)/genotyping/call-set/api.ts new file mode 100644 index 0000000..cb09af1 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/call-set/api.ts @@ -0,0 +1,318 @@ +import { createCachedLoader } from "@/services/dropdownCache"; +import { getAuthToken } from "@/utils/token"; +import { + NONE_SELECT_VALUE, + type AlleleCallQuery, + type AlleleCallRecord, + type CallSetDetail, + type CallSetQuery, + type CallSetRecord, + type SelectOption, +} from "./types"; + +interface BrapiPagination { + currentPage: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +interface BrapiListResponse { + metadata: { + pagination: BrapiPagination; + status: Array>; + datafiles: Array>; + }; + result: { + data: T[]; + }; +} + +interface BrapiSingleResponse { + metadata: { + pagination: BrapiPagination; + status: Array>; + datafiles: Array>; + }; + result: T; +} + +interface SampleResponse { + sampleDbId: string; + sampleName: string | null; +} + +interface VariantSetLookup { + value: string; + label: string; +} + +type CallSetPayload = Partial>; + +type AlleleCallPayload = Partial>; + +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(path: string, init?: RequestInit): Promise { + 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; +} + +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 normalizeVariantSetIds = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item ?? "").trim()) + .filter(Boolean); +}; + +const sampleLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/samples?page=0&pageSize=10"); + return response.result.data.map((item) => ({ + value: item.sampleDbId, + label: item.sampleName || item.sampleDbId, + })); +}); + +const variantSetLoader = createCachedLoader(async () => { + const response = await request>( + "/brapi/v2/variantsets?page=0&pageSize=10", + ); + return response.result.data.map((item) => ({ + value: item.variantSetDbId, + label: item.variantSetName || item.variantSetDbId, + })); +}); + +export function invalidateCallSetPageCache() { + sampleLoader.invalidate(); + variantSetLoader.invalidate(); +} + +const mapCallSet = ( + item: CallSetRecord, + samples: SelectOption[], + variantSets: VariantSetLookup[], +): CallSetRecord => { + const sampleDbId = item.sampleDbId || item.sample_id || null; + const sample = samples.find((entry) => entry.value === sampleDbId); + const variantSetIds = normalizeVariantSetIds(item.variantSetDbIds ?? item.variant_set_ids); + const variantSetNames = variantSetIds + .map((id) => variantSets.find((entry) => entry.value === id)?.label || id) + .join("、"); + + return { + ...item, + id: item.callSetDbId || item.id, + call_set_name: item.call_set_name || item.callSetName || null, + sample_id: sampleDbId, + sample_name: item.sample_name || item.sampleName || sample?.label || null, + variant_set_ids: variantSetIds, + variant_set_names: variantSetNames || "—", + }; +}; + +const filterCallSetRows = ( + rows: CallSetRecord[], + query: CallSetQuery | undefined, + samples: SelectOption[], + variantSets: VariantSetLookup[], +): CallSetRecord[] => { + const nameFilter = String(query?.call_set_name ?? "").trim().toLowerCase(); + const sampleId = optionalText(query?.sample_id); + const variantSetId = optionalText(query?.variant_set_id); + + return rows + .map((item) => mapCallSet(item, samples, variantSets)) + .filter((item) => { + if (nameFilter && !String(item.call_set_name ?? "").toLowerCase().includes(nameFilter)) return false; + if (sampleId && item.sample_id !== sampleId) return false; + if (variantSetId && !(item.variant_set_ids ?? []).includes(variantSetId)) return false; + return true; + }); +}; + +const callSetBody = (payload: CallSetPayload) => ({ + callSetName: requiredText(payload.call_set_name, "CallSet 名称不能为空"), + sampleDbId: requiredText(payload.sample_id, "Sample 不能为空"), + variantSetDbIds: normalizeVariantSetIds(payload.variant_set_ids), +}); + +export const normalizeCallSetFormData = (row: CallSetRecord) => ({ + id: row.id, + call_set_name: row.call_set_name || row.callSetName || "", + sample_id: row.sample_id && row.sample_id !== NONE_SELECT_VALUE ? row.sample_id : NONE_SELECT_VALUE, + variant_set_ids: normalizeVariantSetIds(row.variant_set_ids ?? row.variantSetDbIds), +}); + +export async function loadCallSetPageData(params: { + query?: CallSetQuery; + force?: boolean; +} = {}): Promise<{ + options: { samples: SelectOption[]; variantSets: SelectOption[] }; + rows: CallSetRecord[]; +}> { + const force = params.force ?? false; + const [samples, variantSets] = await Promise.all([ + sampleLoader.load(force), + variantSetLoader.load(force), + ]); + + const searchParams = new URLSearchParams({ page: "0", pageSize: "10" }); + const sampleId = optionalText(params.query?.sample_id); + const variantSetId = optionalText(params.query?.variant_set_id); + if (sampleId) searchParams.set("sampleDbId", sampleId); + if (variantSetId) searchParams.set("variantSetDbId", variantSetId); + + const response = await request>( + `/brapi/v2/callsets?${searchParams.toString()}`, + ); + + return { + options: { samples, variantSets }, + rows: filterCallSetRows(response.result.data, params.query, samples, variantSets), + }; +} + +export async function fetchCallSetDetail(callSetDbId: string): Promise { + const [detail, samples, variantSets] = await Promise.all([ + request>(`/brapi/v2/callsets/${encodeURIComponent(callSetDbId)}`), + sampleLoader.load(), + variantSetLoader.load(), + ]); + return mapCallSet(detail.result, samples, variantSets) as CallSetDetail; +} + +export async function createCallSetRow(payload: CallSetPayload): Promise { + const response = await request>("/brapi/v2/callsets", { + method: "POST", + body: JSON.stringify({ + callSetDbId: optionalText(payload.id), + ...callSetBody(payload), + }), + }); + invalidateCallSetPageCache(); + const [samples, variantSets] = await Promise.all([ + sampleLoader.load(true), + variantSetLoader.load(true), + ]); + return mapCallSet(response.result.data[0], samples, variantSets); +} + +export async function updateCallSetRow(id: string, payload: CallSetPayload): Promise { + const requestedId = optionalText(payload.id); + if (requestedId && requestedId !== id) { + throw new Error("CallSet ID 不可修改,请新建记录"); + } + const response = await request>( + `/brapi/v2/callsets/${encodeURIComponent(id)}`, + { + method: "PUT", + body: JSON.stringify(callSetBody(payload)), + }, + ); + invalidateCallSetPageCache(); + const [samples, variantSets] = await Promise.all([ + sampleLoader.load(true), + variantSetLoader.load(true), + ]); + return mapCallSet(response.result, samples, variantSets); +} + +export async function deleteCallSetRow(id: string): Promise { + await request(`/brapi/v2/callsets/${encodeURIComponent(id)}`, { method: "DELETE" }); + invalidateCallSetPageCache(); +} + +const mapAlleleCall = (item: AlleleCallRecord & { additionalInfo?: Record }): AlleleCallRecord => ({ + ...item, + id: String(item.additionalInfo?.callDbId ?? item.callDbId ?? item.id ?? ""), + call_set_id: item.callSetDbId || item.call_set_id || null, + call_set_name: item.callSetName || item.call_set_name || null, + variant_id: item.variantDbId || item.variant_id || null, + variant_name: item.variantName || item.variant_name || null, + variant_set_id: item.variantSetDbId || item.variant_set_id || null, + genotype: item.genotypeValue || item.genotype || null, +}); + +const filterAlleleCallRows = (rows: AlleleCallRecord[], query?: AlleleCallQuery) => { + const callSetId = optionalText(query?.call_set_id); + const variantId = optionalText(query?.variant_id); + const variantSetId = optionalText(query?.variant_set_id); + + return rows + .map(mapAlleleCall) + .filter((item) => { + if (callSetId && item.call_set_id !== callSetId) return false; + if (variantId && item.variant_id !== variantId) return false; + if (variantSetId && item.variant_set_id !== variantSetId) return false; + return true; + }); +}; + +export async function loadAlleleCallRows(query?: AlleleCallQuery): Promise { + const searchParams = new URLSearchParams({ pageSize: "10" }); + const callSetId = optionalText(query?.call_set_id); + const variantId = optionalText(query?.variant_id); + const variantSetId = optionalText(query?.variant_set_id); + if (callSetId) searchParams.set("callSetDbId", callSetId); + if (variantId) searchParams.set("variantDbId", variantId); + if (variantSetId) searchParams.set("variantSetDbId", variantSetId); + + const response = await request>( + `/brapi/v2/calls?${searchParams.toString()}`, + ); + return filterAlleleCallRows(response.result.data, query); +} + +export async function importAlleleCallRows(payloads: AlleleCallPayload[]): Promise { + const body = payloads.map((payload) => ({ + callDbId: optionalText((payload as Record).id), + callSetDbId: requiredText(payload.call_set_id, "CallSet 不能为空"), + variantDbId: requiredText(payload.variant_id, "Variant 不能为空"), + genotype: requiredText(payload.genotype, "Genotype 不能为空"), + readDepth: payload.read_depth === null || payload.read_depth === undefined || payload.read_depth === "" + ? null + : Number(payload.read_depth), + phaseSet: optionalText(payload.phase_set), + })); + + const response = await request>("/brapi/v2/calls/import", { + method: "POST", + body: JSON.stringify(body), + }); + return response.result.data.map(mapAlleleCall); +} diff --git a/frontend/src/app/(app)/genotyping/call-set/components/AlleleCallTab.tsx b/frontend/src/app/(app)/genotyping/call-set/components/AlleleCallTab.tsx new file mode 100644 index 0000000..b70e1fb --- /dev/null +++ b/frontend/src/app/(app)/genotyping/call-set/components/AlleleCallTab.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Dna, RotateCcw, Search } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { loadAlleleCallRows, loadCallSetPageData } from "../api"; +import { NONE_SELECT_VALUE, type AlleleCallQuery, type AlleleCallRecord, type SelectOption } from "../types"; + +const emptyQuery = (): AlleleCallQuery => ({ + call_set_id: NONE_SELECT_VALUE, + variant_id: NONE_SELECT_VALUE, + variant_set_id: NONE_SELECT_VALUE, +}); + +export function AlleleCallTab() { + const searchParams = useSearchParams(); + const [callSetOptions, setCallSetOptions] = useState([]); + const [variantSetOptions, setVariantSetOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE, + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...emptyQuery(), + variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE, + })); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + loadCallSetPageData() + .then(({ options, rows: callSetRows }) => { + if (!mounted) return; + setCallSetOptions(callSetRows.map((item) => ({ + value: String(item.id ?? item.callSetDbId ?? ""), + label: String(item.call_set_name ?? item.callSetName ?? item.id ?? ""), + })).filter((item) => item.value)); + setVariantSetOptions(options.variantSets); + }) + .catch(() => { + if (!mounted) return; + }); + return () => { mounted = false; }; + }, []); + + const loadRows = useCallback(async () => { + return loadAlleleCallRows(appliedQuery); + }, [appliedQuery]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + loadRows() + .then((data) => { + if (!mounted) return; + setRows(data); + }) + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载 allele_call 失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [loadRows]); + + const hint = useMemo(() => { + if (appliedQuery.variant_id && appliedQuery.variant_id !== NONE_SELECT_VALUE) { + return "按 Variant 过滤查看该位点下的 genotype 调用结果。"; + } + return "allele_call 通常通过 VCF/HapMap/矩阵导入;导入时会自动绑定 callset 与 variantset 关系。"; + }, [appliedQuery.variant_id]); + + return ( +
+
+
+ +
+

allele_call 调用结果

+

{hint}

+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ {loading ? ( +
+ ) : rows.length === 0 ? ( +

暂无 allele_call 记录。

+ ) : ( + + + + CallSet + Variant + VariantSet + Genotype + Read Depth + + + + {rows.map((row) => ( + + {row.call_set_name || row.call_set_id || "—"} + {row.variant_name || row.variant_id || "—"} + + {row.variant_set_id || "—"} + + {row.genotype || "—"} + {row.read_depth ?? "—"} + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/call-set/components/CallSetTab.tsx b/frontend/src/app/(app)/genotyping/call-set/components/CallSetTab.tsx new file mode 100644 index 0000000..caf4507 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/call-set/components/CallSetTab.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Binary, RotateCcw, Search } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createCallSetRow, + deleteCallSetRow, + fetchCallSetDetail, + loadCallSetPageData, + normalizeCallSetFormData, + updateCallSetRow, +} from "../api"; +import { NONE_SELECT_VALUE, type CallSetQuery, type SelectOption } from "../types"; + +const emptyQuery = (): CallSetQuery => ({ + call_set_name: "", + sample_id: NONE_SELECT_VALUE, + variant_set_id: NONE_SELECT_VALUE, +}); + +const optionOrNone = (label: string, options: SelectOption[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +function normalizeSelectedVariantSetIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => String(item ?? "").trim()).filter(Boolean); +} + +export function CallSetTab() { + const searchParams = useSearchParams(); + const [sampleOptions, setSampleOptions] = useState([]); + const [variantSetOptions, setVariantSetOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); + + const urlDefaultFormValues = useMemo(() => { + const sampleId = searchParams.get("sample_id"); + if (!sampleId) return undefined; + return { sample_id: sampleId }; + }, [searchParams]); + + const loadRows = useCallback(async () => { + const { options, rows } = await loadCallSetPageData({ query: appliedQuery }); + setSampleOptions(options.samples); + setVariantSetOptions(options.variantSets); + return rows as unknown as Record[]; + }, [appliedQuery]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchCallSetDetail(id); + return normalizeCallSetFormData(detail); + }, []); + + const fields = useMemo(() => [ + { key: "id", label: "CallSet ID", type: "text", placeholder: "留空则系统自动生成(导入时可指定)" }, + { + key: "call_set_name", + label: "CallSet 名称", + type: "text", + required: true, + placeholder: "如 sample01_variantset1", + }, + { + key: "sample_id", + label: "Sample", + type: "select", + required: true, + options: optionOrNone("请选择 Sample", sampleOptions), + }, + ], [sampleOptions]); + + const renderFormExtra = useCallback(({ formData, updateFormBatch }: { + formData: Record; + updateForm: (key: string, value: string) => void; + updateFormBatch: (patch: Record) => void; + editingRow: Record | null; + }) => { + const selectedIds = normalizeSelectedVariantSetIds(formData.variant_set_ids); + + const toggleVariantSet = (variantSetId: string, checked: boolean) => { + const next = checked + ? Array.from(new Set([...selectedIds, variantSetId])) + : selectedIds.filter((id) => id !== variantSetId); + updateFormBatch({ variant_set_ids: next }); + }; + + return ( + <> +
+ +
+ {variantSetOptions.length === 0 ? ( +

暂无可选 VariantSet,请先在 VariantSet 页面创建。

+ ) : ( + variantSetOptions.map((option) => { + const checked = selectedIds.includes(option.value); + return ( + + ); + }) + )} +
+

+ 创建或编辑 CallSet 时绑定 VariantSet;导入 allele_call 时也会自动写入 callset_variant_sets 关系。 +

+
+
+

callset_variant_sets

+

+ 此处勾选会写入 callset 与 variantset 的多对多关系,无需单独维护关系表。 +

+
+ + ); + }, [variantSetOptions]); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, call_set_name: event.target.value }))} + placeholder="callSetName 模糊匹配" + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, sampleOptions, variantSetOptions]); + + return ( + ( + + {String(value ?? "—")} + + ), + }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/callsets", + value: "BrAPI", + className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200", + }]} + loadData={loadRows} + fetchRecord={fetchRecord} + createRecord={(payload) => createCallSetRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateCallSetRow(id, payload) as unknown as Promise>} + deleteRecord={deleteCallSetRow} + renderQueryForm={renderQueryForm} + renderFormExtra={renderFormExtra} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/call-set/page.tsx b/frontend/src/app/(app)/genotyping/call-set/page.tsx new file mode 100644 index 0000000..91a7843 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/call-set/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Binary, Dna } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AlleleCallTab } from "./components/AlleleCallTab"; +import { CallSetTab } from "./components/CallSetTab"; + +function TabFallback() { + return ; +} + +export default function CallSetPage() { + const searchParams = useSearchParams(); + const [tab, setTab] = useState("callsets"); + + useEffect(() => { + const requested = searchParams.get("tab"); + if (requested === "allele-calls") { + setTab("allele-calls"); + } + }, [searchParams]); + + return ( + + + + + CallSet + + + + allele_call + + + + {tab === "callsets" ? ( + + }> + + + + ) : null} + + {tab === "allele-calls" ? ( + + }> + + + + ) : null} + + ); +} diff --git a/frontend/src/app/(app)/genotyping/call-set/types.ts b/frontend/src/app/(app)/genotyping/call-set/types.ts new file mode 100644 index 0000000..c50f691 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/call-set/types.ts @@ -0,0 +1,57 @@ +export const NONE_SELECT_VALUE = "__none__"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface CallSetQuery { + call_set_name?: string; + sample_id?: string; + variant_set_id?: string; +} + +export interface CallSetRecord { + id?: string; + callSetDbId?: string; + call_set_name?: string | null; + callSetName?: string | null; + sample_id?: string | null; + sampleDbId?: string | null; + sample_name?: string | null; + sampleName?: string | null; + variant_set_ids?: string[]; + variantSetDbIds?: string[] | null; + variant_set_names?: string; + created?: string | null; + updated?: string | null; +} + +export interface CallSetDetail extends CallSetRecord { + variant_set_ids: string[]; +} + +export interface AlleleCallQuery { + call_set_id?: string; + variant_id?: string; + variant_set_id?: string; +} + +export interface AlleleCallRecord { + id?: string; + callDbId?: string; + call_set_id?: string | null; + callSetDbId?: string | null; + call_set_name?: string | null; + callSetName?: string | null; + variant_id?: string | null; + variantDbId?: string | null; + variant_name?: string | null; + variantName?: string | null; + variant_set_id?: string | null; + variantSetDbId?: string | null; + genotype?: string | null; + genotypeValue?: string | null; + read_depth?: number | null; + phase_set?: string | null; +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/api.ts b/frontend/src/app/(app)/genotyping/genome-map/api.ts new file mode 100644 index 0000000..d2fe293 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/api.ts @@ -0,0 +1,568 @@ +import { DEFAULT_LIST_PAGE_SIZE, DEFAULT_PAGE_QUERY } from "@/constants/api"; +import { createCachedLoader, loadCommonCropNameOptions, type SelectOption as CachedSelectOption } from "@/services/dropdownCache"; +import { getAuthToken } from "@/utils/token"; +import { readAdditionalInfoString } from "./genomeMapUtils"; +import { + NONE_SELECT_VALUE, + type GenomeMapDetail, + type GenomeMapQuery, + type GenomeMapRecord, + type LinkageGroupQuery, + type LinkageGroupRecord, + type MarkerPositionQuery, + type MarkerPositionRecord, + type SelectOption, +} from "./types"; + +interface BrapiPagination { + currentPage: number; + pageSize: number; + totalCount: number; + totalPages: number; +} + +interface BrapiListResponse { + metadata: { + pagination: BrapiPagination; + status: Array>; + datafiles: Array>; + }; + result: { + data: T[]; + }; +} + +interface BrapiSingleResponse { + metadata: { + pagination: BrapiPagination; + status: Array>; + datafiles: Array>; + }; + result: T; +} + +type GenomeMapPayload = Partial>; + +type LinkageGroupPayload = Partial>; + +type MarkerPositionPayload = Partial>; + +interface VariantLookup { + variantDbId?: string; + variantId?: string; + id?: string; + variantNames?: string[] | null; + variantName?: string | null; +} + +const readVariantLabel = (item: VariantLookup) => + item.variantNames?.[0] || item.variantName || item.variantDbId || item.variantId || item.id || ""; + +const toValidSelectOptions = (options: SelectOption[]) => + options.filter((option) => Boolean(String(option.value ?? "").trim())); + +const mapVariantOptions = (rows: VariantLookup[]): SelectOption[] => + toValidSelectOptions(rows.map((item) => { + const value = String(item.variantDbId ?? item.variantId ?? item.id ?? "").trim(); + return { value, label: readVariantLabel(item) || value }; + }).filter((item) => item.value)); + +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(path: string, init?: RequestInit): Promise { + 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}`); + } + if (response.status === 204) { + return undefined as T; + } + return response.json() as Promise; +} + +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) => { + if (value === null || value === undefined || value === "") return null; + const parsed = Number(value); + if (Number.isNaN(parsed)) throw new Error("请输入有效数字"); + return parsed; +}; + +const requiredNumber = (value: unknown, message: string) => { + const parsed = optionalNumber(value); + if (parsed === null) throw new Error(message); + return parsed; +}; + +const mapGenomeMap = (item: GenomeMapRecord): GenomeMapRecord => ({ + ...item, + id: item.mapDbId || item.id, + map_name: item.map_name ?? item.mapName ?? null, + map_pui: item.map_pui ?? item.mapPUI ?? null, + common_crop_name: item.common_crop_name ?? item.commonCropName ?? null, + scientific_name: item.scientific_name ?? item.scientificName ?? null, + documentation_url: item.documentation_url ?? item.documentationURL ?? null, + published_date: item.published_date ?? (item.publishedDate ? String(item.publishedDate).slice(0, 10) : null), + linkage_group_count: item.linkage_group_count ?? item.linkageGroupCount ?? null, + marker_count: item.marker_count ?? item.markerCount ?? null, +}); + +const mapLinkageGroup = (item: Record): LinkageGroupRecord => { + const id = ( + readAdditionalInfoString(item, "linkageGroupDbId") + || String(item.linkageGroupDbId ?? item.id ?? "") + ).trim(); + const mapDbId = readAdditionalInfoString(item, "mapDbId") || String(item.mapDbId ?? item.map_db_id ?? "").trim(); + return { + ...item, + id, + linkage_group_db_id: id, + linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""), + max_position: (item.maxPosition ?? item.max_position) as number | null, + marker_count: (item.markerCount ?? item.marker_count) as number | null, + map_db_id: mapDbId || undefined, + }; +}; + +const mapMarkerPosition = (item: Record): MarkerPositionRecord => { + const id = readAdditionalInfoString(item, "markerPositionDbId") + || String(item.markerPositionDbId ?? item.id ?? ""); + return { + ...item, + id, + marker_position_db_id: id, + linkage_group_db_id: readAdditionalInfoString(item, "linkageGroupDbId") ?? undefined, + linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""), + variant_db_id: String(item.variantDbId ?? item.variant_db_id ?? ""), + variant_name: String(item.variantName ?? item.variant_name ?? ""), + position: (item.position ?? null) as number | null, + map_db_id: String(item.mapDbId ?? item.map_db_id ?? ""), + map_name: String(item.mapName ?? item.map_name ?? ""), + }; +}; + +const genomeMapBody = (payload: GenomeMapPayload) => ({ + mapDbId: optionalText(payload.id), + mapName: requiredText(payload.map_name, "图谱名称不能为空"), + commonCropName: optionalText(payload.common_crop_name), + scientificName: optionalText(payload.scientific_name), + type: optionalText(payload.type), + unit: optionalText(payload.unit), + comments: optionalText(payload.comments), + documentationURL: optionalText(payload.documentation_url), + publishedDate: optionalText(payload.published_date), +}); + +const linkageGroupBody = (payload: LinkageGroupPayload) => ({ + linkageGroupName: requiredText(payload.linkage_group_name, "连锁群名称不能为空"), + maxPosition: optionalNumber(payload.max_position), +}); + +const markerPositionBody = (payload: MarkerPositionPayload) => ({ + markerPositionDbId: optionalText(payload.id), + linkageGroupDbId: requiredText(payload.linkage_group_id, "请选择连锁群"), + variantDbId: requiredText(payload.variant_id, "请选择 Variant"), + position: requiredNumber(payload.position, "位置不能为空"), +}); + +export const normalizeGenomeMapFormData = (row: GenomeMapRecord) => ({ + id: row.id, + map_name: row.map_name || "", + map_pui: row.map_pui || "", + common_crop_name: row.common_crop_name && row.common_crop_name !== NONE_SELECT_VALUE + ? row.common_crop_name + : NONE_SELECT_VALUE, + scientific_name: row.scientific_name || "", + type: row.type || "", + unit: row.unit || "", + comments: row.comments || "", + documentation_url: row.documentation_url || "", + published_date: row.published_date || "", +}); + +export const normalizeLinkageGroupFormData = (row: LinkageGroupRecord, includeMap = false) => ({ + id: row.id, + linkage_group_name: row.linkage_group_name || "", + max_position: row.max_position ?? "", + ...(includeMap ? { + map_db_id: row.map_db_id && row.map_db_id !== NONE_SELECT_VALUE + ? row.map_db_id + : NONE_SELECT_VALUE, + } : {}), +}); + +export const normalizeMarkerPositionFormData = (row: MarkerPositionRecord) => ({ + id: row.id, + linkage_group_id: row.linkage_group_db_id && row.linkage_group_db_id !== NONE_SELECT_VALUE + ? row.linkage_group_db_id + : NONE_SELECT_VALUE, + variant_id: row.variant_db_id && row.variant_db_id !== NONE_SELECT_VALUE + ? row.variant_db_id + : NONE_SELECT_VALUE, + position: row.position ?? "", +}); + +const mapListLoader = createCachedLoader(async () => { + const response = await request>(`/brapi/v2/maps?${DEFAULT_PAGE_QUERY}`); + return response.result.data.map(mapGenomeMap); +}); + +const allLinkageGroupsLoader = createCachedLoader(async () => { + const maps = await mapListLoader.load(); + if (maps.length === 0) return []; + const batches = await Promise.all( + maps.map(async (map) => { + try { + const rows = await fetchLinkageGroupRows(map.id); + return rows + .filter((row) => row.id) + .map((row) => ({ + ...row, + map_db_id: row.map_db_id || map.id, + map_name: map.map_name || map.id, + })); + } catch { + return []; + } + }), + ); + return batches.flat(); +}); + +const variantListLoader = createCachedLoader(async () => { + const response = await request>( + `/brapi/v2/variants?pageToken=0&pageSize=${DEFAULT_LIST_PAGE_SIZE}`, + ); + return mapVariantOptions(response.result?.data ?? []); +}); + +export function invalidateGenomeMapPageCache() { + mapListLoader.invalidate(); + allLinkageGroupsLoader.invalidate(); + variantListLoader.invalidate(); +} + +const filterLinkageGroupRows = (rows: LinkageGroupRecord[], query?: LinkageGroupQuery) => { + const nameFilter = String(query?.linkage_group_name ?? "").trim().toLowerCase(); + const mapFilter = optionalText(query?.map_db_id); + return rows.filter((row) => { + if (nameFilter && !String(row.linkage_group_name ?? "").toLowerCase().includes(nameFilter)) return false; + if (mapFilter && row.map_db_id !== mapFilter) return false; + return true; + }); +}; + +export async function loadLinkageGroupPageData(params: { + query?: LinkageGroupQuery; + force?: boolean; +} = {}) { + const [maps, linkageGroups] = await Promise.all([ + mapListLoader.load(params.force), + allLinkageGroupsLoader.load(params.force), + ]); + const mapOptions: SelectOption[] = maps.map((map) => ({ + value: map.id, + label: map.map_name || map.id, + })); + return { + options: { maps: mapOptions }, + rows: filterLinkageGroupRows(linkageGroups, params.query), + }; +} + +const filterMapRows = (rows: GenomeMapRecord[], query?: GenomeMapQuery) => { + const nameFilter = String(query?.map_name ?? "").trim().toLowerCase(); + const cropFilter = optionalText(query?.common_crop_name); + const typeFilter = optionalText(query?.type); + return rows.filter((row) => { + if (nameFilter && !String(row.map_name ?? "").toLowerCase().includes(nameFilter)) return false; + if (cropFilter && row.common_crop_name !== cropFilter) return false; + if (typeFilter && row.type !== typeFilter) return false; + return true; + }); +}; + +export async function loadGenomeMapPageData(params: { + query?: GenomeMapQuery; + force?: boolean; +} = {}) { + const [maps, cropOptions] = await Promise.all([ + mapListLoader.load(params.force), + loadCommonCropNameOptions(params.force), + ]); + const cropSelectOptions: SelectOption[] = cropOptions.map((item: CachedSelectOption) => ({ + value: item.value, + label: item.label, + })); + return { + options: { crops: cropSelectOptions }, + rows: filterMapRows(maps, params.query), + }; +} + +export async function fetchGenomeMapDetail(mapDbId: string): Promise { + const [mapResponse, linkageResponse, markerResponse] = await Promise.all([ + request>(`/brapi/v2/maps/${encodeURIComponent(mapDbId)}`), + request>>( + `/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`, + ), + request>>( + `/brapi/v2/markerpositions?mapDbId=${encodeURIComponent(mapDbId)}&${DEFAULT_PAGE_QUERY}`, + ), + ]); + const map = mapGenomeMap(mapResponse.result); + return { + ...map, + linkageGroups: linkageResponse.result.data.map(mapLinkageGroup), + markerPositions: markerResponse.result.data.map(mapMarkerPosition), + }; +} + +export async function fetchLinkageGroupRows(mapDbId: string): Promise { + const response = await request>>( + `/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`, + ); + return response.result.data.map(mapLinkageGroup); +} + +const buildMarkerPositionQueryString = (query?: MarkerPositionQuery) => { + const params = new URLSearchParams(DEFAULT_PAGE_QUERY); + const mapDbId = optionalText(query?.map_db_id); + const linkageGroupName = optionalText(query?.linkage_group_name); + const variantDbId = optionalText(query?.variant_db_id); + if (mapDbId) params.set("mapDbId", mapDbId); + if (linkageGroupName) params.set("linkageGroupName", linkageGroupName); + if (variantDbId) params.set("variantDbId", variantDbId); + return params.toString(); +}; + +const filterMarkerPositionRows = ( + rows: MarkerPositionRecord[], + query?: MarkerPositionQuery, + linkageGroupDbId?: string, +) => { + const linkageGroupFilter = optionalText(linkageGroupDbId); + return rows.filter((row) => { + if (linkageGroupFilter && row.linkage_group_db_id !== linkageGroupFilter) return false; + return true; + }); +}; + +export async function fetchMarkerPositionRowsByQuery( + query?: MarkerPositionQuery, + linkageGroupDbId?: string, +): Promise { + const response = await request>>( + `/brapi/v2/markerpositions?${buildMarkerPositionQueryString(query)}`, + ); + return filterMarkerPositionRows( + response.result.data.map(mapMarkerPosition), + query, + linkageGroupDbId, + ); +} + +export async function loadMarkerPositionFilterOptions(params: { + mapDbId?: string; +} = {}) { + const maps = await mapListLoader.load(); + const mapOptions: SelectOption[] = maps.map((map) => ({ + value: map.id, + label: map.map_name || map.id, + })); + const mapFilter = optionalText(params.mapDbId); + let linkageGroupOptions: SelectOption[] = []; + if (mapFilter) { + const rows = await fetchLinkageGroupRows(mapFilter); + linkageGroupOptions = toValidSelectOptions(rows.map((row) => ({ + value: row.linkage_group_name || row.id, + label: row.linkage_group_name || row.id, + }))); + } else { + const linkageGroups = await allLinkageGroupsLoader.load(); + linkageGroupOptions = toValidSelectOptions(linkageGroups.map((row) => ({ + value: row.linkage_group_name || row.id, + label: row.map_name + ? `${row.linkage_group_name || row.id} (${row.map_name})` + : (row.linkage_group_name || row.id), + }))); + } + const variants = await variantListLoader.load(); + return { + maps: mapOptions, + linkageGroups: linkageGroupOptions, + variants, + }; +} + +export async function loadMarkerPositionPageData(params: { + query?: MarkerPositionQuery; +} = {}) { + const mapDbId = optionalText(params.query?.map_db_id) ?? undefined; + const [options, rows] = await Promise.all([ + loadMarkerPositionFilterOptions({ mapDbId }), + fetchMarkerPositionRowsByQuery(params.query), + ]); + return { options, rows }; +} + +export async function fetchLinkageGroupDetail( + mapDbId: string, + linkageGroupDbId: string, +): Promise { + const rows = await fetchLinkageGroupRows(mapDbId); + return rows.find((row) => row.id === linkageGroupDbId) ?? null; +} + +export async function fetchMarkerPositionRows(mapDbId: string): Promise { + return fetchMarkerPositionRowsByQuery({ map_db_id: mapDbId }); +} + +export async function fetchMarkerPositionsByVariantId(variantDbId: string): Promise { + return fetchMarkerPositionRowsByQuery({ variant_db_id: variantDbId }); +} + +export async function fetchVariantOptions(force = false): Promise { + return variantListLoader.load(force); +} + +export async function fetchAllLinkageGroupFormOptions(): Promise { + const linkageGroups = await allLinkageGroupsLoader.load(); + return toValidSelectOptions(linkageGroups.map((row) => ({ + value: row.id, + label: row.map_name + ? `${row.linkage_group_name || row.id} (${row.map_name})` + : (row.linkage_group_name || row.id), + }))); +} + +export async function fetchLinkageGroupOptions(mapDbId: string): Promise { + const rows = await fetchLinkageGroupRows(mapDbId); + return toValidSelectOptions(rows.map((row) => ({ + value: row.id, + label: row.linkage_group_name || row.id, + }))); +} + +export async function createGenomeMapRow(payload: GenomeMapPayload) { + invalidateGenomeMapPageCache(); + const response = await request>("/brapi/v2/maps", { + method: "POST", + body: JSON.stringify(genomeMapBody(payload)), + }); + return mapGenomeMap(response.result.data[0]); +} + +export async function updateGenomeMapRow(id: string, payload: GenomeMapPayload) { + invalidateGenomeMapPageCache(); + const response = await request>( + `/brapi/v2/maps/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(genomeMapBody(payload)) }, + ); + return mapGenomeMap(response.result); +} + +export async function deleteGenomeMapRow(id: string) { + invalidateGenomeMapPageCache(); + await request(`/brapi/v2/maps/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export async function createLinkageGroupRow(mapDbId: string, payload: LinkageGroupPayload) { + invalidateGenomeMapPageCache(); + const response = await request>>( + `/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups`, + { method: "POST", body: JSON.stringify(linkageGroupBody(payload)) }, + ); + return mapLinkageGroup(response.result.data[0]); +} + +export async function createLinkageGroupRowFromPayload(payload: LinkageGroupPayload & { map_db_id?: unknown }) { + const mapDbId = requiredText(payload.map_db_id, "请选择所属图谱"); + return createLinkageGroupRow(mapDbId, payload); +} + +export async function updateLinkageGroupRow( + mapDbId: string, + linkageGroupDbId: string, + payload: LinkageGroupPayload, +) { + invalidateGenomeMapPageCache(); + const response = await request>>( + `/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`, + { method: "PUT", body: JSON.stringify(linkageGroupBody(payload)) }, + ); + return mapLinkageGroup(response.result.data[0]); +} + +export async function deleteLinkageGroupRow(mapDbId: string, linkageGroupDbId: string) { + invalidateGenomeMapPageCache(); + await request( + `/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`, + { method: "DELETE" }, + ); +} + +export async function createMarkerPositionRow(payload: MarkerPositionPayload) { + invalidateGenomeMapPageCache(); + const response = await request>>("/brapi/v2/markerpositions", { + method: "POST", + body: JSON.stringify(markerPositionBody(payload)), + }); + return mapMarkerPosition(response.result.data[0]); +} + +export async function updateMarkerPositionRow(id: string, payload: MarkerPositionPayload) { + invalidateGenomeMapPageCache(); + const response = await request>>( + `/brapi/v2/markerpositions/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(markerPositionBody(payload)) }, + ); + return mapMarkerPosition(response.result.data[0]); +} + +export async function deleteMarkerPositionRow(id: string) { + invalidateGenomeMapPageCache(); + await request(`/brapi/v2/markerpositions/${encodeURIComponent(id)}`, { method: "DELETE" }); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx new file mode 100644 index 0000000..dfca006 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/GenomeMapTab.tsx @@ -0,0 +1,196 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import { Map, RotateCcw, Search } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createGenomeMapRow, + deleteGenomeMapRow, + loadGenomeMapPageData, + normalizeGenomeMapFormData, + updateGenomeMapRow, +} from "../api"; +import { NONE_SELECT_VALUE, type GenomeMapQuery, type SelectOption } from "../types"; + +const mapTypeOptions: SelectOption[] = [ + { value: "physical", label: "physical(物理图)" }, + { value: "genomic", label: "genomic(基因组图)" }, +]; + +const emptyQuery = (): GenomeMapQuery => ({ + map_name: "", + common_crop_name: NONE_SELECT_VALUE, + type: NONE_SELECT_VALUE, +}); + +const optionOrNone = (label: string, options: SelectOption[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +export function GenomeMapTab() { + const [cropOptions, setCropOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); + + const loadRows = useCallback(async () => { + const { options, rows } = await loadGenomeMapPageData({ query: appliedQuery }); + setCropOptions(options.crops); + return rows as unknown as Record[]; + }, [appliedQuery]); + + const fields = useMemo(() => [ + { key: "id", label: "Map ID", type: "text", placeholder: "留空则系统自动生成" }, + { key: "map_name", label: "图谱名称", type: "text", required: true, placeholder: "如 Maize IBM2" }, + { + key: "map_pui", + label: "Map PUI", + type: "text", + readOnly: true, + placeholder: "保存后由系统自动生成", + }, + { + key: "common_crop_name", + label: "作物", + type: "select", + options: optionOrNone("不关联作物", cropOptions), + }, + { key: "scientific_name", label: "学名", type: "text", placeholder: "Zea mays" }, + { + key: "type", + label: "图谱类型", + type: "select", + options: optionOrNone("不指定", mapTypeOptions), + }, + { key: "unit", label: "单位", type: "text", placeholder: "如 cM" }, + { key: "published_date", label: "发表日期", type: "date" }, + { key: "documentation_url", label: "文档 URL", type: "text", placeholder: "https://..." }, + { key: "comments", label: "备注", type: "textarea", colSpan: 2 }, + ], [cropOptions]); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, map_name: event.target.value }))} + placeholder="mapName 模糊匹配" + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, cropOptions]); + + return ( + { + const id = String(row.id ?? row.mapDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "common_crop_name", label: "作物" }, + { key: "type", label: "类型" }, + { key: "unit", label: "单位" }, + { + key: "linkage_group_count", + label: "连锁群", + render: (value) => {Number(value ?? 0)}, + }, + { + key: "marker_count", + label: "Marker 数", + render: (value) => {Number(value ?? 0)}, + }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/maps", + value: "BrAPI", + className: "bg-teal-50 text-teal-700 dark:bg-teal-400/10 dark:text-teal-200", + }]} + loadData={loadRows} + fetchRecord={async (id) => { + const { rows } = await loadGenomeMapPageData({ force: true }); + const row = rows.find((item) => item.id === id); + if (!row) throw new Error("图谱不存在"); + return normalizeGenomeMapFormData(row); + }} + createRecord={(payload) => createGenomeMapRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateGenomeMapRow(id, payload) as unknown as Promise>} + deleteRecord={deleteGenomeMapRow} + renderQueryForm={renderQueryForm} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupEntityPage.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupEntityPage.tsx new file mode 100644 index 0000000..41de716 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupEntityPage.tsx @@ -0,0 +1,221 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, type ReactNode } from "react"; +import { GitBranch } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + createLinkageGroupRow, + createLinkageGroupRowFromPayload, + deleteLinkageGroupRow, + fetchLinkageGroupRows, + loadLinkageGroupPageData, + normalizeLinkageGroupFormData, + updateLinkageGroupRow, +} from "../api"; +import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types"; + +const optionOrNone = (label: string, options: SelectOption[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +interface LinkageGroupEntityPageProps { + /** 详情页固定所属图谱;入口 Tab 不传则跨图谱列表维护 */ + mapDbId?: string; + mapOptions?: SelectOption[]; + linkageGroupQuery?: LinkageGroupQuery; + onChanged?: () => void; + defaultFormValues?: Record; + renderQueryForm?: () => ReactNode; +} + +export function LinkageGroupEntityPage({ + mapDbId, + mapOptions = [], + linkageGroupQuery, + onChanged, + defaultFormValues, + renderQueryForm, +}: LinkageGroupEntityPageProps) { + const scopedToMap = Boolean(mapDbId); + const includeMapInForm = !scopedToMap; + + const loadRows = useCallback(async () => { + if (scopedToMap && mapDbId) { + const rows = await fetchLinkageGroupRows(mapDbId); + return rows as unknown as Record[]; + } + const { rows } = await loadLinkageGroupPageData({ query: linkageGroupQuery }); + return rows as unknown as Record[]; + }, [scopedToMap, mapDbId, linkageGroupQuery]); + + const resolveMapDbId = useCallback((payload: Record, row?: Record) => { + if (mapDbId) return mapDbId; + const fromPayload = String(payload.map_db_id ?? "").trim(); + if (fromPayload && fromPayload !== NONE_SELECT_VALUE) return fromPayload; + return String(row?.map_db_id ?? row?.mapDbId ?? "").trim(); + }, [mapDbId]); + + const fields = useMemo(() => { + const base: BrapiFormField[] = []; + if (includeMapInForm) { + base.push({ + key: "map_db_id", + label: "所属图谱", + type: "select", + required: true, + options: optionOrNone("请选择图谱", mapOptions), + }); + } + base.push( + { key: "linkage_group_name", label: "连锁群名称", type: "text", required: true, placeholder: "如 Chr01" }, + { key: "max_position", label: "最大位置", type: "number", placeholder: "可选,非负整数" }, + ); + return base; + }, [includeMapInForm, mapOptions]); + + const columns = useMemo(() => { + const cols = [ + { + key: "linkage_group_name", + label: "名称", + render: (value: unknown, row: Record) => { + const name = String(value ?? "—"); + const targetMapId = resolveMapDbId({}, row); + if (!targetMapId) return name; + return ( + + {name} + + ); + }, + }, + ]; + if (!scopedToMap) { + cols.push({ + key: "map_name", + label: "所属图谱", + render: (value: unknown, row: Record) => { + const targetMapId = resolveMapDbId({}, row); + const label = String(value ?? row.map_name ?? "—"); + if (!targetMapId) return label; + return ( + + {label} + + ); + }, + }); + } + cols.push( + { key: "max_position", label: "最大位置", render: (value: unknown) => String(value ?? "—") }, + { + key: "marker_count", + label: "Marker 数", + render: (value: unknown) => {Number(value ?? 0)}, + }, + ); + return cols; + }, [scopedToMap, resolveMapDbId]); + + const wrapMutation = useCallback((action: () => Promise) => async () => { + const result = await action(); + onChanged?.(); + return result; + }, [onChanged]); + + return ( + { + if (scopedToMap && mapDbId) { + const rows = await fetchLinkageGroupRows(mapDbId); + const row = rows.find((item) => item.id === id); + if (!row) throw new Error("连锁群不存在"); + return normalizeLinkageGroupFormData(row); + } + const { rows } = await loadLinkageGroupPageData({ force: true }); + const row = rows.find((item) => item.id === id); + if (!row) throw new Error("连锁群不存在"); + return normalizeLinkageGroupFormData(row, true); + }} + createRecord={(payload) => { + if (scopedToMap && mapDbId) { + return wrapMutation(() => createLinkageGroupRow(mapDbId, payload))() as Promise>; + } + return wrapMutation(() => createLinkageGroupRowFromPayload(payload))() as Promise>; + }} + updateRecord={async (id, payload) => { + const targetMapId = resolveMapDbId(payload); + if (!targetMapId) throw new Error("无法确定所属图谱"); + return wrapMutation(() => updateLinkageGroupRow(targetMapId, id, payload))() as Promise>; + }} + deleteRecord={async (id) => { + let targetMapId = mapDbId; + if (!targetMapId) { + const { rows } = await loadLinkageGroupPageData({ force: true }); + const row = rows.find((item) => item.id === id); + targetMapId = row?.map_db_id || undefined; + } + if (!targetMapId) throw new Error("无法确定所属图谱"); + await wrapMutation(() => deleteLinkageGroupRow(targetMapId, id))(); + }} + renderQueryForm={renderQueryForm} + renderFormExtra={({ editingRow }) => ( + <> +
+ + {editingRow ? ( + + ) : ( +

+ 保存后由系统自动生成 +

+ )} +

数据库主键,新增与修改时均不可编辑。

+
+
+

校验说明

+

+ 同一图谱下连锁群名称不可重复;删除前需无 Marker Position 引用。 +

+
+ + )} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupPanel.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupPanel.tsx new file mode 100644 index 0000000..61d76e3 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupPanel.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage"; + +interface LinkageGroupPanelProps { + mapDbId: string; + onChanged?: () => void; +} + +export function LinkageGroupPanel({ mapDbId, onChanged }: LinkageGroupPanelProps) { + return ; +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupTab.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupTab.tsx new file mode 100644 index 0000000..fed9fc2 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/LinkageGroupTab.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { RotateCcw, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { loadLinkageGroupPageData } from "../api"; +import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage"; +import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types"; + +const emptyQuery = (): LinkageGroupQuery => ({ + linkage_group_name: "", + map_db_id: NONE_SELECT_VALUE, +}); + +function toSelectValue(value: string | null | undefined) { + return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE; +} + +export function LinkageGroupTab() { + const searchParams = useSearchParams(); + const [mapOptions, setMapOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE, + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...emptyQuery(), + map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE, + })); + + const urlDefaultFormValues = useMemo(() => { + const mapDbId = searchParams.get("map_db_id"); + return mapDbId ? { map_db_id: mapDbId } : undefined; + }, [searchParams]); + + useEffect(() => { + let mounted = true; + loadLinkageGroupPageData().then(({ options }) => { + if (!mounted) return; + setMapOptions(options.maps); + }); + return () => { mounted = false; }; + }, []); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ + ...current, + linkage_group_name: event.target.value, + }))} + placeholder="名称模糊匹配" + /> +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, mapOptions]); + + return ( + + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionEntityPage.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionEntityPage.tsx new file mode 100644 index 0000000..61b6d8d --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionEntityPage.tsx @@ -0,0 +1,222 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { MapPin } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { + createMarkerPositionRow, + deleteMarkerPositionRow, + fetchAllLinkageGroupFormOptions, + fetchLinkageGroupOptions, + fetchMarkerPositionRowsByQuery, + fetchVariantOptions, + normalizeMarkerPositionFormData, + updateMarkerPositionRow, +} from "../api"; +import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types"; + +interface MarkerPositionEntityPageProps { + /** 详情页固定所属图谱 */ + mapDbId?: string; + /** 连锁群详情页固定所属连锁群 */ + linkageGroupDbId?: string; + linkageGroupName?: string | null; + markerPositionQuery?: MarkerPositionQuery; + onChanged?: () => void; + defaultFormValues?: Record; + renderQueryForm?: () => ReactNode; +} + +export function MarkerPositionEntityPage({ + mapDbId, + linkageGroupDbId, + linkageGroupName, + markerPositionQuery, + onChanged, + defaultFormValues, + renderQueryForm, +}: MarkerPositionEntityPageProps) { + const scopedToMap = Boolean(mapDbId); + const scopedToLinkageGroup = Boolean(linkageGroupDbId); + const [linkageGroupOptions, setLinkageGroupOptions] = useState([]); + const [variantOptions, setVariantOptions] = useState([]); + + useEffect(() => { + let mounted = true; + const linkagePromise = mapDbId + ? fetchLinkageGroupOptions(mapDbId) + : fetchAllLinkageGroupFormOptions(); + Promise.all([linkagePromise, fetchVariantOptions()]) + .then(([linkageGroups, variants]) => { + if (!mounted) return; + setLinkageGroupOptions(linkageGroups); + setVariantOptions(variants); + }) + .catch(() => { + if (!mounted) return; + setLinkageGroupOptions([]); + setVariantOptions([]); + }); + return () => { mounted = false; }; + }, [mapDbId]); + + const loadRows = useCallback(async () => { + const query = scopedToMap && mapDbId + ? { map_db_id: mapDbId } + : markerPositionQuery; + const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId); + return rows as unknown as Record[]; + }, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]); + + const fields = useMemo(() => [ + { key: "id", label: "MarkerPosition ID", type: "text", placeholder: "留空则系统自动生成" }, + { + key: "linkage_group_id", + label: "连锁群", + type: "select", + required: true, + options: [ + { value: NONE_SELECT_VALUE, label: "请选择连锁群" }, + ...linkageGroupOptions, + ], + }, + { + key: "variant_id", + label: "Variant", + type: "select", + required: true, + options: [ + { value: NONE_SELECT_VALUE, label: "请选择 Variant" }, + ...variantOptions, + ], + }, + { key: "position", label: "图谱位置", type: "number", required: true, placeholder: "非负整数" }, + ], [linkageGroupOptions, variantOptions]); + + const columns = useMemo(() => { + const cols = []; + if (!scopedToMap) { + cols.push({ + key: "map_name", + label: "所属图谱", + render: (value: unknown, row: Record) => { + const id = String(row.map_db_id ?? row.mapDbId ?? ""); + const label = String(value ?? row.map_name ?? "—"); + if (!id) return label; + return ( + + {label} + + ); + }, + }); + } + cols.push( + { + key: "linkage_group_name", + label: "连锁群", + render: (value: unknown, row: Record) => { + const name = String(value ?? "—"); + const lgId = String(row.linkage_group_db_id ?? row.linkageGroupDbId ?? ""); + const targetMapId = mapDbId || String(row.map_db_id ?? row.mapDbId ?? ""); + if (!lgId || !targetMapId) return name; + return ( + + {name} + + ); + }, + }, + { + key: "variant_name", + label: "Variant", + render: (value: unknown, row: Record) => { + const id = String(row.variant_db_id ?? row.variantDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "position", label: "位置" }, + ); + return cols; + }, [scopedToMap, mapDbId]); + + const resolvedDefaultFormValues = useMemo(() => ({ + ...defaultFormValues, + ...(linkageGroupDbId ? { linkage_group_id: linkageGroupDbId } : {}), + }), [defaultFormValues, linkageGroupDbId]); + + const wrapMutation = useCallback((action: () => Promise) => async () => { + const result = await action(); + onChanged?.(); + return result; + }, [onChanged]); + + const findRowById = useCallback(async (id: string) => { + const query = scopedToMap && mapDbId + ? { map_db_id: mapDbId } + : markerPositionQuery; + const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId); + return rows.find((item) => item.id === id); + }, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]); + + const title = scopedToLinkageGroup && linkageGroupName + ? `Marker Position · ${linkageGroupName}` + : scopedToMap + ? "Marker Position" + : "Marker Position 列表"; + + const description = scopedToLinkageGroup + ? "在该连锁群下维护 Variant 图谱位置;同一 Variant 不可重复。" + : scopedToMap + ? "将 Variant 定位到连锁群坐标;同一连锁群下同一 Variant 不可重复。" + : "按图谱、连锁群、Variant 查询并维护 marker_position。"; + + return ( + { + const row = await findRowById(id); + if (!row) throw new Error("Marker Position 不存在"); + return normalizeMarkerPositionFormData(row); + }} + createRecord={(payload) => wrapMutation(() => createMarkerPositionRow(payload))() as Promise>} + updateRecord={(id, payload) => wrapMutation(() => updateMarkerPositionRow(id, payload))() as Promise>} + deleteRecord={(id) => wrapMutation(() => deleteMarkerPositionRow(id))().then(() => undefined)} + renderQueryForm={renderQueryForm} + renderFormExtra={() => ( +
+

校验说明

+

+ position 不应超过所属连锁群的 max_position(若已设置);单位应与图谱 unit 一致。 +

+
+ )} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionPanel.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionPanel.tsx new file mode 100644 index 0000000..fb9fe84 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionPanel.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage"; + +interface MarkerPositionPanelProps { + mapDbId: string; + onChanged?: () => void; +} + +export function MarkerPositionPanel({ mapDbId, onChanged }: MarkerPositionPanelProps) { + return ; +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionTab.tsx b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionTab.tsx new file mode 100644 index 0000000..44b4a7a --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/components/MarkerPositionTab.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { RotateCcw, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { loadMarkerPositionFilterOptions } from "../api"; +import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage"; +import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types"; + +const emptyQuery = (): MarkerPositionQuery => ({ + map_db_id: NONE_SELECT_VALUE, + linkage_group_name: NONE_SELECT_VALUE, + variant_db_id: NONE_SELECT_VALUE, +}); + +function toSelectValue(value: string | null | undefined) { + return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE; +} + +export function MarkerPositionTab() { + const searchParams = useSearchParams(); + const [mapOptions, setMapOptions] = useState([]); + const [linkageGroupOptions, setLinkageGroupOptions] = useState([]); + const [variantOptions, setVariantOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE, + linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE, + variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE, + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...emptyQuery(), + map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE, + linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE, + variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE, + })); + + useEffect(() => { + let mounted = true; + loadMarkerPositionFilterOptions() + .then((options) => { + if (!mounted) return; + setMapOptions(options.maps); + setLinkageGroupOptions(options.linkageGroups); + setVariantOptions(options.variants); + }) + .catch(() => { + if (!mounted) return; + setMapOptions([]); + setLinkageGroupOptions([]); + setVariantOptions([]); + }); + return () => { mounted = false; }; + }, []); + + useEffect(() => { + let mounted = true; + const mapDbId = toSelectValue(draftQuery.map_db_id); + loadMarkerPositionFilterOptions({ + mapDbId: mapDbId !== NONE_SELECT_VALUE ? mapDbId : undefined, + }).then((options) => { + if (!mounted) return; + setLinkageGroupOptions(options.linkageGroups); + }); + return () => { mounted = false; }; + }, [draftQuery.map_db_id]); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, mapOptions, linkageGroupOptions, variantOptions]); + + const urlDefaultFormValues = useMemo(() => { + const variantId = searchParams.get("variant_db_id"); + return variantId ? { variant_id: variantId } : undefined; + }, [searchParams]); + + return ( + + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/genomeMapUtils.ts b/frontend/src/app/(app)/genotyping/genome-map/genomeMapUtils.ts new file mode 100644 index 0000000..c958d8e --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/genomeMapUtils.ts @@ -0,0 +1,15 @@ +export function readAdditionalInfoString( + item: Record, + key: string, +): string | null { + const info = item.additionalInfo; + if (!info || typeof info !== "object") return null; + const value = (info as Record)[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function formatPublishedDate(value: unknown): string { + if (!value) return "—"; + const text = String(value); + return text.length >= 10 ? text.slice(0, 10) : text; +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/linkage-groups/[linkageGroupDbId]/page.tsx b/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/linkage-groups/[linkageGroupDbId]/page.tsx new file mode 100644 index 0000000..9d7764c --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/linkage-groups/[linkageGroupDbId]/page.tsx @@ -0,0 +1,129 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, GitBranch, Map } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MarkerPositionEntityPage } from "../../../../components/MarkerPositionEntityPage"; +import { fetchGenomeMapDetail, fetchLinkageGroupDetail } from "../../../../api"; +import type { GenomeMapRecord, LinkageGroupRecord } from "../../../../types"; + +export default function LinkageGroupDetailPage() { + const params = useParams<{ mapDbId: string; linkageGroupDbId: string }>(); + const mapDbId = decodeURIComponent(params.mapDbId); + const linkageGroupDbId = decodeURIComponent(params.linkageGroupDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [mapDetail, setMapDetail] = useState(null); + const [linkageGroup, setLinkageGroup] = useState(null); + + const loadDetail = useCallback(async () => { + const [map, group] = await Promise.all([ + fetchGenomeMapDetail(mapDbId), + fetchLinkageGroupDetail(mapDbId, linkageGroupDbId), + ]); + if (!group) throw new Error("连锁群不存在"); + setMapDetail(map); + setLinkageGroup(group); + }, [mapDbId, linkageGroupDbId]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + loadDetail() + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载连锁群详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [loadDetail]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error || !linkageGroup || !mapDetail) { + return ( +
+ {error || "连锁群不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {linkageGroup.linkage_group_name || linkageGroup.id} + + + +
LinkageGroup ID:{linkageGroup.id}
+
+ 所属图谱: + + {mapDetail.map_name || mapDbId} + +
+
最大位置:{linkageGroup.max_position ?? "—"}
+
+ Marker 数: + {linkageGroup.marker_count ?? 0} +
+
+
+ +
+ +
+ + +
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/page.tsx b/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/page.tsx new file mode 100644 index 0000000..5be176e --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/maps/[mapDbId]/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, GitBranch, Map, Sigma } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { LinkageGroupPanel } from "../../components/LinkageGroupPanel"; +import { MarkerPositionPanel } from "../../components/MarkerPositionPanel"; +import { fetchGenomeMapDetail } from "../../api"; +import { formatPublishedDate } from "../../genomeMapUtils"; +import type { GenomeMapDetail } from "../../types"; + +export default function GenomeMapDetailPage() { + const params = useParams<{ mapDbId: string }>(); + const mapDbId = decodeURIComponent(params.mapDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + const loadDetail = useCallback(async () => { + const record = await fetchGenomeMapDetail(mapDbId); + setDetail(record); + }, [mapDbId]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + loadDetail() + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载图谱详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [loadDetail]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "图谱不存在"} +
+ +
+
+ ); + } + + const hasDependencies = (detail.linkage_group_count ?? detail.linkageGroups.length) > 0 + || (detail.marker_count ?? detail.markerPositions.length) > 0; + + return ( +
+ + + + + + + {detail.map_name || detail.id} + + + +
Map ID:{detail.id}
+
作物:{detail.common_crop_name || "—"}
+
类型:{detail.type || "—"}
+
单位:{detail.unit || "—"}
+
学名:{detail.scientific_name || "—"}
+
Map PUI:{detail.map_pui || "—"}
+
发表日期:{formatPublishedDate(detail.published_date)}
+
连锁群数:{detail.linkage_group_count ?? detail.linkageGroups.length}
+
Marker 数:{detail.marker_count ?? detail.markerPositions.length}
+
文档:{detail.documentation_url || "—"}
+
备注:{detail.comments || "—"}
+
+
+ + {hasDependencies ? ( +

+ 删除图谱前请先移除下属连锁群与 Marker Position。 +

+ ) : null} + +
+ + +
+ + + +
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/page.tsx b/frontend/src/app/(app)/genotyping/genome-map/page.tsx new file mode 100644 index 0000000..c167e7b --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { GitBranch, Map, MapPin } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { GenomeMapTab } from "./components/GenomeMapTab"; +import { LinkageGroupTab } from "./components/LinkageGroupTab"; +import { MarkerPositionTab } from "./components/MarkerPositionTab"; + +function GenomeMapPageContent() { + const searchParams = useSearchParams(); + const [tab, setTab] = useState("genome-maps"); + + useEffect(() => { + const nextTab = searchParams.get("tab"); + if (nextTab === "linkage-groups" || nextTab === "genome-maps" || nextTab === "marker-positions") { + setTab(nextTab); + } + }, [searchParams]); + + return ( + + + + + GenomeMap + + + + Linkage Group + + + + Marker Position + + + + {tab === "genome-maps" ? ( + + + + ) : null} + + {tab === "linkage-groups" ? ( + + + + ) : null} + + {tab === "marker-positions" ? ( + + + + ) : null} + + ); +} + +function PageFallback() { + return ; +} + +export default function GenomeMapPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/(app)/genotyping/genome-map/types.ts b/frontend/src/app/(app)/genotyping/genome-map/types.ts new file mode 100644 index 0000000..59fab6d --- /dev/null +++ b/frontend/src/app/(app)/genotyping/genome-map/types.ts @@ -0,0 +1,84 @@ +export const NONE_SELECT_VALUE = "__none__"; + +export type SelectOption = { value: string; label: string }; + +export type GenomeMapQuery = { + map_name?: string; + common_crop_name?: string; + type?: string; +}; + +export type LinkageGroupQuery = { + linkage_group_name?: string; + map_db_id?: string; +}; + +export type MarkerPositionQuery = { + map_db_id?: string; + linkage_group_name?: string; + variant_db_id?: string; +}; + +export type GenomeMapRecord = { + id: string; + mapDbId?: string; + map_name?: string | null; + mapName?: string | null; + map_pui?: string | null; + mapPUI?: string | null; + common_crop_name?: string | null; + commonCropName?: string | null; + scientific_name?: string | null; + scientificName?: string | null; + type?: string | null; + unit?: string | null; + comments?: string | null; + documentation_url?: string | null; + documentationURL?: string | null; + published_date?: string | null; + publishedDate?: string | null; + linkage_group_count?: number | null; + linkageGroupCount?: number | null; + marker_count?: number | null; + markerCount?: number | null; +}; + +export type LinkageGroupRecord = { + id: string; + linkage_group_db_id?: string; + linkageGroupDbId?: string; + linkage_group_name?: string | null; + linkageGroupName?: string | null; + max_position?: number | null; + maxPosition?: number | null; + marker_count?: number | null; + markerCount?: number | null; + map_db_id?: string | null; + mapDbId?: string | null; + map_name?: string | null; + mapName?: string | null; +}; + +export type MarkerPositionRecord = { + id: string; + marker_position_db_id?: string; + markerPositionDbId?: string; + linkage_group_db_id?: string; + linkageGroupDbId?: string; + linkage_group_name?: string | null; + linkageGroupName?: string | null; + variant_db_id?: string | null; + variantDbId?: string | null; + variant_name?: string | null; + variantName?: string | null; + position?: number | null; + map_db_id?: string | null; + mapDbId?: string | null; + map_name?: string | null; + mapName?: string | null; +}; + +export type GenomeMapDetail = GenomeMapRecord & { + linkageGroups: LinkageGroupRecord[]; + markerPositions: MarkerPositionRecord[]; +}; diff --git a/frontend/src/app/(app)/genotyping/variant-set/api.ts b/frontend/src/app/(app)/genotyping/variant-set/api.ts index b3c608c..4d7e7bf 100644 --- a/frontend/src/app/(app)/genotyping/variant-set/api.ts +++ b/frontend/src/app/(app)/genotyping/variant-set/api.ts @@ -3,7 +3,9 @@ import { getAuthToken } from "@/utils/token"; import { NONE_SELECT_VALUE, type SelectOption, + type VariantSetAnalysisItem, type VariantSetDetail, + type VariantSetFormatItem, type VariantSetQuery, type VariantSetRecord, } from "./types"; @@ -273,6 +275,184 @@ export async function deleteVariantSetRow(id: string): Promise { invalidateVariantSetPageCache(); } +const mapAnalysisRow = (item: VariantSetAnalysisItem): VariantSetAnalysisItem => ({ + ...item, + id: item.analysisDbId || item.id, + analysisName: item.analysisName ?? null, + type: item.type ?? null, + description: item.description ?? null, + software: Array.isArray(item.software) + ? item.software + : (item.software ? [String(item.software)] : []), + created: item.created ?? null, + updated: item.updated ?? null, +}); + +const mapFormatRow = (item: VariantSetFormatItem): VariantSetFormatItem => ({ + ...item, + id: item.formatDbId || item.id, + dataFormat: item.dataFormat ?? item.data_format ?? null, + fileFormat: item.fileFormat ?? item.file_format ?? null, + fileURL: item.fileURL ?? item.fileurl ?? null, + expandHomozygotes: item.expandHomozygotes ?? item.expand_homozygotes ?? null, + sepPhased: item.sepPhased ?? item.sep_phased ?? null, + sepUnphased: item.sepUnphased ?? item.sep_unphased ?? null, + unknownString: item.unknownString ?? item.unknown_string ?? null, +}); + +const parseSoftwareLines = (value: unknown) => String(value ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + +const isValidUrl = (value: string) => { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +}; + +const analysisBody = (payload: Partial>) => { + const analysisName = requiredText(payload.analysis_name, "分析名称不能为空"); + const software = parseSoftwareLines(payload.software_text); + for (const item of software) { + if (item.startsWith("http") && !isValidUrl(item)) { + throw new Error(`Software URL 格式无效:${item}`); + } + } + return { + analysisDbId: optionalText(payload.id), + analysisName, + description: optionalText(payload.description), + type: optionalText(payload.type === NONE_SELECT_VALUE ? null : payload.type), + created: optionalText(payload.created), + updated: optionalText(payload.updated), + software, + }; +}; + +const formatBody = (payload: Partial>) => { + const dataFormat = requiredText(payload.data_format, "请选择 data_format"); + const fileFormat = requiredText(payload.file_format, "请选择 file_format"); + const fileURL = optionalText(payload.fileurl); + if (fileURL && !isValidUrl(fileURL)) { + throw new Error("fileurl 格式无效,请输入 http(s) URL"); + } + return { + formatDbId: optionalText(payload.id), + dataFormat, + fileFormat, + fileURL, + expandHomozygotes: payload.expand_homozygotes === true || payload.expand_homozygotes === "true", + sepPhased: optionalText(payload.sep_phased), + sepUnphased: optionalText(payload.sep_unphased), + unknownString: optionalText(payload.unknown_string), + }; +}; + +export const normalizeVariantSetAnalysisFormData = (row: VariantSetAnalysisItem) => ({ + id: row.id || row.analysisDbId || "", + analysis_name: row.analysisName || "", + type: row.type ? row.type : NONE_SELECT_VALUE, + description: row.description || "", + software_text: (Array.isArray(row.software) ? row.software : []).join("\n"), + created: row.created ? String(row.created).slice(0, 19) : "", + updated: row.updated ? String(row.updated).slice(0, 19) : "", +}); + +export const normalizeVariantSetFormatFormData = (row: VariantSetFormatItem) => ({ + id: row.id || row.formatDbId || "", + data_format: row.dataFormat || NONE_SELECT_VALUE, + file_format: row.fileFormat || NONE_SELECT_VALUE, + fileurl: row.fileURL || "", + expand_homozygotes: row.expandHomozygotes === true, + sep_phased: row.sepPhased || "", + sep_unphased: row.sepUnphased || "", + unknown_string: row.unknownString || "", +}); + +export async function fetchVariantSetAnalysisRows(variantSetDbId: string): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`, + ); + return response.result.data.map(mapAnalysisRow); +} + +export async function fetchVariantSetFormatRows(variantSetDbId: string): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`, + ); + return response.result.data.map(mapFormatRow); +} + +export async function createVariantSetAnalysisRow( + variantSetDbId: string, + payload: Partial>, +): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`, + { method: "POST", body: JSON.stringify(analysisBody(payload)) }, + ); + invalidateVariantSetPageCache(); + return mapAnalysisRow(response.result.data[0]); +} + +export async function updateVariantSetAnalysisRow( + variantSetDbId: string, + id: string, + payload: Partial>, +): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(analysisBody(payload)) }, + ); + invalidateVariantSetPageCache(); + return mapAnalysisRow(response.result.data[0]); +} + +export async function deleteVariantSetAnalysisRow(variantSetDbId: string, id: string): Promise { + await request( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + invalidateVariantSetPageCache(); +} + +export async function createVariantSetFormatRow( + variantSetDbId: string, + payload: Partial>, +): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`, + { method: "POST", body: JSON.stringify(formatBody(payload)) }, + ); + invalidateVariantSetPageCache(); + return mapFormatRow(response.result.data[0]); +} + +export async function updateVariantSetFormatRow( + variantSetDbId: string, + id: string, + payload: Partial>, +): Promise { + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${encodeURIComponent(id)}`, + { method: "PUT", body: JSON.stringify(formatBody(payload)) }, + ); + invalidateVariantSetPageCache(); + return mapFormatRow(response.result.data[0]); +} + +export async function deleteVariantSetFormatRow(variantSetDbId: string, id: string): Promise { + await request( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); + invalidateVariantSetPageCache(); +} + export async function loadVariantSetPageData(options?: { query?: VariantSetQuery; force?: boolean }) { const force = options?.force ?? false; const [referenceSets, studies, variantSets] = await Promise.all([ diff --git a/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetAnalysisPanel.tsx b/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetAnalysisPanel.tsx new file mode 100644 index 0000000..b61ecfb --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetAnalysisPanel.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { FlaskConical } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + createVariantSetAnalysisRow, + deleteVariantSetAnalysisRow, + fetchVariantSetAnalysisRows, + normalizeVariantSetAnalysisFormData, + updateVariantSetAnalysisRow, +} from "../api"; +import { ANALYSIS_TYPE_OPTIONS, NONE_SELECT_VALUE } from "../types"; + +interface VariantSetAnalysisPanelProps { + variantSetDbId: string; + onChanged?: () => void; +} + +const optionOrNone = (label: string, options: readonly { value: string; label: string }[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +export function VariantSetAnalysisPanel({ variantSetDbId, onChanged }: VariantSetAnalysisPanelProps) { + const loadRows = useCallback(async () => { + const rows = await fetchVariantSetAnalysisRows(variantSetDbId); + return rows as unknown as Record[]; + }, [variantSetDbId]); + + const findRowById = useCallback(async (id: string) => { + const rows = await fetchVariantSetAnalysisRows(variantSetDbId); + const row = rows.find((item) => item.id === id); + if (!row) throw new Error("Analysis 不存在"); + return normalizeVariantSetAnalysisFormData(row); + }, [variantSetDbId]); + + const wrapMutation = useCallback((action: () => Promise) => async () => { + const result = await action(); + onChanged?.(); + return result; + }, [onChanged]); + + const fields = useMemo(() => [ + { key: "id", label: "Analysis ID", type: "text", placeholder: "留空则系统自动生成" }, + { key: "analysis_name", label: "分析名称", type: "text", required: true, placeholder: "如 Standard QC" }, + { + key: "type", + label: "分析类型", + type: "select", + options: optionOrNone("不指定类型", ANALYSIS_TYPE_OPTIONS), + }, + { key: "description", label: "分析说明", type: "textarea", placeholder: "描述分析流程或 QC 标准" }, + { key: "created", label: "创建时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" }, + { key: "updated", label: "更新时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" }, + ], []); + + const columns = useMemo(() => [ + { key: "analysisName", label: "分析名称" }, + { key: "type", label: "类型" }, + { + key: "software", + label: "Software", + render: (value: unknown) => { + if (Array.isArray(value)) return value.length ? value.join(", ") : "—"; + return String(value ?? "—"); + }, + }, + { + key: "description", + label: "说明", + render: (value: unknown) => { + const text = String(value ?? ""); + if (!text) return "—"; + return text.length > 40 ? `${text.slice(0, 40)}…` : text; + }, + }, + ], []); + + return ( + wrapMutation(() => createVariantSetAnalysisRow(variantSetDbId, payload))() as Promise>} + updateRecord={(id, payload) => wrapMutation(() => updateVariantSetAnalysisRow(variantSetDbId, id, payload))() as Promise>} + deleteRecord={(id) => wrapMutation(() => deleteVariantSetAnalysisRow(variantSetDbId, id))().then(() => undefined)} + renderFormExtra={({ formData, updateForm }) => ( +
+ +