From 3bdd16cbd2b3073342d2853060157bd75f92d318 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 15:51:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:java=E9=A1=B9=E7=9B=AE=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 41 +- docs/dev/03-genotyping/06-variantset.md | 11 +- docs/dev/03-genotyping/07-variant.md | 11 +- frontend/README.md | 4 + .../(app)/basic-dictionary/base/list/api.ts | 8 +- .../basic-dictionary/base/location/api.ts | 9 +- .../(app)/basic-dictionary/ontology/api.ts | 9 +- .../trait-method-scale/api.ts | 18 +- .../app/(app)/genotyping/reference-set/api.ts | 364 ++++++++++++++---- .../components/ReferenceSetTab.tsx | 164 ++++++++ .../reference-set/components/ReferenceTab.tsx | 168 ++++++++ .../(app)/genotyping/reference-set/page.tsx | 275 ++----------- .../reference-set/referenceUtils.ts | 38 ++ .../references/[referenceDbId]/page.tsx | 165 ++++++++ .../referencesets/[referenceSetDbId]/page.tsx | 148 +++++++ .../(app)/genotyping/reference-set/types.ts | 19 + .../app/(app)/genotyping/sample-plate/api.ts | 28 +- .../app/(app)/genotyping/variant-set/api.ts | 294 ++++++++++++++ .../variant-set/components/VariantSetTab.tsx | 199 ++++++++++ .../app/(app)/genotyping/variant-set/page.tsx | 11 + .../app/(app)/genotyping/variant-set/types.ts | 51 +++ .../variant-sets/[variantSetDbId]/page.tsx | 233 +++++++++++ .../src/app/(app)/genotyping/variant/api.ts | 190 +++++++-- .../variant/components/VariantTab.tsx | 241 ++++++++++++ .../src/app/(app)/genotyping/variant/page.tsx | 172 +++------ .../src/app/(app)/genotyping/variant/types.ts | 11 +- .../variant/variants/[variantDbId]/page.tsx | 126 ++++++ .../(app)/germplasm/breeding-method/api.ts | 4 +- .../app/(app)/germplasm/cross-pedigree/api.ts | 22 +- .../cross-pedigree/crossPedigreeCache.ts | 6 +- .../src/app/(app)/germplasm/germplasm/api.ts | 4 +- .../(app)/germplasm/germplasm/attributeApi.ts | 6 +- .../germplasm/germplasm/attributeValueApi.ts | 8 +- .../src/app/(app)/germplasm/seed-lot/api.ts | 34 +- .../app/(app)/phenotyping/event-image/api.ts | 10 +- .../(app)/phenotyping/observation-unit/api.ts | 6 +- .../phenotyping/observation-variable/api.ts | 14 +- frontend/src/app/(app)/project/program/api.ts | 4 +- frontend/src/app/(app)/project/study/api.ts | 6 +- frontend/src/app/(app)/project/trial/api.ts | 4 +- .../src/components/brapi/BrapiEntityPage.tsx | 6 + frontend/src/components/brapi/navigation.ts | 10 +- frontend/src/constants/api.ts | 11 + frontend/src/constants/menu.ts | 5 +- frontend/src/lib/api/types.gen.ts | 4 +- frontend/src/services/dictionaryService.ts | 15 +- frontend/src/services/dropdownCache.ts | 7 +- .../GenotypingVariantWriteController.java | 131 +++++++ .../dto/geno/VariantSetWriteRequest.java | 40 ++ .../model/dto/geno/VariantWriteRequest.java | 141 +++++++ .../repository/geno/CallSetRepository.java | 14 +- .../repository/geno/VariantRepository.java | 10 + .../service/geno/VariantService.java | 139 ++++++- .../service/geno/VariantSetService.java | 133 ++++++- 54 files changed, 3178 insertions(+), 624 deletions(-) create mode 100644 frontend/src/app/(app)/genotyping/reference-set/components/ReferenceSetTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/reference-set/components/ReferenceTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/reference-set/referenceUtils.ts create mode 100644 frontend/src/app/(app)/genotyping/reference-set/references/[referenceDbId]/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/reference-set/referencesets/[referenceSetDbId]/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant-set/api.ts create mode 100644 frontend/src/app/(app)/genotyping/variant-set/components/VariantSetTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant-set/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant-set/types.ts create mode 100644 frontend/src/app/(app)/genotyping/variant-set/variant-sets/[variantSetDbId]/page.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant/components/VariantTab.tsx create mode 100644 frontend/src/app/(app)/genotyping/variant/variants/[variantDbId]/page.tsx create mode 100644 frontend/src/constants/api.ts create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingVariantWriteController.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetWriteRequest.java create mode 100644 src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantWriteRequest.java diff --git a/README.md b/README.md index ae9c34d..1173392 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,44 @@ This server implements all BrAPI calls. It is backed by a custom database with d Use [/calls](https://test-server.brapi.org/brapi/v1/call) (V1) or [/serverinfo](https://test-server.brapi.org/brapi/v2/serverinfo) (V2) to check the available endpoints. ## Prerequisites -* Maven 3.9 -* Java 21 -* Postgres 17.2 + +| Tool | Required version | Notes | +| --- | --- | --- | +| **Java (JDK)** | **21** (class file 65.0) | Compile **and** run must use JDK 21. Java 17 will fail at startup. | +| **Maven** | **3.9.9+** | Used to build and run the backend. | +| **PostgreSQL** | **17.2** | Empty schema; tables/data load on first startup. | + +This repo ships pinned tooling under `.tools/` (Windows-friendly): + +- JDK: `.tools/jdk-21.0.11+10` (Temurin 21.0.11) +- Maven: `.tools/apache-maven-3.9.9` + +**PowerShell** (set env before `mvn`; do not use CMD `set` syntax in PowerShell): + +```powershell +$env:JAVA_HOME = "D:\maimaiproject\brapi-java\.tools\jdk-21.0.11+10" +$env:Path = "$env:JAVA_HOME\bin;" + $env:Path +java -version # should show 21.0.11 +mvn -version # Java version should also be 21 +``` + +**CMD**: + +```cmd +set JAVA_HOME=D:\maimaiproject\brapi-java\.tools\jdk-21.0.11+10 +set Path=%JAVA_HOME%\bin;%Path% +``` + +Or use the bundled Maven directly: + +```powershell +$env:JAVA_HOME = "D:\maimaiproject\brapi-java\.tools\jdk-21.0.11+10" +$env:Path = "$env:JAVA_HOME\bin;" + $env:Path +.\.tools\apache-maven-3.9.9\bin\mvn.cmd clean install +.\.tools\apache-maven-3.9.9\bin\mvn.cmd spring-boot:run +``` + +Adjust the path if your checkout is not `D:\maimaiproject\brapi-java`. ## Auth Configuration BrAPI has provided a [sample central authentication service for the test server](https://brapi.org/oauth). diff --git a/docs/dev/03-genotyping/06-variantset.md b/docs/dev/03-genotyping/06-variantset.md index 467af98..2f0b4fc 100644 --- a/docs/dev/03-genotyping/06-variantset.md +++ b/docs/dev/03-genotyping/06-variantset.md @@ -31,9 +31,16 @@ - VariantSet 列表页支持按 referenceSet、study、variantSetName 查询。 - 详情页展示 variants、callsets、analysis、available formats。 - 从 Study 工作台创建时默认带出 `study_id`。 +- **本版本不做**:单条删除、批量删除;放到下一版本实现。 ## 关键校验 1. `reference_set_id` 与下属 `variant.reference_set_id` 应保持一致。 -2. 删除 variantset 前检查 `variant`、`callset_variant_sets`、`variantset_analysis`、`variantset_format`。 -3. 导入大型 variantset 时建议先建 variantset,再异步导入 variants 和 calls。 +2. 导入大型 variantset 时建议先建 variantset,再异步导入 variants 和 calls。 +3. **下一版本再做**:删除 variantset 前检查 `variant`、`callset_variant_sets`、`variantset_analysis`、`variantset_format`(含单条删除与批量删除)。 + +## 开发状态 + +**已完成**(2026-05-28):列表查询、新增、编辑、详情。 + +**下一版本**:单条删除、批量删除。 diff --git a/docs/dev/03-genotyping/07-variant.md b/docs/dev/03-genotyping/07-variant.md index 9ef6a36..80f304d 100644 --- a/docs/dev/03-genotyping/07-variant.md +++ b/docs/dev/03-genotyping/07-variant.md @@ -47,11 +47,18 @@ ## 页面与交互 - Variant 列表页支持按 variantSet、referenceSet、variantName、variantType 查询。 -- 大批量位点建议通过文件导入,不建议普通表单逐条录入。 - 详情页展示 allele_call 数量和 marker_position 入口。 +- **本版本不做**:单条删除、批量删除、大批量文件导入;放到下一版本实现。 +- 本版本仅支持少量位点的表单逐条录入;大批量位点导入下一版本再做。 ## 关键校验 1. `variant` 是位点定义,不能把样本 genotype 写在本表。 2. `variant_set_id` 和 `reference_set_id` 应与所属 variantset 保持一致。 -3. 删除 variant 前检查 `allele_call` 和 `marker_position` 引用。 +3. **下一版本再做**:删除 variant 前检查 `allele_call` 和 `marker_position` 引用(含单条删除与批量删除)。 + +## 开发状态 + +**已完成**(2026-05-28):列表查询、新增、编辑、详情。 + +**下一版本**:单条删除、批量删除、大批量文件导入。 diff --git a/frontend/README.md b/frontend/README.md index 304a426..dd9ab60 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,5 +1,9 @@ # Frontend +## Prerequisites + +Backend API proxy targets `http://localhost:8081` (see root [README](../README.md): **Java 21 + Maven 3.9.9**). + ## Start ```bash diff --git a/frontend/src/app/(app)/basic-dictionary/base/list/api.ts b/frontend/src/app/(app)/basic-dictionary/base/list/api.ts index 7aca43f..db67118 100644 --- a/frontend/src/app/(app)/basic-dictionary/base/list/api.ts +++ b/frontend/src/app/(app)/basic-dictionary/base/list/api.ts @@ -109,7 +109,7 @@ export const mapListRecord = (list: ListSummary | ListDetails): ListRecord => ({ }); const toRequestBody = (payload: ListPayload, data?: string[]): ListNewRequest => ({ - listName: requiredText(payload.listName ?? payload.list_name, "请填写列表名称"), + listName: requiredText(payload.listName ?? payload.list_name, "???????"), listType: requiredListType(payload.listType ?? payload.list_type), listDescription: optionalText(payload.listDescription ?? payload.list_description), listSource: optionalText(payload.listSource ?? payload.list_source), @@ -119,7 +119,7 @@ const toRequestBody = (payload: ListPayload, data?: string[]): ListNewRequest => }); export async function fetchListRows(): Promise { - const response = await request>("/brapi/v2/lists?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/lists?page=0&pageSize=10"); return response.result.data.map(mapListRecord); } @@ -129,7 +129,7 @@ export async function fetchListDetail(listDbId: string): Promise { } export async function fetchPersonOptions(): Promise { - const response = await request>("/brapi/v2/people?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/people?page=0&pageSize=10"); return response.result.data.map((person) => { const name = [person.firstName, person.lastName].filter(Boolean).join(" ").trim(); const label = name @@ -199,7 +199,7 @@ export function normalizeNewItems(existing: string[], incoming: string[]): strin throw new Error(`以下列表项已存在:${duplicates.join("、")}`); } if (added.length === 0) { - throw new Error("请至少填写一个有效的列表项"); + throw new Error("请至少填写一个有效的列表"); } return added; } diff --git a/frontend/src/app/(app)/basic-dictionary/base/location/api.ts b/frontend/src/app/(app)/basic-dictionary/base/location/api.ts index 9f12744..687cf78 100644 --- a/frontend/src/app/(app)/basic-dictionary/base/location/api.ts +++ b/frontend/src/app/(app)/basic-dictionary/base/location/api.ts @@ -1,3 +1,4 @@ +import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api"; import { getAuthToken } from "@/utils/token"; import type { LocationRecord } from "@/services/dictionaryService"; @@ -29,7 +30,7 @@ async function request(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `请求失败:${response.status}`); + throw new Error(detail || `?????${response.status}`); } return response.json() as Promise; } @@ -63,7 +64,7 @@ const toRequestBody = (payload: LocationPayload) => ({ topography: emptyToNull(payload.topography), }); -export async function fetchLocationRows(page = 0, pageSize = 1000): Promise { +export async function fetchLocationRows(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise { const response = await request>>( `/brapi/v2/locations?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`, ); @@ -79,7 +80,7 @@ export async function fetchLocationDetail(locationDbId: string): Promise { if (!emptyToNull(payload.locationName)) { - throw new Error("请填写地点名称"); + throw new Error("???????"); } const response = await request>>("/brapi/v2/locations", { method: "POST", @@ -87,7 +88,7 @@ export async function createLocationRow(payload: LocationPayload): Promise(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `请求失败:${response.status}`); + throw new Error(detail || `?????${response.status}`); } return response.json() as Promise; } @@ -62,7 +63,7 @@ const toRequestBody = (payload: Record): OntologyPayload => ({ description: emptyToNull(payload.description), }); -export async function fetchOntologyRows(page = 0, pageSize = 1000): Promise { +export async function fetchOntologyRows(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise { const response = await request>( `/brapi/v2/ontologies?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`, ); @@ -79,7 +80,7 @@ export async function fetchOntologyDetail(ontologyDbId: string): Promise): Promise { const body = toRequestBody(payload); if (!body.ontologyName) { - throw new Error("请填写本体名称"); + throw new Error("???????"); } const response = await request>("/brapi/v2/ontologies", { method: "POST", @@ -87,7 +88,7 @@ export async function createOntologyRow(payload: Record): Promi }); const ontology = response.result.data[0]; if (!ontology) { - throw new Error("新增本体失败:后端未返回数据"); + throw new Error("??????????????"); } return mapOntology(ontology); } diff --git a/frontend/src/app/(app)/basic-dictionary/trait-method-scale/api.ts b/frontend/src/app/(app)/basic-dictionary/trait-method-scale/api.ts index bd31c6f..86d7c66 100644 --- a/frontend/src/app/(app)/basic-dictionary/trait-method-scale/api.ts +++ b/frontend/src/app/(app)/basic-dictionary/trait-method-scale/api.ts @@ -118,7 +118,7 @@ const compositeId = (kind: TraitMethodScaleKind, dbId: string) => `${kind}:${dbI const parseCompositeId = (id: string): { kind: TraitMethodScaleKind; dbId: string } => { const [kind, ...rest] = id.split(":"); if (kind !== "Trait" && kind !== "Method" && kind !== "Scale") { - throw new Error("标准项类型无效"); + throw new Error("标准项类型无"); } return { kind, dbId: rest.join(":") }; }; @@ -188,12 +188,12 @@ const payloadKind = (payload: TraitMethodScalePayload): TraitMethodScaleKind => const commonName = (payload: TraitMethodScalePayload) => { const name = optionalText(payload.name); - if (!name) throw new Error("请填写名称"); + if (!name) throw new Error("?????"); return name; }; export async function fetchOntologyOptions(): Promise { - const response = await request>("/brapi/v2/ontologies?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/ontologies?page=0&pageSize=10"); return response.result.data.map((ontology) => ({ value: ontology.ontologyDbId, label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`, @@ -202,24 +202,24 @@ export async function fetchOntologyOptions(): Promise { export async function fetchTraitMethodScaleRows(kind?: TraitMethodScaleKind): Promise { if (kind === "Trait") { - const response = await request>("/brapi/v2/traits?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/traits?page=0&pageSize=10"); return response.result.data.map(mapTrait); } if (kind === "Method") { - const response = await request>("/brapi/v2/methods?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/methods?page=0&pageSize=10"); return response.result.data.map(mapMethod); } if (kind === "Scale") { - const response = await request>("/brapi/v2/scales?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/scales?page=0&pageSize=10"); return response.result.data.map(mapScale); } const [traits, methods, scales] = await Promise.all([ - request>("/brapi/v2/traits?page=0&pageSize=1000"), - request>("/brapi/v2/methods?page=0&pageSize=1000"), - request>("/brapi/v2/scales?page=0&pageSize=1000"), + request>("/brapi/v2/traits?page=0&pageSize=10"), + request>("/brapi/v2/methods?page=0&pageSize=10"), + request>("/brapi/v2/scales?page=0&pageSize=10"), ]); return [ diff --git a/frontend/src/app/(app)/genotyping/reference-set/api.ts b/frontend/src/app/(app)/genotyping/reference-set/api.ts index db8c3b6..b1c70f7 100644 --- a/frontend/src/app/(app)/genotyping/reference-set/api.ts +++ b/frontend/src/app/(app)/genotyping/reference-set/api.ts @@ -3,7 +3,10 @@ import { getAuthToken } from "@/utils/token"; import { NONE_SELECT_VALUE, type ReferenceBasesRecord, + type ReferenceQuery, type ReferenceRecord, + type ReferenceSetPageOptions, + type ReferenceSetQuery, type ReferenceSetRecord, type SelectOption, } from "./types"; @@ -41,6 +44,11 @@ interface VariantSetResponse { referenceSetDbId: string | null; } +interface VariantResponse { + variantDbId: string; + referenceSetDbId: string | null; +} + type ReferenceSetPayload = Partial(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `Request failed: ${response.status}`); + throw new Error(detail || `请求失败:${response.status}`); } return response.json() as Promise; } @@ -105,7 +113,7 @@ const requiredText = (value: unknown, message: string) => { const optionalNumber = (value: unknown) => { const normalized = optionalText(value); - if (!normalized) return null; + if (normalized === null) return null; const parsed = Number(normalized); return Number.isNaN(parsed) ? null : parsed; }; @@ -125,19 +133,22 @@ const optionalUrl = (value: unknown, label: string) => { return normalized; }; -const validateBases = (value: unknown) => { +const validateBases = (value: unknown, required = false) => { const normalized = optionalText(value); - if (!normalized) return null; + if (!normalized) { + if (required) throw new Error("碱基序列片段不能为空"); + return null; + } if (normalized.length > 2048) { throw new Error("碱基序列片段不能超过 2048 字符"); } if (!BASES_PATTERN.test(normalized)) { - throw new Error("碱基序列仅允许 A/C/G/T/N 及常见占位符"); + throw new Error("碱基序列仅允�?A/C/G/T/N 及常见占位符"); } return normalized.toUpperCase(); }; -const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({ +export const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({ ...item, id: item.referenceSetDbId || item.id, reference_set_name: item.reference_set_name || item.referenceSetName || null, @@ -166,7 +177,7 @@ const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({ || null, }); -const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({ +export const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({ ...reference, id: reference.referenceDbId || reference.id, reference_name: reference.reference_name || reference.referenceName || null, @@ -175,7 +186,7 @@ const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({ source_divergence: reference.source_divergence ?? reference.sourceDivergence ?? null, }); -const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({ +export const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({ ...item, id: item.referenceBasesDbId || item.id, reference_id: item.reference_id || item.referenceDbId || null, @@ -183,63 +194,155 @@ const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => page_number: item.page_number ?? item.pageNumber ?? null, }); -const referenceSetBody = (payload: ReferenceSetPayload) => ({ - referenceSetName: requiredText(payload.reference_set_name, "ReferenceSet 名称不能为空"), - assemblyPUI: optionalText(payload.assembly_pui), - description: optionalText(payload.description), - isDerived: optionalBoolean(payload.is_derived), - md5checksum: optionalText(payload.md5checksum), - sourceURI: optionalUrl(payload.source_uri, "来源 URI"), - species: optionalText(payload.species_ontology_term) || optionalUrl(payload.species_ontology_termuri, "物种本体 URI") - ? { - term: optionalText(payload.species_ontology_term), - termURI: optionalUrl(payload.species_ontology_termuri, "物种本体 URI"), - } - : undefined, - sourceGermplasmDbId: optionalText(payload.source_germplasm_id), -}); +function buildSpecies(payload: ReferenceSetPayload) { + const term = optionalText(payload.species_ontology_term); + const termURI = optionalUrl(payload.species_ontology_termuri, "物种本体 URI"); + if (!term && !termURI) return undefined; + return { term, termURI }; +} -const referenceBody = (payload: ReferencePayload) => ({ - referenceName: requiredText(payload.reference_name, "Reference 名称不能为空"), - referenceSetDbId: requiredText(payload.reference_set_id, "ReferenceSet 不能为空"), - length: optionalNumber(payload.length), - md5checksum: optionalText(payload.md5checksum), - sourceDivergence: optionalNumber(payload.source_divergence), -}); +function buildReferenceSetWriteBody(payload: ReferenceSetPayload) { + const body: Record = { + referenceSetName: requiredText(payload.reference_set_name, "请填�?ReferenceSet 名称"), + }; + const optionalFields: Array<[string, unknown]> = [ + ["assemblyPUI", optionalText(payload.assembly_pui)], + ["description", optionalText(payload.description)], + ["isDerived", optionalBoolean(payload.is_derived)], + ["md5checksum", optionalText(payload.md5checksum)], + ["sourceURI", optionalUrl(payload.source_uri, "来源 URI")], + ["sourceGermplasmDbId", optionalText(payload.source_germplasm_id)], + ]; + optionalFields.forEach(([key, value]) => { + if (value !== null && value !== undefined) body[key] = value; + }); + const species = buildSpecies(payload); + if (species) body.species = species; + return body; +} -const referenceBasesBody = (payload: ReferenceBasesPayload) => ({ - referenceDbId: requiredText(payload.reference_id, "Reference 不能为空"), - pageNumber: optionalNumber(payload.page_number), - bases: validateBases(payload.bases), -}); +function buildReferenceWriteBody(payload: ReferencePayload) { + const body: Record = { + referenceName: requiredText(payload.reference_name, "请填�?Reference 名称"), + referenceSetDbId: requiredText(payload.reference_set_id, "请选择 ReferenceSet"), + }; + const length = optionalNumber(payload.length); + const sourceDivergence = optionalNumber(payload.source_divergence); + const md5checksum = optionalText(payload.md5checksum); + if (length !== null) { + if (length < 0) throw new Error("序列长度不能为负"); + body.length = length; + } + if (sourceDivergence !== null) body.sourceDivergence = sourceDivergence; + if (md5checksum) body.md5checksum = md5checksum; + return body; +} -const attachReferenceSetCounts = ( +function buildReferenceBasesWriteBody(payload: ReferenceBasesPayload, creating: boolean) { + const pageNumber = optionalNumber(payload.page_number); + if (pageNumber === null) throw new Error("请填写分页序"); + if (pageNumber < 0) throw new Error("分页序号不能为负"); + return { + referenceDbId: requiredText(payload.reference_id, "请选择 Reference"), + pageNumber, + bases: validateBases(payload.bases, creating), + }; +} + +function filterReferenceSets(rows: ReferenceSetRecord[], query?: ReferenceSetQuery) { + const name = optionalText(query?.reference_set_name)?.toLowerCase(); + const assembly = optionalText(query?.assembly_pui)?.toLowerCase(); + return rows.filter((row) => { + if (name && !String(row.reference_set_name ?? "").toLowerCase().includes(name)) return false; + if (assembly && !String(row.assembly_pui ?? "").toLowerCase().includes(assembly)) return false; + return true; + }); +} + +function filterReferences(rows: ReferenceRecord[], query?: ReferenceQuery) { + const name = optionalText(query?.reference_name)?.toLowerCase(); + const referenceSetId = optionalText(query?.reference_set_id); + return rows.filter((row) => { + if (referenceSetId && row.reference_set_id !== referenceSetId) return false; + if (name && !String(row.reference_name ?? "").toLowerCase().includes(name)) return false; + return true; + }); +} + +function attachReferenceSetCounts( rows: ReferenceSetRecord[], references: ReferenceRecord[], variantSets: VariantSetResponse[], -) => rows.map((row) => ({ - ...row, - reference_count: references.filter((item) => item.reference_set_id === row.id).length, - variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length, -})); + variants: VariantResponse[], +) { + return rows.map((row) => ({ + ...row, + reference_count: references.filter((item) => item.reference_set_id === row.id).length, + variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length, + variant_count: variants.filter((item) => item.referenceSetDbId === row.id).length, + })); +} + +function attachReferenceBasesStats(references: ReferenceRecord[], basesPages: ReferenceBasesRecord[]) { + return references.map((reference) => { + const pages = basesPages.filter((page) => page.reference_id === reference.id); + const basesTotalLength = pages.reduce((total, page) => total + String(page.bases ?? "").length, 0); + return { + ...reference, + bases_page_count: pages.length, + bases_total_length: basesTotalLength, + }; + }); +} + +function enrichReferenceSetNames( + references: ReferenceRecord[], + referenceSets: ReferenceSetRecord[], +): ReferenceRecord[] { + const label = new Map(referenceSets.map((item) => [item.id, item.reference_set_name || item.id])); + return references.map((reference) => ({ + ...reference, + reference_set_name: reference.reference_set_id + ? label.get(reference.reference_set_id) ?? reference.reference_set_name + : reference.reference_set_name, + })); +} + +function enrichGermplasmNames( + rows: ReferenceSetRecord[], + germplasm: SelectOption[], +): ReferenceSetRecord[] { + const label = new Map(germplasm.map((item) => [item.value, item.label])); + return rows.map((row) => ({ + ...row, + source_germplasm_name: row.source_germplasm_id + ? label.get(row.source_germplasm_id) ?? row.source_germplasm_name ?? row.source_germplasm_id + : row.source_germplasm_name, + })); +} const referenceSetRowsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/referencesets?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/referencesets?page=0&pageSize=10"); return response.result.data.map(mapReferenceSet); }); const referenceRowsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/references?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/references?page=0&pageSize=10"); return response.result.data.map(mapReference); }); const variantSetRowsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/variantsets?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/variantsets?page=0&pageSize=10"); + return response.result.data; +}); + +const variantRowsLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/variants?page=0&pageSize=10"); return response.result.data; }); const referenceBasesRowsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/referencebases?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/referencebases?page=0&pageSize=10"); return response.result.data.map(mapReferenceBases); }); @@ -247,32 +350,11 @@ export function invalidateReferenceSetPageCache() { referenceSetRowsLoader.invalidate(); referenceRowsLoader.invalidate(); variantSetRowsLoader.invalidate(); + variantRowsLoader.invalidate(); referenceBasesRowsLoader.invalidate(); } -export async function fetchReferenceSetRows(force = false): Promise { - const [referenceSets, references, variantSets] = await Promise.all([ - referenceSetRowsLoader.load(force), - referenceRowsLoader.load(force), - variantSetRowsLoader.load(force), - ]); - - return attachReferenceSetCounts(referenceSets, references, variantSets); -} - -export async function fetchReferenceRows(force = false): Promise { - return referenceRowsLoader.load(force); -} - -export async function fetchReferenceBasesRows(force = false): Promise { - return referenceBasesRowsLoader.load(force); -} - -export async function fetchReferenceSetOptions(force = false): Promise<{ - referenceSets: SelectOption[]; - references: SelectOption[]; - germplasm: SelectOption[]; -}> { +export async function fetchReferenceSetOptions(force = false): Promise { const [sharedOptions, referenceSets, references] = await Promise.all([ loadDropdownBundle({ germplasms: true }, force), referenceSetRowsLoader.load(force), @@ -292,13 +374,127 @@ export async function fetchReferenceSetOptions(force = false): Promise<{ }; } +export async function loadReferenceSetPageData(params: { + referenceSetQuery?: ReferenceSetQuery; + referenceQuery?: ReferenceQuery; + force?: boolean; +} = {}) { + const force = params.force ?? false; + const [options, referenceSets, references, variantSets, variants, basesPages] = await Promise.all([ + fetchReferenceSetOptions(force), + referenceSetRowsLoader.load(force), + referenceRowsLoader.load(force), + variantSetRowsLoader.load(force), + variantRowsLoader.load(force), + referenceBasesRowsLoader.load(force), + ]); + + const enrichedSets = enrichGermplasmNames( + attachReferenceSetCounts(referenceSets, references, variantSets, variants), + options.germplasm, + ); + const enrichedReferences = attachReferenceBasesStats( + enrichReferenceSetNames(references, referenceSets), + basesPages, + ); + + return { + options, + referenceSets: filterReferenceSets(enrichedSets, params.referenceSetQuery), + references: filterReferences(enrichedReferences, params.referenceQuery), + basesPages, + variantSets, + variants, + }; +} + +export async function fetchReferenceSetRows(query?: ReferenceSetQuery, force = false): Promise { + const { referenceSets } = await loadReferenceSetPageData({ referenceSetQuery: query, force }); + return referenceSets; +} + +export async function fetchReferenceRows(query?: ReferenceQuery, force = false): Promise { + const { references } = await loadReferenceSetPageData({ referenceQuery: query, force }); + return references; +} + +export async function fetchReferenceSetDetail(id: string): Promise { + const response = await request>( + `/brapi/v2/referencesets/${encodeURIComponent(id)}`, + ); + const { options, references, variantSets, variants } = await loadReferenceSetPageData(); + const [detail] = enrichGermplasmNames( + attachReferenceSetCounts([mapReferenceSet(response.result)], references, variantSets, variants), + options.germplasm, + ); + return detail; +} + +export async function fetchReferenceDetail(id: string): Promise { + const response = await request>( + `/brapi/v2/references/${encodeURIComponent(id)}`, + ); + const { referenceSets, basesPages } = await loadReferenceSetPageData(); + const [detail] = attachReferenceBasesStats( + enrichReferenceSetNames([mapReference(response.result)], referenceSets), + basesPages, + ); + return detail; +} + +export async function fetchReferenceBasesRows(referenceDbId?: string, force = false): Promise { + const pages = await referenceBasesRowsLoader.load(force); + const filtered = referenceDbId + ? pages.filter((page) => page.reference_id === referenceDbId) + : pages; + return filtered.sort((a, b) => Number(a.page_number ?? 0) - Number(b.page_number ?? 0)); +} + +export function normalizeReferenceSetFormData(record: ReferenceSetRecord): Record { + return { + id: record.id, + reference_set_name: record.reference_set_name ?? "", + assembly_pui: record.assembly_pui ?? "", + description: record.description ?? "", + is_derived: record.is_derived === true ? "true" : record.is_derived === false ? "false" : NONE_SELECT_VALUE, + md5checksum: record.md5checksum ?? "", + source_uri: record.source_uri ?? "", + species_ontology_term: record.species_ontology_term ?? "", + species_ontology_termuri: record.species_ontology_termuri ?? "", + source_germplasm_id: record.source_germplasm_id && record.source_germplasm_id !== NONE_SELECT_VALUE + ? record.source_germplasm_id + : NONE_SELECT_VALUE, + }; +} + +export function normalizeReferenceFormData(record: ReferenceRecord): Record { + return { + id: record.id, + reference_name: record.reference_name ?? "", + reference_set_id: record.reference_set_id ?? "", + length: record.length ?? "", + md5checksum: record.md5checksum ?? "", + source_divergence: record.source_divergence ?? "", + }; +} + +export function normalizeReferenceBasesFormData(record: ReferenceBasesRecord): Record { + return { + id: record.id, + reference_id: record.reference_id ?? "", + page_number: record.page_number ?? "", + bases: record.bases ?? "", + }; +} + export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise { + const body = { + ...buildReferenceSetWriteBody(payload), + ...(optionalText(payload.id) ? { referenceSetDbId: optionalText(payload.id) } : {}), + }; const response = await request>("/brapi/v2/referencesets", { method: "POST", - body: JSON.stringify({ - referenceSetDbId: requiredText(payload.id, "ReferenceSet ID 不能为空"), - ...referenceSetBody(payload), - }), + body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReferenceSet(response.result.data[0]); @@ -313,7 +509,7 @@ export async function updateReferenceSetRow(id: string, payload: ReferenceSetPay `/brapi/v2/referencesets/${encodeURIComponent(id)}`, { method: "PUT", - body: JSON.stringify(referenceSetBody(payload)), + body: JSON.stringify(buildReferenceSetWriteBody(payload)), }, ); invalidateReferenceSetPageCache(); @@ -329,12 +525,13 @@ export async function deleteReferenceSetRow(id: string): Promise { } export async function createReferenceRow(payload: ReferencePayload): Promise { + const body = { + ...buildReferenceWriteBody(payload), + ...(optionalText(payload.id) ? { referenceDbId: optionalText(payload.id) } : {}), + }; const response = await request>("/brapi/v2/references", { method: "POST", - body: JSON.stringify({ - referenceDbId: requiredText(payload.id, "Reference ID 不能为空"), - ...referenceBody(payload), - }), + body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReference(response.result.data[0]); @@ -349,7 +546,7 @@ export async function updateReferenceRow(id: string, payload: ReferencePayload): `/brapi/v2/references/${encodeURIComponent(id)}`, { method: "PUT", - body: JSON.stringify(referenceBody(payload)), + body: JSON.stringify(buildReferenceWriteBody(payload)), }, ); invalidateReferenceSetPageCache(); @@ -365,12 +562,13 @@ export async function deleteReferenceRow(id: string): Promise { } export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise { + const body = { + ...buildReferenceBasesWriteBody(payload, true), + ...(optionalText(payload.id) ? { referenceBasesDbId: optionalText(payload.id) } : {}), + }; const response = await request>("/brapi/v2/referencebases", { method: "POST", - body: JSON.stringify({ - referenceBasesDbId: requiredText(payload.id, "ReferenceBases ID 不能为空"), - ...referenceBasesBody(payload), - }), + body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReferenceBases(response.result.data[0]); @@ -385,7 +583,7 @@ export async function updateReferenceBasesRow(id: string, payload: ReferenceBase `/brapi/v2/referencebases/${encodeURIComponent(id)}`, { method: "PUT", - body: JSON.stringify(referenceBasesBody(payload)), + body: JSON.stringify(buildReferenceBasesWriteBody(payload, false)), }, ); invalidateReferenceSetPageCache(); diff --git a/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceSetTab.tsx b/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceSetTab.tsx new file mode 100644 index 0000000..ee35d88 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceSetTab.tsx @@ -0,0 +1,164 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import { Layers, RotateCcw, Search } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createReferenceSetRow, + deleteReferenceSetRow, + fetchReferenceSetDetail, + loadReferenceSetPageData, + normalizeReferenceSetFormData, + updateReferenceSetRow, +} from "../api"; +import { boolLabel, optionOrNone, warnMd5Checksum } from "../referenceUtils"; +import { NONE_SELECT_VALUE, type ReferenceSetQuery, type SelectOption } from "../types"; + +const booleanOptions: SelectOption[] = [ + { value: NONE_SELECT_VALUE, label: "不指定" }, + { value: "true", label: "是" }, + { value: "false", label: "否" }, +]; + +const emptyQuery = (): ReferenceSetQuery => ({ + reference_set_name: "", + assembly_pui: "", +}); + +export function ReferenceSetTab() { + const [germplasmOptions, setGermplasmOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); + + const loadRows = useCallback(async () => { + const { options, referenceSets } = await loadReferenceSetPageData({ referenceSetQuery: appliedQuery }); + setGermplasmOptions(options.germplasm); + return referenceSets as unknown as Record[]; + }, [appliedQuery]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchReferenceSetDetail(id); + return normalizeReferenceSetFormData(detail); + }, []); + + const fields = useMemo(() => [ + { key: "id", label: "ReferenceSet ID", type: "text", placeholder: "留空则系统自动生成(导入时可指定)" }, + { + key: "reference_set_name", + label: "参考集合名称", + type: "text", + required: true, + placeholder: "如 Maize B73 v4", + }, + { key: "assembly_pui", label: "Assembly PUI", type: "text", placeholder: "GA4GH 永久标识" }, + { key: "description", label: "说明", type: "textarea", colSpan: 2, placeholder: "参考集合说明" }, + { key: "is_derived", label: "是否派生参考", type: "select", options: booleanOptions }, + { key: "md5checksum", label: "MD5 校验值", type: "text", placeholder: "32 位十六进制(建议)" }, + { key: "source_uri", label: "来源 URI", type: "text", placeholder: "https://..." }, + { key: "species_ontology_term", label: "物种本体 Term", type: "text", placeholder: "Zea mays" }, + { key: "species_ontology_termuri", label: "物种本体 URI", type: "text", placeholder: "https://..." }, + { + key: "source_germplasm_id", + label: "来源 Germplasm", + type: "select", + options: optionOrNone("不关联 Germplasm", germplasmOptions), + }, + ], [germplasmOptions]); + + const renderFormExtra = useCallback((props: { formData: Record }) => { + const md5Warning = warnMd5Checksum(props.formData.md5checksum); + return ( +
+

校验说明

+

删除前服务端会检查是否仍被 Reference / VariantSet / Variant 引用。来源 Germplasm 必须已存在。

+ {md5Warning ?

{md5Warning}

: null} +
+ ); + }, []); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, reference_set_name: event.target.value }))} + placeholder="名称模糊匹配" + /> +
+
+ + setDraftQuery((current) => ({ ...current, assembly_pui: event.target.value }))} + placeholder="PUI 模糊匹配" + /> +
+
+
+ + +
+
+ ), [draftQuery]); + + return ( + { + const id = String(row.id ?? row.referenceSetDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "assembly_pui", label: "Assembly PUI" }, + { key: "species_ontology_term", label: "物种" }, + { key: "source_germplasm_name", label: "来源 Germplasm" }, + { key: "reference_count", label: "Reference 数" }, + { key: "variantset_count", label: "VariantSet 数" }, + { key: "variant_count", label: "Variant 数" }, + { key: "is_derived", label: "派生", render: boolLabel }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/referencesets", + value: "BrAPI", + className: "bg-indigo-50 text-indigo-700 dark:bg-indigo-400/10 dark:text-indigo-200", + }]} + loadData={loadRows} + fetchRecord={fetchRecord} + createRecord={(payload) => createReferenceSetRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateReferenceSetRow(id, payload) as unknown as Promise>} + deleteRecord={deleteReferenceSetRow} + renderQueryForm={() => renderQueryForm()} + renderFormExtra={renderFormExtra} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceTab.tsx b/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceTab.tsx new file mode 100644 index 0000000..37e96df --- /dev/null +++ b/frontend/src/app/(app)/genotyping/reference-set/components/ReferenceTab.tsx @@ -0,0 +1,168 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { BookOpen, RotateCcw, Search } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createReferenceRow, + deleteReferenceRow, + fetchReferenceDetail, + loadReferenceSetPageData, + normalizeReferenceFormData, + updateReferenceRow, +} from "../api"; +import { NONE_SELECT_VALUE, type ReferenceQuery, type SelectOption } from "../types"; + +const emptyQuery = (): ReferenceQuery => ({ + reference_name: "", + reference_set_id: NONE_SELECT_VALUE, +}); + +function toSelectValue(value: string | null | undefined) { + return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE; +} + +export function ReferenceTab() { + const searchParams = useSearchParams(); + const [referenceSetOptions, setReferenceSetOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + reference_set_id: searchParams.get("reference_set_id") ?? NONE_SELECT_VALUE, + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...emptyQuery(), + reference_set_id: searchParams.get("reference_set_id") ?? NONE_SELECT_VALUE, + })); + + const urlDefaultFormValues = useMemo(() => { + const referenceSetId = searchParams.get("reference_set_id"); + return referenceSetId ? { reference_set_id: referenceSetId } : undefined; + }, [searchParams]); + + const loadRows = useCallback(async () => { + const { options, references } = await loadReferenceSetPageData({ referenceQuery: appliedQuery }); + setReferenceSetOptions(options.referenceSets); + return references as unknown as Record[]; + }, [appliedQuery]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchReferenceDetail(id); + return normalizeReferenceFormData(detail); + }, []); + + const fields = useMemo(() => [ + { key: "id", label: "Reference ID", type: "text", placeholder: "留空则系统自动生成(导入时可指定)" }, + { key: "reference_name", label: "参考序列名称", type: "text", required: true, placeholder: "如 chr1" }, + { + key: "reference_set_id", + label: "ReferenceSet", + type: "select", + required: true, + options: referenceSetOptions, + }, + { key: "length", label: "序列长度", type: "number", placeholder: "非负整数" }, + { key: "source_divergence", label: "来源差异度", type: "number", placeholder: "0.01" }, + { key: "md5checksum", label: "MD5", type: "text", placeholder: "md5 checksum", colSpan: 2 }, + ], [referenceSetOptions]); + + const renderFormExtra = useCallback(() => ( +
+

校验说明

+

ReferenceSet 必选且必须存在。删除前服务端会检查 ReferenceBases 引用。详情页可维护序列分页。

+
+ ), []); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, reference_name: event.target.value }))} + placeholder="名称模糊匹配" + /> +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, referenceSetOptions]); + + return ( + { + const id = String(row.id ?? row.referenceDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "reference_set_name", label: "ReferenceSet" }, + { key: "length", label: "长度" }, + { key: "bases_page_count", label: "Bases 分页数" }, + { key: "bases_total_length", label: "Bases 总长度" }, + { key: "source_divergence", label: "来源差异" }, + { key: "md5checksum", label: "MD5" }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/references", + value: "BrAPI", + className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200", + }]} + loadData={loadRows} + fetchRecord={fetchRecord} + createRecord={(payload) => createReferenceRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateReferenceRow(id, payload) as unknown as Promise>} + deleteRecord={deleteReferenceRow} + renderQueryForm={() => renderQueryForm()} + renderFormExtra={renderFormExtra} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/page.tsx b/frontend/src/app/(app)/genotyping/reference-set/page.tsx index 7054a3a..0590729 100644 --- a/frontend/src/app/(app)/genotyping/reference-set/page.tsx +++ b/frontend/src/app/(app)/genotyping/reference-set/page.tsx @@ -1,159 +1,23 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { BookOpen, Dna, Layers } from "lucide-react"; -import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { BookOpen, Layers } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - createReferenceBasesRow, - createReferenceRow, - createReferenceSetRow, - deleteReferenceBasesRow, - deleteReferenceRow, - deleteReferenceSetRow, - fetchReferenceBasesRows, - fetchReferenceRows, - fetchReferenceSetOptions, - fetchReferenceSetRows, - updateReferenceBasesRow, - updateReferenceRow, - updateReferenceSetRow, -} from "./api"; -import { NONE_SELECT_VALUE, type SelectOption } from "./types"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ReferenceSetTab } from "./components/ReferenceSetTab"; +import { ReferenceTab } from "./components/ReferenceTab"; -const booleanOptions: SelectOption[] = [ - { value: NONE_SELECT_VALUE, label: "不指定" }, - { value: "true", label: "是" }, - { value: "false", label: "否" }, -]; - -const optionOrNone = (label: string, options: SelectOption[]) => [ - { value: NONE_SELECT_VALUE, label }, - ...options, -]; - -const boolLabel = (value: unknown) => { - if (value === true) return "是"; - if (value === false) return "否"; - return "N/A"; -}; - -const truncateBases = (value: unknown) => { - const text = String(value ?? "").trim(); - if (!text) return "N/A"; - return text.length > 32 ? `${text.slice(0, 32)}…` : text; -}; - -export default function ReferenceSetPage() { +function ReferenceSetPageContent() { + const searchParams = useSearchParams(); const [tab, setTab] = useState("reference-sets"); - const [referenceSetOptions, setReferenceSetOptions] = useState([]); - const [referenceOptions, setReferenceOptions] = useState([]); - const [germplasmOptions, setGermplasmOptions] = useState([]); - - const applyOptions = useCallback((options: Awaited>) => { - setReferenceSetOptions(options.referenceSets); - setReferenceOptions(options.references); - setGermplasmOptions(options.germplasm); - return options; - }, []); - - const refreshOptions = useCallback(async (force = false) => { - const options = await fetchReferenceSetOptions(force); - applyOptions(options); - return options; - }, [applyOptions]); useEffect(() => { - let mounted = true; - refreshOptions() - .catch(() => undefined) - .finally(() => { - if (!mounted) return; - }); - return () => { - mounted = false; - }; - }, [refreshOptions]); - - const loadReferenceSets = useCallback(async () => { - const rows = await fetchReferenceSetRows(); - return rows as unknown as Record[]; - }, []); - - const loadReferences = useCallback(async () => { - const rows = await fetchReferenceRows(); - return rows as unknown as Record[]; - }, []); - - const loadReferenceBases = useCallback(async () => { - const rows = await fetchReferenceBasesRows(); - return rows as unknown as Record[]; - }, []); - - const refreshAfterMutation = useCallback(async (action: () => Promise) => { - const result = await action(); - await refreshOptions(true); - return result; - }, [refreshOptions]); - - const referenceSetFields = useMemo(() => [ - { key: "id", label: "ReferenceSet ID", type: "text", required: true, placeholder: "refset-001" }, - { - key: "reference_set_name", - label: "参考集合名称", - type: "text", - required: true, - placeholder: "Maize B73 v4", - }, - { key: "assembly_pui", label: "Assembly PUI", type: "text", placeholder: "GA4GH 永久标识" }, - { key: "description", label: "说明", type: "textarea", colSpan: 2, placeholder: "参考集合说明" }, - { key: "is_derived", label: "是否派生参考", type: "select", options: booleanOptions }, - { key: "md5checksum", label: "MD5 校验值", type: "text", placeholder: "md5 checksum" }, - { key: "source_uri", label: "来源 URI", type: "text", placeholder: "https://..." }, - { key: "species_ontology_term", label: "物种本体 Term", type: "text", placeholder: "Zea mays" }, - { key: "species_ontology_termuri", label: "物种本体 URI", type: "text", placeholder: "https://..." }, - { - key: "source_germplasm_id", - label: "来源 Germplasm", - type: "select", - options: optionOrNone("不关联 Germplasm", germplasmOptions), - }, - ], [germplasmOptions]); - - const referenceFields = useMemo(() => [ - { key: "id", label: "Reference ID", type: "text", required: true, placeholder: "reference-001" }, - { key: "reference_name", label: "参考序列名称", type: "text", required: true, placeholder: "chr1" }, - { - key: "reference_set_id", - label: "ReferenceSet", - type: "select", - required: true, - options: referenceSetOptions, - }, - { key: "length", label: "序列长度", type: "number", placeholder: "1000000" }, - { key: "source_divergence", label: "来源差异", type: "number", placeholder: "0.01" }, - { key: "md5checksum", label: "MD5", type: "text", placeholder: "md5 checksum", colSpan: 2 }, - ], [referenceSetOptions]); - - const referenceBasesFields = useMemo(() => [ - { key: "id", label: "ReferenceBases ID", type: "text", required: true, placeholder: "refbases-001" }, - { - key: "reference_id", - label: "Reference", - type: "select", - required: true, - options: referenceOptions, - }, - { key: "page_number", label: "分页序号", type: "number", required: true, placeholder: "0" }, - { - key: "bases", - label: "碱基序列片段", - type: "textarea", - required: true, - colSpan: 2, - placeholder: "ACGT...(最多 2048 字符)", - }, - ], [referenceOptions]); + const nextTab = searchParams.get("tab"); + if (nextTab === "references" || nextTab === "reference-sets") { + setTab(nextTab); + } + }, [searchParams]); return ( @@ -166,116 +30,31 @@ export default function ReferenceSetPage() { Reference - - - ReferenceBases - {tab === "reference-sets" ? ( - refreshAfterMutation(() => createReferenceSetRow(payload)) as unknown as Promise>} - updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceSetRow(id, payload)) as unknown as Promise>} - deleteRecord={async (id) => { - await deleteReferenceSetRow(id); - await refreshOptions(true); - }} - /> + ) : null} {tab === "references" ? ( - refreshAfterMutation(() => createReferenceRow(payload)) as unknown as Promise>} - updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceRow(id, payload)) as unknown as Promise>} - deleteRecord={async (id) => { - await deleteReferenceRow(id); - await refreshOptions(true); - }} - /> - - ) : null} - - {tab === "reference-bases" ? ( - - refreshAfterMutation(() => createReferenceBasesRow(payload)) as unknown as Promise>} - updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceBasesRow(id, payload)) as unknown as Promise>} - deleteRecord={async (id) => { - await deleteReferenceBasesRow(id); - await refreshOptions(true); - }} - /> + ) : null} ); } + +function PageFallback() { + return ; +} + +export default function ReferenceSetPage() { + return ( + }> + + + ); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/referenceUtils.ts b/frontend/src/app/(app)/genotyping/reference-set/referenceUtils.ts new file mode 100644 index 0000000..235c96f --- /dev/null +++ b/frontend/src/app/(app)/genotyping/reference-set/referenceUtils.ts @@ -0,0 +1,38 @@ +import { NONE_SELECT_VALUE, type SelectOption } from "./types"; + +export const MD5_PATTERN = /^[a-fA-F0-9]{32}$/; + +export function optionOrNone(label: string, options: SelectOption[]) { + return [{ value: NONE_SELECT_VALUE, label }, ...options]; +} + +export function optionLabel(options: SelectOption[], value: unknown) { + const text = String(value ?? "").trim(); + if (!text || text === NONE_SELECT_VALUE) return "—"; + return options.find((option) => option.value === text)?.label || text; +} + +export function boolLabel(value: unknown) { + if (value === true || value === "true") return "是"; + if (value === false || value === "false") return "否"; + return "—"; +} + +export function truncateText(value: unknown, max = 32) { + const text = String(value ?? "").trim(); + if (!text) return "—"; + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +export function warnMd5Checksum(value: unknown) { + const normalized = String(value ?? "").trim(); + if (!normalized) return null; + if (!MD5_PATTERN.test(normalized)) { + return "MD5 建议为 32 位十六进制,当前格式可能不规范(不阻断保存)"; + } + return null; +} + +export function sumBasesLength(pages: Array<{ bases?: string | null }>) { + return pages.reduce((total, page) => total + String(page.bases ?? "").length, 0); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/references/[referenceDbId]/page.tsx b/frontend/src/app/(app)/genotyping/reference-set/references/[referenceDbId]/page.tsx new file mode 100644 index 0000000..bc39c0f --- /dev/null +++ b/frontend/src/app/(app)/genotyping/reference-set/references/[referenceDbId]/page.tsx @@ -0,0 +1,165 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, BookOpen, Dna } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + createReferenceBasesRow, + deleteReferenceBasesRow, + fetchReferenceBasesRows, + fetchReferenceDetail, + normalizeReferenceBasesFormData, + updateReferenceBasesRow, +} from "../../api"; +import { truncateText } from "../../referenceUtils"; +import type { ReferenceRecord } from "../../types"; + +export default function ReferenceDetailPage() { + const params = useParams<{ referenceDbId: string }>(); + const referenceDbId = decodeURIComponent(params.referenceDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + fetchReferenceDetail(referenceDbId) + .then((record) => { + if (!mounted) return; + setDetail(record); + }) + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载 Reference 详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [referenceDbId]); + + const defaultFormValues = useMemo(() => ({ reference_id: referenceDbId }), [referenceDbId]); + + const loadBases = useCallback(async () => { + const rows = await fetchReferenceBasesRows(referenceDbId); + return rows as unknown as Record[]; + }, [referenceDbId]); + + const fetchRecord = useCallback(async (id: string) => { + const rows = await fetchReferenceBasesRows(referenceDbId); + const found = rows.find((row) => row.id === id); + if (!found) throw new Error("ReferenceBases 记录不存在"); + return normalizeReferenceBasesFormData({ ...found, reference_id: referenceDbId }); + }, [referenceDbId]); + + const fields = useMemo(() => [ + { key: "id", label: "ReferenceBases ID", type: "text", placeholder: "留空则系统自动生成" }, + { key: "page_number", label: "分页序号", type: "number", required: true, placeholder: "0 起" }, + { + key: "bases", + label: "碱基序列片段", + type: "textarea", + required: true, + colSpan: 2, + placeholder: "ACGT...(最多 2048 字符,建议文件导入)", + }, + ], []); + + const lengthMismatch = detail?.length != null + && detail.bases_total_length != null + && Number(detail.length) > 0 + && Number(detail.bases_total_length) !== Number(detail.length); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "Reference 不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.reference_name || detail.id} + + + +
Reference ID:{detail.id}
+
ReferenceSet:{detail.reference_set_name || detail.reference_set_id || "—"}
+
序列长度:{detail.length ?? "—"}
+
MD5:{detail.md5checksum || "—"}
+
来源差异:{detail.source_divergence ?? "—"}
+
Bases 分页数:{detail.bases_page_count ?? 0}
+
Bases 总长度:{detail.bases_total_length ?? 0}
+
+
+ + {lengthMismatch ? ( + + Bases 总长度 ({detail.bases_total_length}) 与声明长度 ({detail.length}) 不一致,请检查分页数据 + + ) : null} + + truncateText(value) }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/referencebases", + value: "Admin", + className: "bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-200", + }]} + loadData={loadBases} + fetchRecord={fetchRecord} + createRecord={(payload) => createReferenceBasesRow({ ...payload, reference_id: referenceDbId }) as unknown as Promise>} + updateRecord={(id, payload) => updateReferenceBasesRow(id, { ...payload, reference_id: referenceDbId }) as unknown as Promise>} + deleteRecord={deleteReferenceBasesRow} + /> +
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/referencesets/[referenceSetDbId]/page.tsx b/frontend/src/app/(app)/genotyping/reference-set/referencesets/[referenceSetDbId]/page.tsx new file mode 100644 index 0000000..4ed927f --- /dev/null +++ b/frontend/src/app/(app)/genotyping/reference-set/referencesets/[referenceSetDbId]/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, BookOpen, Layers, Sigma } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + fetchReferenceRows, + fetchReferenceSetDetail, +} from "../../api"; +import { boolLabel } from "../../referenceUtils"; +import type { ReferenceRecord, ReferenceSetRecord } from "../../types"; + +export default function ReferenceSetDetailPage() { + const params = useParams<{ referenceSetDbId: string }>(); + const referenceSetDbId = decodeURIComponent(params.referenceSetDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState(null); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + fetchReferenceSetDetail(referenceSetDbId) + .then((record) => { + if (!mounted) return; + setDetail(record); + }) + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载 ReferenceSet 详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [referenceSetDbId]); + + const loadReferences = useCallback(async () => { + const rows = await fetchReferenceRows({ reference_set_id: referenceSetDbId }); + return rows as unknown as Record[]; + }, [referenceSetDbId]); + + const referenceColumns = useMemo(() => [ + { + key: "reference_name", + label: "序列名称", + render: (value: unknown, row: Record) => { + const id = String(row.id ?? row.referenceDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "length", label: "长度" }, + { key: "bases_page_count", label: "Bases 分页数" }, + { key: "md5checksum", label: "MD5" }, + ], []); + + const emptyFields = useMemo(() => [], []); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "ReferenceSet 不存在"} +
+ +
+
+ ); + } + + return ( +
+ + + + + + + {detail.reference_set_name || detail.id} + + + +
ReferenceSet ID:{detail.id}
+
Assembly PUI:{detail.assembly_pui || "—"}
+
物种:{detail.species_ontology_term || "—"}
+
来源 Germplasm:{detail.source_germplasm_name || "—"}
+
Reference 数:{detail.reference_count ?? 0}
+
VariantSet 数:{detail.variantset_count ?? 0}
+
Variant 数:{detail.variant_count ?? 0}
+
派生参考:{boolLabel(detail.is_derived)}
+
说明:{detail.description || "—"}
+
+
+ +
+ + +
+ + +
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/reference-set/types.ts b/frontend/src/app/(app)/genotyping/reference-set/types.ts index a509831..c538cbf 100644 --- a/frontend/src/app/(app)/genotyping/reference-set/types.ts +++ b/frontend/src/app/(app)/genotyping/reference-set/types.ts @@ -5,6 +5,16 @@ export interface SelectOption { label: string; } +export interface ReferenceSetQuery { + reference_set_name?: string; + assembly_pui?: string; +} + +export interface ReferenceQuery { + reference_name?: string; + reference_set_id?: string; +} + export interface ReferenceSetRecord { id: string; referenceSetDbId: string; @@ -28,6 +38,7 @@ export interface ReferenceSetRecord { source_germplasm_name: string | null; reference_count?: number | null; variantset_count?: number | null; + variant_count?: number | null; } export interface ReferenceRecord { @@ -43,6 +54,8 @@ export interface ReferenceRecord { md5checksum: string | null; sourceDivergence: number | string | null; source_divergence: number | string | null; + bases_page_count?: number | null; + bases_total_length?: number | null; } export interface ReferenceBasesRecord { @@ -56,3 +69,9 @@ export interface ReferenceBasesRecord { pageNumber: number | string | null; bases: string | null; } + +export interface ReferenceSetPageOptions { + referenceSets: SelectOption[]; + references: SelectOption[]; + germplasm: SelectOption[]; +} diff --git a/frontend/src/app/(app)/genotyping/sample-plate/api.ts b/frontend/src/app/(app)/genotyping/sample-plate/api.ts index 0baacbf..7e263b2 100644 --- a/frontend/src/app/(app)/genotyping/sample-plate/api.ts +++ b/frontend/src/app/(app)/genotyping/sample-plate/api.ts @@ -143,7 +143,7 @@ async function request(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `请求失败:${response.status}`); + throw new Error(detail || `?????${response.status}`); } return response.json() as Promise; } @@ -168,7 +168,7 @@ const optionalNumber = (value: unknown) => { }; const trialContextLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/trials?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/trials?page=0&pageSize=10"); return response.result.data.map((trial) => ({ value: trial.trialDbId, label: trial.trialName || trial.trialDbId, @@ -177,7 +177,7 @@ const trialContextLoader = createCachedLoader(async () => { }); const studyContextLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/studies?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/studies?page=0&pageSize=10"); return response.result.data.map((study) => ({ value: study.studyDbId, label: study.studyName || study.studyDbId, @@ -188,7 +188,7 @@ const studyContextLoader = createCachedLoader(async () => { const observationUnitContextLoader = createCachedLoader(async () => { const response = await request>( - "/brapi/v2/observationunits?page=0&pageSize=1000", + "/brapi/v2/observationunits?page=0&pageSize=10", ); return response.result.data.map((unit) => ({ value: unit.observationUnitDbId, @@ -198,12 +198,12 @@ const observationUnitContextLoader = createCachedLoader(async () => { }); const plateRowsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/plates?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/plates?page=0&pageSize=10"); return response.result.data.map(mapPlate); }); const sampleRowsAllLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/samples?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/samples?page=0&pageSize=10"); return response.result.data.map(mapSample); }); @@ -238,7 +238,7 @@ function hasSampleServerFilter(query?: SampleQuery, plateDbId?: string) { async function loadRawPlates(query?: PlateQuery, force = false): Promise { if (hasPlateServerFilter(query)) { - const params = new URLSearchParams({ page: "0", pageSize: "1000" }); + const params = new URLSearchParams({ page: "0", pageSize: "10" }); if (query?.program_id && query.program_id !== NONE_SELECT_VALUE) params.set("programDbId", query.program_id); if (query?.trial_id && query.trial_id !== NONE_SELECT_VALUE) params.set("trialDbId", query.trial_id); if (query?.study_id && query.study_id !== NONE_SELECT_VALUE) params.set("studyDbId", query.study_id); @@ -251,7 +251,7 @@ async function loadRawPlates(query?: PlateQuery, force = false): Promise { if (hasSampleServerFilter(query, plateDbId)) { - const params = new URLSearchParams({ page: "0", pageSize: "1000" }); + const params = new URLSearchParams({ page: "0", pageSize: "10" }); const effectivePlateId = plateDbId || optionalText(query?.plate_id); if (effectivePlateId) params.set("plateDbId", effectivePlateId); if (query?.sample_name) params.set("sampleName", query.sample_name); @@ -382,7 +382,7 @@ function filterPlates(plates: PlateRecord[], query: PlateQuery) { const plateBody = (payload: PlatePayload) => { const body: Record = { - plateName: requiredText(payload.plate_name, "请填写样本板名称"), + plateName: requiredText(payload.plate_name, "????????"), }; const plateBarcode = optionalText(payload.plate_barcode); const plateFormat = optionalText(payload.plate_format); @@ -409,7 +409,7 @@ const sampleBody = (payload: SamplePayload, plateFormat?: string | null) => { validatePlateWell(plateFormat ?? null, row, column, well); const body: Record = { - sampleName: requiredText(payload.sample_name, "请填写样本名称"), + sampleName: requiredText(payload.sample_name, "???????"), }; const optionalFields: Array<[string, unknown]> = [ @@ -675,15 +675,15 @@ export function normalizeSampleFormData(record: SampleRecord): Record { const callsetCount = await countCallsetsBySample(id); if (callsetCount > 0) { - throw new Error(`该样本已有 ${callsetCount} 个 CallSet 关联,无法删除。请先处理下游基因型数据。`); + throw new Error(`????? ${callsetCount} ? CallSet ????????????????????`); } - throw new Error("BrAPI Samples 接口暂不支持 DELETE,请在后端扩展删除能力后再启用"); + throw new Error("BrAPI Samples ?????? DELETE???????????????"); } export async function deletePlateRow(id: string): Promise { const count = await countSamplesByPlate(id); if (count > 0) { - throw new Error(`该样本板下仍有 ${count} 个样本,请先迁移或删除样本后再操作。BrAPI 暂不支持 DELETE /plates`); + throw new Error(`??????? ${count} ??????????????????BrAPI ???? DELETE /plates`); } - throw new Error("BrAPI Plates 接口暂不支持 DELETE,请在后端扩展删除能力后再启用"); + throw new Error("BrAPI Plates ?????? DELETE???????????????"); } diff --git a/frontend/src/app/(app)/genotyping/variant-set/api.ts b/frontend/src/app/(app)/genotyping/variant-set/api.ts new file mode 100644 index 0000000..b3c608c --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/api.ts @@ -0,0 +1,294 @@ +import { createCachedLoader, loadStudyOptions, type SelectOption as CachedSelectOption } from "@/services/dropdownCache"; +import { getAuthToken } from "@/utils/token"; +import { + NONE_SELECT_VALUE, + type SelectOption, + type VariantSetDetail, + type VariantSetQuery, + type VariantSetRecord, +} 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 ReferenceSetResponse { + referenceSetDbId: string; + referenceSetName: string | null; +} + +interface StudyLookup { + studyDbId: string; + studyName: string | null; +} + +type VariantSetPayload = 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 mapVariantSet = ( + item: VariantSetRecord, + referenceSets: ReferenceSetResponse[], + studies: StudyLookup[], +): VariantSetRecord => { + const referenceSetDbId = item.referenceSetDbId || item.reference_set_id || null; + const studyDbId = item.studyDbId || item.study_id || null; + const referenceSet = referenceSets.find((entry) => entry.referenceSetDbId === referenceSetDbId); + const study = studies.find((entry) => entry.studyDbId === studyDbId); + + return { + ...item, + id: item.variantSetDbId || item.id, + variant_set_name: item.variant_set_name || item.variantSetName || null, + reference_set_id: referenceSetDbId, + reference_set_name: item.reference_set_name || referenceSet?.referenceSetName || null, + study_id: studyDbId, + study_name: item.study_name || study?.studyName || null, + variant_count: item.variant_count ?? item.variantCount ?? null, + callset_count: item.callset_count ?? item.callSetCount ?? null, + analysis_count: item.analysis_count ?? item.analysis?.length ?? null, + format_count: item.format_count ?? item.availableFormats?.length ?? null, + }; +}; + +const variantSetBody = (payload: VariantSetPayload) => ({ + variantSetName: requiredText(payload.variant_set_name, "VariantSet 名称不能为空"), + referenceSetDbId: optionalText(payload.reference_set_id), + studyDbId: optionalText(payload.study_id), +}); + +export const normalizeVariantSetFormData = (row: VariantSetRecord) => ({ + id: row.id, + variant_set_name: row.variant_set_name || row.variantSetName || "", + reference_set_id: row.reference_set_id && row.reference_set_id !== NONE_SELECT_VALUE + ? row.reference_set_id + : NONE_SELECT_VALUE, + study_id: row.study_id && row.study_id !== NONE_SELECT_VALUE ? row.study_id : NONE_SELECT_VALUE, +}); + +const referenceSetLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/referencesets?page=0&pageSize=10"); + return response.result.data; +}); + +const variantSetListLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/variantsets?page=0&pageSize=10"); + return response.result.data; +}); + +const toStudyLookup = (studies: CachedSelectOption[]): StudyLookup[] => + studies.map((item) => ({ studyDbId: item.value, studyName: item.label })); + +const filterVariantSetRows = ( + rows: VariantSetRecord[], + query: VariantSetQuery | undefined, + referenceSets: ReferenceSetResponse[], + studies: StudyLookup[], +): VariantSetRecord[] => { + const nameFilter = String(query?.variant_set_name ?? "").trim().toLowerCase(); + const referenceSetId = optionalText(query?.reference_set_id); + const studyId = optionalText(query?.study_id); + + return rows + .map((item) => mapVariantSet(item, referenceSets, studies)) + .filter((item) => { + if (nameFilter && !String(item.variant_set_name ?? "").toLowerCase().includes(nameFilter)) return false; + if (referenceSetId && item.reference_set_id !== referenceSetId) return false; + if (studyId && item.study_id !== studyId) return false; + return true; + }); +}; + +export function invalidateVariantSetPageCache() { + referenceSetLoader.invalidate(); + variantSetListLoader.invalidate(); +} + +export async function fetchVariantSetOptions(force = false): Promise<{ + referenceSets: SelectOption[]; + studies: SelectOption[]; +}> { + const [referenceSets, studies] = await Promise.all([ + referenceSetLoader.load(force), + loadStudyOptions(force), + ]); + + return { + referenceSets: referenceSets.map((item) => ({ + value: item.referenceSetDbId, + label: item.referenceSetName || item.referenceSetDbId, + })), + studies, + }; +} + +export async function fetchVariantSetRows(query?: VariantSetQuery, force = false): Promise { + const [referenceSets, studies, variantSets] = await Promise.all([ + referenceSetLoader.load(force), + loadStudyOptions(force), + variantSetListLoader.load(force), + ]); + return filterVariantSetRows(variantSets, query, referenceSets, toStudyLookup(studies)); +} + +export async function fetchVariantSetDetail(variantSetDbId: string): Promise { + const [detail, options] = await Promise.all([ + request>(`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}`), + fetchVariantSetOptions(), + ]); + + const referenceSet = options.referenceSets.find((item) => item.value === detail.result.referenceSetDbId); + const study = options.studies.find((item) => item.value === detail.result.studyDbId); + + return { + ...mapVariantSet(detail.result, [], []), + id: detail.result.variantSetDbId || variantSetDbId, + variant_set_name: detail.result.variantSetName || detail.result.variant_set_name || null, + reference_set_id: detail.result.referenceSetDbId || null, + reference_set_name: referenceSet?.label || null, + study_id: detail.result.studyDbId || null, + study_name: study?.label || null, + variant_count: detail.result.variantCount ?? null, + callset_count: detail.result.callSetCount ?? null, + analysis: detail.result.analysis || [], + availableFormats: detail.result.availableFormats || [], + analysis_count: detail.result.analysis?.length ?? 0, + format_count: detail.result.availableFormats?.length ?? 0, + }; +} + +export async function fetchVariantSetVariants(variantSetDbId: string) { + const response = await request>>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/variants?pageSize=10`, + ); + return response.result.data; +} + +export async function fetchVariantSetCallsets(variantSetDbId: string) { + const response = await request>>( + `/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/callsets?page=0&pageSize=10`, + ); + return response.result.data; +} + +export async function createVariantSetRow(payload: VariantSetPayload): Promise { + const response = await request>("/brapi/v2/variantsets", { + method: "POST", + body: JSON.stringify({ + variantSetDbId: requiredText(payload.id, "VariantSet ID 不能为空"), + ...variantSetBody(payload), + }), + }); + invalidateVariantSetPageCache(); + const [referenceSets, studies] = await Promise.all([ + referenceSetLoader.load(true), + loadStudyOptions(true), + ]); + return mapVariantSet(response.result.data[0], referenceSets, toStudyLookup(studies)); +} + +export async function updateVariantSetRow(id: string, payload: VariantSetPayload): Promise { + const requestedId = optionalText(payload.id); + if (requestedId && requestedId !== id) { + throw new Error("VariantSet ID 不可修改,请新建记录"); + } + const response = await request>( + `/brapi/v2/variantsets/${encodeURIComponent(id)}`, + { + method: "PUT", + body: JSON.stringify(variantSetBody(payload)), + }, + ); + invalidateVariantSetPageCache(); + const [referenceSets, studies] = await Promise.all([ + referenceSetLoader.load(true), + loadStudyOptions(true), + ]); + return mapVariantSet(response.result, referenceSets, toStudyLookup(studies)); +} + +export async function deleteVariantSetRow(id: string): Promise { + await request>(`/brapi/v2/variantsets/${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([ + referenceSetLoader.load(force), + loadStudyOptions(force), + variantSetListLoader.load(force), + ]); + + return { + options: { + referenceSets: referenceSets.map((item) => ({ + value: item.referenceSetDbId, + label: item.referenceSetName || item.referenceSetDbId, + })), + studies, + }, + rows: filterVariantSetRows(variantSets, options?.query, referenceSets, toStudyLookup(studies)), + }; +} diff --git a/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetTab.tsx b/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetTab.tsx new file mode 100644 index 0000000..04876be --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/components/VariantSetTab.tsx @@ -0,0 +1,199 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import { Layers, 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 { + createVariantSetRow, + deleteVariantSetRow, + fetchVariantSetDetail, + loadVariantSetPageData, + normalizeVariantSetFormData, + updateVariantSetRow, +} from "../api"; +import { NONE_SELECT_VALUE, type SelectOption, type VariantSetQuery } from "../types"; + +const emptyQuery = (): VariantSetQuery => ({ + variant_set_name: "", + reference_set_id: NONE_SELECT_VALUE, + study_id: NONE_SELECT_VALUE, +}); + +const optionOrNone = (label: string, options: SelectOption[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +export function VariantSetTab() { + const [referenceSetOptions, setReferenceSetOptions] = useState([]); + const [studyOptions, setStudyOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(emptyQuery); + const [appliedQuery, setAppliedQuery] = useState(emptyQuery); + + const loadRows = useCallback(async () => { + const { options, rows } = await loadVariantSetPageData({ query: appliedQuery }); + setReferenceSetOptions(options.referenceSets); + setStudyOptions(options.studies); + return rows as unknown as Record[]; + }, [appliedQuery]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchVariantSetDetail(id); + return normalizeVariantSetFormData(detail); + }, []); + + const fields = useMemo(() => [ + { key: "id", label: "VariantSet ID", type: "text", required: true, placeholder: "variantset-001" }, + { + key: "variant_set_name", + label: "变异集合名称", + type: "text", + required: true, + placeholder: "如 Hapmap 2026", + }, + { + key: "reference_set_id", + label: "ReferenceSet", + type: "select", + options: optionOrNone("不关联 ReferenceSet", referenceSetOptions), + }, + { + key: "study_id", + label: "Study", + type: "select", + options: optionOrNone("不关联 Study", studyOptions), + }, + ], [referenceSetOptions, studyOptions]); + + const renderFormExtra = useCallback(() => ( +
+

ReferenceSet 一致性

+

+ 建议为 VariantSet 指定 ReferenceSet;下属 Variant 的 reference_set_id 应与此处保持一致。 +

+
+ ), []); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, variant_set_name: event.target.value }))} + placeholder="variantSetName 模糊匹配" + /> +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, referenceSetOptions, studyOptions]); + + return ( + { + const id = String(row.id ?? row.variantSetDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "reference_set_name", label: "ReferenceSet" }, + { key: "study_name", label: "Study" }, + { + key: "variant_count", + label: "Variant 数", + render: (value) => {Number(value ?? 0)}, + }, + { + key: "callset_count", + label: "CallSet 数", + render: (value) => {Number(value ?? 0)}, + }, + { key: "analysis_count", label: "Analysis 数" }, + { key: "format_count", label: "Formats 数" }, + ]} + fields={fields} + data={[]} + stats={[{ + label: "/brapi/v2/variantsets", + value: "BrAPI", + className: "bg-violet-50 text-violet-700 dark:bg-violet-400/10 dark:text-violet-200", + }]} + loadData={loadRows} + fetchRecord={fetchRecord} + createRecord={(payload) => createVariantSetRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateVariantSetRow(id, payload) as unknown as Promise>} + deleteRecord={deleteVariantSetRow} + renderQueryForm={renderQueryForm} + renderFormExtra={renderFormExtra} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/variant-set/page.tsx b/frontend/src/app/(app)/genotyping/variant-set/page.tsx new file mode 100644 index 0000000..0051a3d --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { VariantSetTab } from "./components/VariantSetTab"; + +export default function VariantSetPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/variant-set/types.ts b/frontend/src/app/(app)/genotyping/variant-set/types.ts new file mode 100644 index 0000000..d716452 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/types.ts @@ -0,0 +1,51 @@ +export const NONE_SELECT_VALUE = "__none__"; + +export interface SelectOption { + value: string; + label: string; +} + +export interface VariantSetQuery { + variant_set_name?: string; + reference_set_id?: string; + study_id?: string; +} + +export interface VariantSetAnalysisItem { + analysisDbId?: string; + analysisName?: string | null; + type?: string | null; + software?: string | null; +} + +export interface VariantSetFormatItem { + dataFormat?: string | null; + fileFormat?: string | null; + fileURL?: string | null; +} + +export interface VariantSetRecord { + id: string; + variantSetDbId: string; + variantSetName: string | null; + variant_set_name: string | null; + referenceSetDbId: string | null; + reference_set_id: string | null; + reference_set_name: string | null; + studyDbId: string | null; + study_id: string | null; + study_name: string | null; + variantCount: number | null; + variant_count: number | null; + callSetCount: number | null; + callset_count: number | null; + analysis_count: number | null; + format_count: number | null; + analysis?: VariantSetAnalysisItem[]; + availableFormats?: VariantSetFormatItem[]; +} + +export interface VariantSetDetail extends VariantSetRecord { + analysis: VariantSetAnalysisItem[]; + availableFormats: VariantSetFormatItem[]; +} diff --git a/frontend/src/app/(app)/genotyping/variant-set/variant-sets/[variantSetDbId]/page.tsx b/frontend/src/app/(app)/genotyping/variant-set/variant-sets/[variantSetDbId]/page.tsx new file mode 100644 index 0000000..e10374f --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant-set/variant-sets/[variantSetDbId]/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, Binary, Layers, Sigma } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + fetchVariantSetCallsets, + fetchVariantSetDetail, + fetchVariantSetVariants, +} from "../../api"; + +export default function VariantSetDetailPage() { + const params = useParams<{ variantSetDbId: string }>(); + const variantSetDbId = decodeURIComponent(params.variantSetDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState> | null>(null); + const [variants, setVariants] = useState>>([]); + const [callsets, setCallsets] = useState>>([]); + + const loadDetail = useCallback(async () => { + const [variantSet, variantRows, callsetRows] = await Promise.all([ + fetchVariantSetDetail(variantSetDbId), + fetchVariantSetVariants(variantSetDbId), + fetchVariantSetCallsets(variantSetDbId), + ]); + setDetail(variantSet); + setVariants(variantRows); + setCallsets(callsetRows); + }, [variantSetDbId]); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + loadDetail() + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载 VariantSet 详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [loadDetail]); + + if (loading) { + return ( +
+ + + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "VariantSet 不存在"} +
+ +
+
+ ); + } + + const hasDependencies = (detail.variant_count ?? 0) > 0 + || (detail.callset_count ?? 0) > 0 + || (detail.analysis_count ?? 0) > 0 + || (detail.format_count ?? 0) > 0; + + return ( +
+
+ + {hasDependencies ? ( + + 删除前请确认无 Variant、CallSet、Analysis 或 Format 引用 + + ) : null} +
+ + + + + + {detail.variant_set_name || detail.id} + + + +
VariantSet ID:{detail.id}
+
ReferenceSet:{detail.reference_set_name || "N/A"}
+
Study:{detail.study_name || "N/A"}
+
Variant 数:{detail.variant_count ?? 0}
+
CallSet 数:{detail.callset_count ?? 0}
+
+
+ + + + + + Variants + + + + {variants.length === 0 ? ( +

暂无 Variant 位点。

+ ) : ( + + + + Variant ID + 名称 + 类型 + 起点 + 终点 + + + + {variants.slice(0, 20).map((row) => { + const id = String(row.variantDbId ?? row.id ?? ""); + const names = Array.isArray(row.variantNames) ? row.variantNames.join(", ") : String(row.variantName ?? "—"); + return ( + + + {id ? ( + + {id} + + ) : "—"} + + {names} + {String(row.variantType ?? "—")} + {String(row.start ?? "—")} + {String(row.end ?? "—")} + + ); + })} + +
+ )} + {variants.length > 20 ? ( +

仅展示前 20 条,完整列表请前往 Variant 管理页筛选。

+ ) : null} +
+
+ + + + + + CallSets + + + + {callsets.length === 0 ? ( +

暂无 CallSet。

+ ) : ( + + + + CallSet ID + 名称 + Sample + + + + {callsets.map((row) => ( + + {String(row.callSetDbId ?? "—")} + {String(row.callSetName ?? "—")} + {String(row.sampleName ?? row.sampleDbId ?? "—")} + + ))} + +
+ )} +
+
+ +
+ + + Analysis + + + {(detail.analysis || []).length === 0 ? ( +

暂无 Analysis。

+ ) : ( + detail.analysis.map((item) => ( +
+
{item.analysisName || item.analysisDbId}
+
{item.type || "—"} · {item.software || "—"}
+
+ )) + )} +
+
+ + + + Available Formats + + + {(detail.availableFormats || []).length === 0 ? ( +

暂无可用格式。

+ ) : ( + detail.availableFormats.map((item) => ( +
+
{item.fileFormat || item.dataFormat || "Format"}
+
{item.fileURL || "—"}
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/(app)/genotyping/variant/api.ts b/frontend/src/app/(app)/genotyping/variant/api.ts index 2f216c8..165bec2 100644 --- a/frontend/src/app/(app)/genotyping/variant/api.ts +++ b/frontend/src/app/(app)/genotyping/variant/api.ts @@ -1,8 +1,10 @@ +import { createCachedLoader } from "@/services/dropdownCache"; import { getAuthToken } from "@/utils/token"; import { NONE_SELECT_VALUE, type CallRecord, type SelectOption, + type VariantQuery, type VariantRecord, } from "./types"; @@ -135,12 +137,18 @@ const genotypeToText = (value: unknown) => { return null; }; -const mapVariant = (variant: VariantRecord): VariantRecord => ({ +export const mapVariant = (variant: VariantRecord): VariantRecord => ({ ...variant, id: variant.variantDbId || variant.variantId || variant.id, - variant_name: variant.variant_name || variant.variantName || null, + variant_name: variant.variant_name + || variant.variantName + || (Array.isArray(variant.variantNames) ? variant.variantNames[0] : null) + || null, variant_type: variant.variant_type || variant.variantType || null, - variant_set_id: variant.variant_set_id || variant.variantSetDbId || null, + variant_set_id: variant.variant_set_id + || variant.variantSetDbId + || (Array.isArray(variant.variantSetDbId) ? variant.variantSetDbId[0] : null) + || null, variant_set_name: variant.variant_set_name || variant.variantSetName || null, reference_set_id: variant.reference_set_id || variant.referenceSetDbId || null, reference_set_name: variant.reference_set_name || variant.referenceSetName || null, @@ -188,49 +196,154 @@ const callBody = (payload: CallPayload) => ({ phaseSet: optionalText(payload.phase_set), }); -export async function fetchVariantRows(): Promise { - const response = await request>("/brapi/v2/variants?page=0&pageSize=1000"); +export const normalizeVariantFormData = (row: VariantRecord) => ({ + id: row.id, + variant_name: row.variant_name || "", + variant_type: row.variant_type && row.variant_type !== NONE_SELECT_VALUE ? row.variant_type : NONE_SELECT_VALUE, + variant_set_id: row.variant_set_id && row.variant_set_id !== NONE_SELECT_VALUE ? row.variant_set_id : NONE_SELECT_VALUE, + reference_set_id: row.reference_set_id && row.reference_set_id !== NONE_SELECT_VALUE ? row.reference_set_id : NONE_SELECT_VALUE, + start: row.start ?? "", + end: row.end ?? "", + reference_bases: row.reference_bases || "", + svlen: row.svlen ?? "", + filters_applied: row.filters_applied === true ? "true" : row.filters_applied === false ? "false" : NONE_SELECT_VALUE, + filters_passed: row.filters_passed === true ? "true" : row.filters_passed === false ? "false" : NONE_SELECT_VALUE, +}); + +const referenceSetLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/referencesets?page=0&pageSize=10"); + return response.result.data; +}); + +const variantSetLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/variantsets?page=0&pageSize=10"); + return response.result.data; +}); + +const callSetLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/callsets?page=0&pageSize=10"); + return response.result.data; +}); + +const variantRowsLoader = createCachedLoader(async () => { + const response = await request>("/brapi/v2/variants?page=0&pageSize=10"); return response.result.data.map(mapVariant); +}); + +function enrichVariantRows( + rows: VariantRecord[], + referenceSets: ReferenceSetResponse[], + variantSets: VariantSetResponse[], +): VariantRecord[] { + return rows.map((row) => { + const referenceSet = referenceSets.find((item) => item.referenceSetDbId === row.reference_set_id); + const variantSet = variantSets.find((item) => item.variantSetDbId === row.variant_set_id); + return { + ...row, + reference_set_name: row.reference_set_name || referenceSet?.referenceSetName || null, + variant_set_name: row.variant_set_name || variantSet?.variantSetName || null, + }; + }); } -export async function fetchCallRows(): Promise { - const response = await request>("/brapi/v2/calls?page=0&pageSize=1000"); - return response.result.data.map(mapCall); +function filterVariantRows(rows: VariantRecord[], query?: VariantQuery): VariantRecord[] { + const nameFilter = String(query?.variant_name ?? "").trim().toLowerCase(); + const typeFilter = optionalText(query?.variant_type); + const variantSetId = optionalText(query?.variant_set_id); + const referenceSetId = optionalText(query?.reference_set_id); + + return rows.filter((row) => { + if (nameFilter && !String(row.variant_name ?? "").toLowerCase().includes(nameFilter)) return false; + if (typeFilter && row.variant_type !== typeFilter) return false; + if (variantSetId && row.variant_set_id !== variantSetId) return false; + if (referenceSetId && row.reference_set_id !== referenceSetId) return false; + return true; + }); } -export async function fetchVariantOptions(): Promise<{ +function buildVariantOptions( + referenceSets: ReferenceSetResponse[], + variantSets: VariantSetResponse[], + callSets: CallSetResponse[], + variants: VariantRecord[], +) { + return { + referenceSets: referenceSets.map((item) => ({ + value: item.referenceSetDbId, + label: item.referenceSetName || item.referenceSetDbId, + })), + variantSets: variantSets.map((item) => ({ + value: item.variantSetDbId, + label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`, + })), + callSets: callSets.map((item) => ({ + value: item.callSetDbId, + label: `${item.callSetName || item.callSetDbId}${item.sampleName || item.sampleDbId ? ` / ${item.sampleName || item.sampleDbId}` : ""}`, + })), + variants: variants.map((item) => ({ + value: item.id, + label: `${item.variant_name || item.id}${item.variant_type ? ` / ${item.variant_type}` : ""}`, + })), + }; +} + +export function invalidateVariantPageCache() { + referenceSetLoader.invalidate(); + variantSetLoader.invalidate(); + callSetLoader.invalidate(); + variantRowsLoader.invalidate(); +} + +export async function fetchVariantOptions(force = false): Promise<{ referenceSets: SelectOption[]; variantSets: SelectOption[]; callSets: SelectOption[]; variants: SelectOption[]; }> { const [referenceSets, variantSets, callSets, variants] = await Promise.all([ - request>("/brapi/v2/referencesets?page=0&pageSize=1000"), - request>("/brapi/v2/variantsets?page=0&pageSize=1000"), - request>("/brapi/v2/callsets?page=0&pageSize=1000"), - request>("/brapi/v2/variants?page=0&pageSize=1000"), + referenceSetLoader.load(force), + variantSetLoader.load(force), + callSetLoader.load(force), + variantRowsLoader.load(force), ]); + return buildVariantOptions(referenceSets, variantSets, callSets, variants); +} + +export async function fetchVariantRows(query?: VariantQuery): Promise { + const [variants, referenceSets, variantSets] = await Promise.all([ + variantRowsLoader.load(), + referenceSetLoader.load(), + variantSetLoader.load(), + ]); + + return filterVariantRows(enrichVariantRows(variants, referenceSets, variantSets), query); +} + +export async function fetchVariantDetail(variantDbId: string): Promise { + const [detail, options, callsResponse] = await Promise.all([ + request>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}`), + fetchVariantOptions(), + request>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}/calls?pageSize=10`), + ]); + + const mapped = mapVariant(detail.result); + const referenceSet = options.referenceSets.find((item) => item.value === mapped.reference_set_id); + const variantSet = options.variantSets.find((item) => item.value === mapped.variant_set_id); + return { - referenceSets: referenceSets.result.data.map((item) => ({ - value: item.referenceSetDbId, - label: item.referenceSetName || item.referenceSetDbId, - })), - variantSets: variantSets.result.data.map((item) => ({ - value: item.variantSetDbId, - label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`, - })), - callSets: callSets.result.data.map((item) => ({ - value: item.callSetDbId, - label: `${item.callSetName || item.callSetDbId}${item.sampleName || item.sampleDbId ? ` / ${item.sampleName || item.sampleDbId}` : ""}`, - })), - variants: variants.result.data.map(mapVariant).map((item) => ({ - value: item.id, - label: `${item.variant_name || item.id}${item.variant_type ? ` / ${item.variant_type}` : ""}`, - })), + ...mapped, + reference_set_name: mapped.reference_set_name || referenceSet?.label || null, + variant_set_name: mapped.variant_set_name || variantSet?.label || null, + allele_call_count: callsResponse.result.data.length, }; } +export async function fetchCallRows(): Promise { + const response = await request>("/brapi/v2/calls?page=0&pageSize=10"); + return response.result.data.map(mapCall); +} + export async function createVariantRow(payload: VariantPayload): Promise { const response = await request>("/brapi/v2/variants", { method: "POST", @@ -239,6 +352,7 @@ export async function createVariantRow(payload: VariantPayload): Promise { await request>(`/brapi/v2/variants/${encodeURIComponent(id)}`, { method: "DELETE", }); + invalidateVariantPageCache(); } export async function createCallRow(payload: CallPayload): Promise { @@ -284,3 +400,19 @@ export async function deleteCallRow(id: string): Promise { method: "DELETE", }); } + +export async function loadVariantPageData(options?: { query?: VariantQuery; force?: boolean }) { + const force = options?.force ?? false; + const [referenceSets, variantSets, callSets, variants] = await Promise.all([ + referenceSetLoader.load(force), + variantSetLoader.load(force), + callSetLoader.load(force), + variantRowsLoader.load(force), + ]); + + const enrichedRows = enrichVariantRows(variants, referenceSets, variantSets); + return { + options: buildVariantOptions(referenceSets, variantSets, callSets, variants), + rows: filterVariantRows(enrichedRows, options?.query), + }; +} diff --git a/frontend/src/app/(app)/genotyping/variant/components/VariantTab.tsx b/frontend/src/app/(app)/genotyping/variant/components/VariantTab.tsx new file mode 100644 index 0000000..5ab7ce5 --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant/components/VariantTab.tsx @@ -0,0 +1,241 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { RotateCcw, Search, Sigma } from "lucide-react"; +import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + createVariantRow, + deleteVariantRow, + fetchVariantDetail, + loadVariantPageData, + normalizeVariantFormData, + updateVariantRow, +} from "../api"; +import { NONE_SELECT_VALUE, type SelectOption, type VariantQuery } from "../types"; + +const variantTypeOptions: SelectOption[] = [ + { value: NONE_SELECT_VALUE, label: "不指定类型" }, + { value: "SNP", label: "SNP / 单核苷酸变异" }, + { value: "INDEL", label: "INDEL / 插入缺失" }, + { value: "SV", label: "SV / 结构变异" }, + { value: "CNV", label: "CNV / 拷贝数变异" }, +]; + +const booleanOptions: SelectOption[] = [ + { value: NONE_SELECT_VALUE, label: "不指定" }, + { value: "true", label: "是" }, + { value: "false", label: "否" }, +]; + +const emptyQuery = (): VariantQuery => ({ + variant_name: "", + variant_type: NONE_SELECT_VALUE, + variant_set_id: NONE_SELECT_VALUE, + reference_set_id: NONE_SELECT_VALUE, +}); + +const optionOrNone = (label: string, options: SelectOption[]) => [ + { value: NONE_SELECT_VALUE, label }, + ...options, +]; + +const optionLabel = (options: SelectOption[], value: unknown) => { + const text = String(value ?? "").trim(); + return options.find((option) => option.value === text)?.label || text || "N/A"; +}; + +const boolLabel = (value: unknown) => { + if (value === true) return "是"; + if (value === false) return "否"; + return "N/A"; +}; + +function toSelectValue(value: string | null | undefined) { + return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE; +} + +export function VariantTab() { + const searchParams = useSearchParams(); + const [referenceSetOptions, setReferenceSetOptions] = useState([]); + const [variantSetOptions, setVariantSetOptions] = useState([]); + const [draftQuery, setDraftQuery] = useState(() => ({ + ...emptyQuery(), + variant_set_id: toSelectValue(searchParams.get("variantSetDbId")), + })); + const [appliedQuery, setAppliedQuery] = useState(() => ({ + ...emptyQuery(), + variant_set_id: toSelectValue(searchParams.get("variantSetDbId")), + })); + + const loadRows = useCallback(async () => { + const { options, rows } = await loadVariantPageData({ query: appliedQuery }); + setReferenceSetOptions(options.referenceSets); + setVariantSetOptions(options.variantSets); + return rows as unknown as Record[]; + }, [appliedQuery]); + + const fetchRecord = useCallback(async (id: string) => { + const detail = await fetchVariantDetail(id); + return normalizeVariantFormData(detail); + }, []); + + const fields = useMemo(() => [ + { key: "id", label: "Variant ID", type: "text", required: true, placeholder: "variant-001" }, + { key: "variant_name", label: "变异名称", type: "text", required: true, placeholder: "S1_12345_A_T" }, + { key: "variant_type", label: "变异类型", type: "select", options: variantTypeOptions }, + { + key: "variant_set_id", + label: "VariantSet", + type: "select", + options: optionOrNone("不关联 VariantSet", variantSetOptions), + }, + { + key: "reference_set_id", + label: "ReferenceSet", + type: "select", + options: optionOrNone("不关联 ReferenceSet", referenceSetOptions), + }, + { key: "start", label: "起点", type: "number", placeholder: "1000" }, + { key: "end", label: "终点", type: "number", placeholder: "1001" }, + { key: "reference_bases", label: "参考碱基", type: "text", placeholder: "A" }, + { key: "svlen", label: "SV 长度", type: "number", placeholder: "1" }, + { key: "filters_applied", label: "已应用过滤", type: "select", options: booleanOptions }, + { key: "filters_passed", label: "通过过滤", type: "select", options: booleanOptions }, + ], [referenceSetOptions, variantSetOptions]); + + const renderFormExtra = useCallback(() => ( +
+

位点定义说明

+

+ Variant 仅描述位点坐标与参考碱基,样本 genotype 结果写入 allele_call。大批量位点建议通过文件导入。 +

+
+ ), []); + + const renderQueryForm = useCallback(() => ( +
+
+
+ + setDraftQuery((current) => ({ ...current, variant_name: event.target.value }))} + placeholder="variantName 模糊匹配" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ ), [draftQuery, referenceSetOptions, variantSetOptions]); + + return ( + { + const id = String(row.id ?? row.variantDbId ?? ""); + const name = String(value ?? "—"); + if (!id) return name; + return ( + + {name} + + ); + }, + }, + { key: "variant_type", label: "类型", render: (value) => optionLabel(variantTypeOptions, value) }, + { key: "variant_set_name", label: "VariantSet" }, + { key: "reference_set_name", label: "ReferenceSet" }, + { key: "start", label: "起点" }, + { key: "end", label: "终点" }, + { key: "reference_bases", label: "参考碱基" }, + { key: "filters_passed", label: "过滤", render: boolLabel }, + ]} + fields={fields} + data={[]} + stats={[{ label: "/brapi/v2/variants", value: "BrAPI", className: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-200" }]} + loadData={loadRows} + fetchRecord={fetchRecord} + createRecord={(payload) => createVariantRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateVariantRow(id, payload) as unknown as Promise>} + deleteRecord={deleteVariantRow} + renderQueryForm={() => renderQueryForm()} + renderFormExtra={renderFormExtra} + /> + ); +} diff --git a/frontend/src/app/(app)/genotyping/variant/page.tsx b/frontend/src/app/(app)/genotyping/variant/page.tsx index c28bcb2..14be38b 100644 --- a/frontend/src/app/(app)/genotyping/variant/page.tsx +++ b/frontend/src/app/(app)/genotyping/variant/page.tsx @@ -1,106 +1,41 @@ -/** - * filekorolheader: Variant / Call - 变异数据管理页面 - * 功能:Variant 变异位点维护、Call 基因型判读维护 - * 路径:/genotyping/variant - * 规范:遵循开发项目规范.md,使用 shadcn 语义化样式和 BrAPI 数据接口 - */ "use client"; -import { useCallback, useMemo, useState } from "react"; +import { Suspense, useCallback, useMemo, useState } from "react"; import { Binary, Sigma } from "lucide-react"; import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { VariantTab } from "./components/VariantTab"; import { createCallRow, - createVariantRow, deleteCallRow, - deleteVariantRow, fetchCallRows, fetchVariantOptions, - fetchVariantRows, updateCallRow, - updateVariantRow, } from "./api"; import { NONE_SELECT_VALUE, type SelectOption } from "./types"; -const variantTypeOptions: SelectOption[] = [ - { value: NONE_SELECT_VALUE, label: "不指定类型" }, - { value: "SNP", label: "SNP / 单核苷酸变异" }, - { value: "INDEL", label: "INDEL / 插入缺失" }, - { value: "SV", label: "SV / 结构变异" }, - { value: "CNV", label: "CNV / 拷贝数变异" }, -]; - -const booleanOptions: SelectOption[] = [ - { value: NONE_SELECT_VALUE, label: "不指定" }, - { value: "true", label: "是" }, - { value: "false", label: "否" }, -]; - -const optionOrNone = (label: string, options: SelectOption[]) => [ - { value: NONE_SELECT_VALUE, label }, - ...options, -]; - -const optionLabel = (options: SelectOption[], value: unknown) => { - const text = String(value ?? "").trim(); - return options.find((option) => option.value === text)?.label || text || "N/A"; -}; - -const boolLabel = (value: unknown) => { - if (value === true) return "是"; - if (value === false) return "否"; - return "N/A"; -}; +function VariantTabFallback() { + return ; +} export default function VariantPage() { - const [referenceSetOptions, setReferenceSetOptions] = useState([]); - const [variantSetOptions, setVariantSetOptions] = useState([]); + const [tab, setTab] = useState("variants"); const [callSetOptions, setCallSetOptions] = useState([]); const [variantOptions, setVariantOptions] = useState([]); const loadOptions = useCallback(async () => { const options = await fetchVariantOptions(); - setReferenceSetOptions(options.referenceSets); - setVariantSetOptions(options.variantSets); setCallSetOptions(options.callSets); setVariantOptions(options.variants); + return options; }, []); - const loadVariants = useCallback(async () => { - const [, rows] = await Promise.all([loadOptions(), fetchVariantRows()]); - return rows as unknown as Record[]; - }, [loadOptions]); - const loadCalls = useCallback(async () => { const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]); return rows as unknown as Record[]; }, [loadOptions]); - const variantFields = useMemo(() => [ - { key: "id", label: "Variant ID", type: "text", required: true, placeholder: "variant-001" }, - { key: "variant_name", label: "变异名称", type: "text", required: true, placeholder: "S1_12345_A_T" }, - { key: "variant_type", label: "变异类型", type: "select", options: variantTypeOptions }, - { - key: "variant_set_id", - label: "VariantSet", - type: "select", - options: optionOrNone("不关联 VariantSet", variantSetOptions), - }, - { - key: "reference_set_id", - label: "ReferenceSet", - type: "select", - options: optionOrNone("不关联 ReferenceSet", referenceSetOptions), - }, - { key: "start", label: "起点", type: "number", placeholder: "1000" }, - { key: "end", label: "终点", type: "number", placeholder: "1001" }, - { key: "reference_bases", label: "参考碱基", type: "text", placeholder: "A" }, - { key: "svlen", label: "SV 长度", type: "number", placeholder: "1" }, - { key: "filters_applied", label: "已应用过滤", type: "select", options: booleanOptions }, - { key: "filters_passed", label: "通过过滤", type: "select", options: booleanOptions }, - ], [referenceSetOptions, variantSetOptions]); - const callFields = useMemo(() => [ { key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" }, { @@ -124,67 +59,48 @@ export default function VariantPage() { ], [callSetOptions, variantOptions]); return ( - + Variants Calls - - optionLabel(variantTypeOptions, value) }, - { key: "variant_set_name", label: "VariantSet" }, - { key: "reference_set_name", label: "ReferenceSet" }, - { key: "start", label: "起点" }, - { key: "end", label: "终点" }, - { key: "reference_bases", label: "参考碱基" }, - { key: "filters_passed", label: "过滤", render: boolLabel }, - ]} - fields={variantFields} - data={[]} - stats={[{ label: "/brapi/v2/variants", value: "BrAPI", className: "bg-rose-50 text-rose-700 dark:bg-rose-400/10 dark:text-rose-200" }]} - loadData={loadVariants} - createRecord={(payload) => createVariantRow(payload) as unknown as Promise>} - updateRecord={(id, payload) => updateVariantRow(id, payload) as unknown as Promise>} - deleteRecord={deleteVariantRow} - /> - + {tab === "variants" ? ( + + }> + + + + ) : null} - - createCallRow(payload) as unknown as Promise>} - updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise>} - deleteRecord={deleteCallRow} - /> - + {tab === "calls" ? ( + + createCallRow(payload) as unknown as Promise>} + updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise>} + deleteRecord={deleteCallRow} + /> + + ) : null} ); } diff --git a/frontend/src/app/(app)/genotyping/variant/types.ts b/frontend/src/app/(app)/genotyping/variant/types.ts index 0f23691..bf7c4b0 100644 --- a/frontend/src/app/(app)/genotyping/variant/types.ts +++ b/frontend/src/app/(app)/genotyping/variant/types.ts @@ -5,6 +5,13 @@ export interface SelectOption { label: string; } +export interface VariantQuery { + variant_name?: string; + variant_type?: string; + variant_set_id?: string; + reference_set_id?: string; +} + export interface ReferenceRecord { id: string; referenceId: string; @@ -27,9 +34,10 @@ export interface VariantRecord { variantDbId: string; variantName: string | null; variant_name: string | null; + variantNames?: string[] | null; variantType: string | null; variant_type: string | null; - variantSetDbId: string | null; + variantSetDbId: string | string[] | null; variant_set_id: string | null; variantSetName: string | null; variant_set_name: string | null; @@ -50,6 +58,7 @@ export interface VariantRecord { filters_applied: boolean | null; filtersPassed: boolean | null; filters_passed: boolean | null; + allele_call_count?: number | null; } export interface CallGenotype { diff --git a/frontend/src/app/(app)/genotyping/variant/variants/[variantDbId]/page.tsx b/frontend/src/app/(app)/genotyping/variant/variants/[variantDbId]/page.tsx new file mode 100644 index 0000000..5be7abe --- /dev/null +++ b/frontend/src/app/(app)/genotyping/variant/variants/[variantDbId]/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { ArrowLeft, MapPin, Sigma } 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 { fetchVariantDetail } from "../../api"; + +const boolLabel = (value: boolean | null | undefined) => { + if (value === true) return "是"; + if (value === false) return "否"; + return "N/A"; +}; + +export default function VariantDetailPage() { + const params = useParams<{ variantDbId: string }>(); + const variantDbId = decodeURIComponent(params.variantDbId); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [detail, setDetail] = useState> | null>(null); + + useEffect(() => { + let mounted = true; + setLoading(true); + setError(null); + fetchVariantDetail(variantDbId) + .then((result) => { + if (!mounted) return; + setDetail(result); + }) + .catch((event) => { + if (!mounted) return; + setError(event instanceof Error ? event.message : "加载 Variant 详情失败"); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + return () => { mounted = false; }; + }, [variantDbId]); + + if (loading) { + return ( +
+ + +
+ ); + } + + if (error || !detail) { + return ( +
+ {error || "Variant 不存在"} +
+ +
+
+ ); + } + + return ( +
+
+ + {(detail.allele_call_count ?? 0) > 0 ? ( + + 已有 {detail.allele_call_count} 条 allele_call,删除前请先处理关联数据 + + ) : null} +
+ + + + + + {detail.variant_name || detail.id} + + + +
Variant ID:{detail.id}
+
类型:{detail.variant_type || "N/A"}
+
VariantSet:{detail.variant_set_name || "N/A"}
+
ReferenceSet:{detail.reference_set_name || "N/A"}
+
起点:{detail.start ?? "N/A"}
+
终点:{detail.end ?? "N/A"}
+
参考碱基:{detail.reference_bases || "N/A"}
+
SV 长度:{detail.svlen ?? "N/A"}
+
已应用过滤:{boolLabel(detail.filters_applied)}
+
通过过滤:{boolLabel(detail.filters_passed)}
+
allele_call 数:{detail.allele_call_count ?? 0}
+
+
+ + + + + + Marker Position + + + +

+ Marker Position 与遗传图谱 linkage group 关联,可在后续 Marker / GenomeMap 模块中维护。 + 当前 Variant 详情已展示 allele_call 引用数量,便于删除前校验。 +

+ {detail.variant_set_id ? ( + + ) : null} +
+
+
+ ); +} diff --git a/frontend/src/app/(app)/germplasm/breeding-method/api.ts b/frontend/src/app/(app)/germplasm/breeding-method/api.ts index f9a0c79..a471fdc 100644 --- a/frontend/src/app/(app)/germplasm/breeding-method/api.ts +++ b/frontend/src/app/(app)/germplasm/breeding-method/api.ts @@ -71,14 +71,14 @@ export const mapBreedingMethod = (method: BreedingMethod): BreedingMethodRecord }; async function fetchBreedingMethodList(): Promise { - const response = await request>("/brapi/v2/breedingmethods?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/breedingmethods?page=0&pageSize=10"); return response.result?.data ?? []; } const toRequestBody = (payload: Record) => { const breedingMethodName = emptyToNull(payload.breedingMethodName ?? payload.name); if (!breedingMethodName) { - throw new Error("请填写方法名称"); + throw new Error("???????"); } return { breedingMethodName, diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts b/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts index 5560229..34aea8b 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/api.ts @@ -105,7 +105,7 @@ const buildCrossParent = ( const observationUnitDbId = optionalText(observationUnitId); if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null; if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type"); - if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm 或 observation_unit 至少一项"); + if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm �?observation_unit 至少一"); return { parentType: parentTypeValue as CrossParent["parentType"], ...(germplasmDbId ? { germplasmDbId } : {}), @@ -181,23 +181,23 @@ export function buildCrossParentFormState( } const crossingProjectBody = (payload: CrossingProjectPayload) => { - const programDbId = requiredText(payload.program_id, "请选择所属 Program"); + const programDbId = requiredText(payload.program_id, "请选择所�?Program"); return { - crossingProjectName: requiredText(payload.name, "请填写杂交项目名称"), + crossingProjectName: requiredText(payload.name, "请填写杂交项目名"), crossingProjectDescription: optionalText(payload.description), programDbId, }; }; const plannedCrossBody = (payload: PlannedCrossPayload) => ({ - plannedCrossName: requiredText(payload.name, "请填写计划杂交名称"), + plannedCrossName: requiredText(payload.name, "请填写计划杂交名"), crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"), ...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}), ...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }), }); const crossBody = (payload: CrossPayload) => ({ - crossName: requiredText(payload.name, "请填写实际杂交名称"), + crossName: requiredText(payload.name, "请填写实际杂交名"), crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"), ...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}), ...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}), @@ -430,7 +430,7 @@ export async function fetchCrossParentRows(): Promise { } export async function updateCrossParents(payload: CrossParentFormState): Promise { - const crossId = requiredText(payload.cross_id, "请选择所属 Cross"); + const crossId = requiredText(payload.cross_id, "请选择所�?Cross"); const parent1 = buildCrossParent( payload.parent1_type, payload.parent1_germplasm_id, @@ -466,14 +466,14 @@ export async function updateCrossParents(payload: CrossParentFormState): Promise export async function fetchPedigreeRows(): Promise { const response = await request>( - "/brapi/v2/pedigree?page=0&pageSize=1000", + "/brapi/v2/pedigree?page=0&pageSize=10", ); return response.result.data.map(mapPedigree); } export async function fetchPedigreeRowsWithRelations(): Promise { const response = await request>( - "/brapi/v2/pedigree?page=0&pageSize=1000&includeParents=true&includeProgeny=false&includeSiblings=true", + "/brapi/v2/pedigree?page=0&pageSize=10&includeParents=true&includeProgeny=false&includeSiblings=true", ); return response.result.data.map(mapPedigree); } @@ -481,7 +481,7 @@ export async function fetchPedigreeRowsWithRelations(): Promise { const rows = await fetchPedigreeRows(); const found = rows.find((row) => row.id === id || row.germplasm_id === id); - if (!found) throw new Error("系谱节点不存在"); + if (!found) throw new Error("系谱节点不存"); return found; } @@ -526,7 +526,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料"); const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料"); if (thisNodeId === connectedNodeId) { - throw new Error("当前材料与关联材料不能相同"); + throw new Error("当前材料与关联材料不能相"); } if (edgeType === "sibling") { throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护"); @@ -560,7 +560,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina export async function removePedigreeEdge(edgeId: string): Promise { const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":"); if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) { - throw new Error("仅支持删除 parent 关系"); + throw new Error("仅支持删�?parent 关系"); } const nodes = await fetchPedigreeRowsWithRelations(); diff --git a/frontend/src/app/(app)/germplasm/cross-pedigree/crossPedigreeCache.ts b/frontend/src/app/(app)/germplasm/cross-pedigree/crossPedigreeCache.ts index 844691f..0b51d5c 100644 --- a/frontend/src/app/(app)/germplasm/cross-pedigree/crossPedigreeCache.ts +++ b/frontend/src/app/(app)/germplasm/cross-pedigree/crossPedigreeCache.ts @@ -118,9 +118,9 @@ async function fetchSnapshotFromNetwork(): Promise { const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([ loadProgramOptions(), loadGermplasmOptions(), - request>("/brapi/v2/crossingprojects?page=0&pageSize=1000"), - request>("/brapi/v2/plannedcrosses?page=0&pageSize=1000"), - request>("/brapi/v2/crosses?page=0&pageSize=1000"), + request>("/brapi/v2/crossingprojects?page=0&pageSize=10"), + request>("/brapi/v2/plannedcrosses?page=0&pageSize=10"), + request>("/brapi/v2/crosses?page=0&pageSize=10"), loadObservationUnitOptions().catch(() => [] as SelectOption[]), ]); diff --git a/frontend/src/app/(app)/germplasm/germplasm/api.ts b/frontend/src/app/(app)/germplasm/germplasm/api.ts index eb7d3fb..9c1f062 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/api.ts +++ b/frontend/src/app/(app)/germplasm/germplasm/api.ts @@ -99,7 +99,7 @@ const optionalNumber = (value: unknown) => { const germplasmName = (payload: GermplasmPayload) => { const name = optionalText(payload.germplasm_name); - if (!name) throw new Error("请填写种质名称"); + if (!name) throw new Error("请填写种质名"); return name; }; @@ -154,7 +154,7 @@ const toRequestBody = (payload: GermplasmPayload) => ({ }); export async function fetchGermplasmRows(): Promise { - const response = await request>("/brapi/v2/germplasm?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/germplasm?page=0&pageSize=10"); return response.result.data.map(mapGermplasm); } diff --git a/frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts b/frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts index 30d9701..e55a506 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts +++ b/frontend/src/app/(app)/germplasm/germplasm/attributeApi.ts @@ -105,7 +105,7 @@ export function normalizeAttributeFormData(record: AttributeRecord): Record { const attributeName = optionalText(payload.attributeName); - if (!attributeName) throw new Error("请填写属性名称"); + if (!attributeName) throw new Error("请填写属性名"); const dataType = optionalText(payload.dataType) as AttributeDataType | null; const scaleName = `${attributeName} scale`; @@ -128,7 +128,7 @@ const toRequestBody = (payload: AttributePayload) => { }; export async function fetchAttributeRows(): Promise { - const response = await request>("/brapi/v2/attributes?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/attributes?page=0&pageSize=10"); return (response.result?.data ?? []).map(mapAttribute); } @@ -150,7 +150,7 @@ export async function fetchAttributeOptions(): Promise { } export async function fetchAttributeFormOptions(): Promise<{ crops: SelectOption[] }> { - const cropsResult = await request("/brapi/v2/commoncropnames?page=0&pageSize=1000").catch(() => null); + const cropsResult = await request("/brapi/v2/commoncropnames?page=0&pageSize=10").catch(() => null); return { crops: (cropsResult?.result?.data ?? []).map((cropName) => ({ value: cropName, diff --git a/frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts b/frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts index 1fbcbec..8d3f861 100644 --- a/frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts +++ b/frontend/src/app/(app)/germplasm/germplasm/attributeValueApi.ts @@ -103,9 +103,9 @@ const toRequestBody = (payload: AttributeValuePayload) => { const germplasmDbId = optionalText(payload.germplasm_id); const value = optionalText(payload.value); - if (!attributeDbId) throw new Error("请选择属性定义"); + if (!attributeDbId) throw new Error("请选择属性定"); if (!germplasmDbId) throw new Error("请选择种质材料"); - if (!value) throw new Error("请填写属性值"); + if (!value) throw new Error("请填写属性"); return { attributeDbId, @@ -119,8 +119,8 @@ const toRequestBody = (payload: AttributeValuePayload) => { export async function fetchAttributeValueRows(germplasmDbId?: string): Promise { const query = germplasmDbId - ? `?page=0&pageSize=1000&germplasmDbId=${encodeURIComponent(germplasmDbId)}` - : "?page=0&pageSize=1000"; + ? `?page=0&pageSize=10&germplasmDbId=${encodeURIComponent(germplasmDbId)}` + : "?page=0&pageSize=10"; const response = await request>(`/brapi/v2/attributevalues${query}`); return (response.result?.data ?? []).map(mapAttributeValue); } diff --git a/frontend/src/app/(app)/germplasm/seed-lot/api.ts b/frontend/src/app/(app)/germplasm/seed-lot/api.ts index 89c4f2e..2ddaae7 100644 --- a/frontend/src/app/(app)/germplasm/seed-lot/api.ts +++ b/frontend/src/app/(app)/germplasm/seed-lot/api.ts @@ -109,7 +109,7 @@ const optionalNumber = (value: unknown) => { const seedLotName = (payload: SeedLotPayload) => { const name = optionalText(payload.name); - if (!name) throw new Error("请填写批次名称"); + if (!name) throw new Error("请填写批次名"); return name; }; @@ -145,7 +145,7 @@ export const buildContentMixturePayload = (payload: SeedLotPayload) => { } if (rows.length === 0) { - throw new Error("请至少录入一条批次组成,或选择主材料"); + throw new Error("请至少录入一条批次组成,或选择主材"); } const normalized = rows.map((row) => { @@ -154,10 +154,10 @@ export const buildContentMixturePayload = (payload: SeedLotPayload) => { const mixturePercentage = optionalNumber(row.mixture_percentage); if (!germplasmDbId && !crossDbId) { - throw new Error("批次组成每行需选择材料或杂交来源"); + throw new Error("批次组成每行需选择材料或杂交来"); } if (mixturePercentage === null || mixturePercentage < 0 || mixturePercentage > 100) { - throw new Error("批次组成占比需在 0 到 100 之间"); + throw new Error("批次组成占比需�?0 �?100 之间"); } return { @@ -268,7 +268,7 @@ export function normalizeSeedLotFormData(record: SeedLotRecord): Record { - const response = await request>("/brapi/v2/seedlots?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/seedlots?page=0&pageSize=10"); return response.result.data.map(mapSeedLot); }); @@ -349,8 +349,8 @@ export async function fetchSeedLotTransactions(seedLotDbId?: string): Promise>( seedLotDbId - ? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=1000` - : "/brapi/v2/seedlots/transactions?page=0&pageSize=1000", + ? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=10` + : "/brapi/v2/seedlots/transactions?page=0&pageSize=10", ), fetchSeedLotRows().catch(() => [] as SeedLotRecord[]), ]); @@ -365,7 +365,7 @@ export function inferTransactionAction(transaction: SeedLotTransactionRecord): T if (!fromId && toId) return "in"; if (fromId && !toId) { const description = String(transaction.description ?? "").toLowerCase(); - if (description.includes("报废") || description.includes("消耗")) return "consume"; + if (description.includes("报废") || description.includes("消")) return "consume"; return "out"; } if (fromId && toId) { @@ -388,7 +388,7 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed const toId = optionalText(payload.to_seed_lot_id); if (fromId && toId && fromId === toId) { - throw new Error("来源批次与目标批次不能相同"); + throw new Error("来源批次与目标批次不能相"); } let fromSeedLotDbId: string | undefined; @@ -405,38 +405,38 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed case "consume": if (!fromId) throw new Error("出库/消耗需选择来源批次"); if ((payload.action === "out" || payload.action === "consume") && !description) { - throw new Error("出库/消耗/报废建议填写流转说明"); + throw new Error("出库/消�?报废建议填写流转说明"); } fromSeedLotDbId = fromId; units = units || seedLotMap.get(fromId)?.units || null; break; case "transfer": case "split": - if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批次"); + if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批"); fromSeedLotDbId = fromId; toSeedLotDbId = toId; units = units || seedLotMap.get(fromId)?.units || seedLotMap.get(toId)?.units || null; break; default: - throw new Error("未知的库存动作"); + throw new Error("未知的库存动"); } if (!fromSeedLotDbId && !toSeedLotDbId) { - throw new Error("来源批次与目标批次至少填写一个"); + throw new Error("来源批次与目标批次至少填写一"); } const sourceLot = fromSeedLotDbId ? seedLotMap.get(fromSeedLotDbId) : undefined; if (fromSeedLotDbId && sourceLot) { const currentAmount = Number(sourceLot.amount ?? 0); if (amount > currentAmount) { - throw new Error(`出库数量不能超过当前库存(${currentAmount}${sourceLot.units ? ` ${sourceLot.units}` : ""})`); + throw new Error(`出库数量不能超过当前库存:${currentAmount}${sourceLot.units ? ` ${sourceLot.units}` : ""})`); } } - if (!units) throw new Error("请指定流转单位"); + if (!units) throw new Error("请指定流转单"); - const transactionDescription = payload.action === "consume" && description && !description.includes("消耗") - ? `消耗/报废:${description}` + const transactionDescription = payload.action === "consume" && description && !description.includes("消") + ? `消�?报废:${description}` : payload.action === "split" && description && !description.includes("分装") ? `分装:${description}` : description; diff --git a/frontend/src/app/(app)/phenotyping/event-image/api.ts b/frontend/src/app/(app)/phenotyping/event-image/api.ts index 96a235c..25c7a05 100644 --- a/frontend/src/app/(app)/phenotyping/event-image/api.ts +++ b/frontend/src/app/(app)/phenotyping/event-image/api.ts @@ -183,12 +183,12 @@ const imageBody = (payload: ImagePayload) => ({ }); export async function fetchEventRows(): Promise { - const response = await request>("/brapi/v2/events?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/events?page=0&pageSize=10"); return response.result.data.map(mapEvent); } export async function fetchImageRows(): Promise { - const response = await request>("/brapi/v2/images?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/images?page=0&pageSize=10"); return response.result.data.map(mapImage); } @@ -198,9 +198,9 @@ export async function fetchEventImageOptions(): Promise<{ observations: SelectOption[]; }> { const [studies, observationUnits, observations] = await Promise.all([ - request>("/brapi/v2/studies?page=0&pageSize=1000"), - request>("/brapi/v2/observationunits?page=0&pageSize=1000"), - request>("/brapi/v2/observations?page=0&pageSize=1000"), + request>("/brapi/v2/studies?page=0&pageSize=10"), + request>("/brapi/v2/observationunits?page=0&pageSize=10"), + request>("/brapi/v2/observations?page=0&pageSize=10"), ]); return { diff --git a/frontend/src/app/(app)/phenotyping/observation-unit/api.ts b/frontend/src/app/(app)/phenotyping/observation-unit/api.ts index 55e54ee..6f3ab65 100644 --- a/frontend/src/app/(app)/phenotyping/observation-unit/api.ts +++ b/frontend/src/app/(app)/phenotyping/observation-unit/api.ts @@ -101,7 +101,7 @@ const mapObservationUnit = (unit: ObservationUnitRecord): ObservationUnitRecord }; const toRequestBody = (payload: ObservationUnitPayload) => ({ - observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名称"), + observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名"), studyDbId: optionalText(payload.study_id), germplasmDbId: optionalText(payload.germplasm_id), observationLevel: { @@ -113,7 +113,7 @@ const toRequestBody = (payload: ObservationUnitPayload) => ({ }); export async function fetchObservationUnitRows(): Promise { - const response = await request>("/brapi/v2/observationunits?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/observationunits?page=0&pageSize=10"); return response.result.data.map(mapObservationUnit); } @@ -133,7 +133,7 @@ export async function createObservationUnitRow(payload: ObservationUnitPayload): const response = await request>("/brapi/v2/observationunits", { method: "POST", body: JSON.stringify({ - observationUnitDbId: requiredText(payload.id, "请填写观测单元 ID"), + observationUnitDbId: requiredText(payload.id, "请填写观测单�?ID"), ...toRequestBody(payload), }), }); diff --git a/frontend/src/app/(app)/phenotyping/observation-variable/api.ts b/frontend/src/app/(app)/phenotyping/observation-variable/api.ts index a34afa6..56a2550 100644 --- a/frontend/src/app/(app)/phenotyping/observation-variable/api.ts +++ b/frontend/src/app/(app)/phenotyping/observation-variable/api.ts @@ -136,7 +136,7 @@ const mapObservationVariable = (variable: ObservationVariableRecord): Observatio }); const toRequestBody = (payload: ObservationVariablePayload) => ({ - observationVariableName: requiredText(payload.name, "请填写变量名称"), + observationVariableName: requiredText(payload.name, "请填写变量名"), pui: optionalText(payload.pui), defaultValue: optionalText(payload.default_value), documentationurl: optionalText(payload.documentationurl), @@ -154,7 +154,7 @@ const toRequestBody = (payload: ObservationVariablePayload) => ({ }); export async function fetchObservationVariableRows(): Promise { - const response = await request>("/brapi/v2/variables?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/variables?page=0&pageSize=10"); return response.result.data.map(mapObservationVariable); } @@ -167,10 +167,10 @@ export async function fetchObservationVariableOptions(): Promise<{ }> { const [crops, ontologies, traits, methods, scales] = await Promise.all([ request("/api/dictionaries/crops"), - request>("/brapi/v2/ontologies?page=0&pageSize=1000"), - request>("/brapi/v2/traits?page=0&pageSize=1000"), - request>("/brapi/v2/methods?page=0&pageSize=1000"), - request>("/brapi/v2/scales?page=0&pageSize=1000"), + request>("/brapi/v2/ontologies?page=0&pageSize=10"), + request>("/brapi/v2/traits?page=0&pageSize=10"), + request>("/brapi/v2/methods?page=0&pageSize=10"), + request>("/brapi/v2/scales?page=0&pageSize=10"), ]); return { @@ -201,7 +201,7 @@ export async function createObservationVariableRow(payload: ObservationVariableP const response = await request>("/brapi/v2/variables", { method: "POST", body: JSON.stringify({ - id: requiredText(payload.id, "请填写变量 ID"), + id: requiredText(payload.id, "请填写变�?ID"), ...toRequestBody(payload), }), }); diff --git a/frontend/src/app/(app)/project/program/api.ts b/frontend/src/app/(app)/project/program/api.ts index 5209350..127dea8 100644 --- a/frontend/src/app/(app)/project/program/api.ts +++ b/frontend/src/app/(app)/project/program/api.ts @@ -77,7 +77,7 @@ const optionalNumber = (value: unknown) => { const programName = (payload: ProgramPayload) => { const name = optionalText(payload.name); - if (!name) throw new Error("请填写项目名称"); + if (!name) throw new Error("请填写项目名"); return name; }; @@ -106,7 +106,7 @@ const toRequestBody = (payload: ProgramPayload) => ({ }); export async function fetchProgramRows(): Promise { - const response = await request>("/brapi/v2/programs?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/programs?page=0&pageSize=10"); return response.result.data.map(mapProgram); } diff --git a/frontend/src/app/(app)/project/study/api.ts b/frontend/src/app/(app)/project/study/api.ts index b8532e0..296efda 100644 --- a/frontend/src/app/(app)/project/study/api.ts +++ b/frontend/src/app/(app)/project/study/api.ts @@ -137,7 +137,7 @@ const optionalBoolean = (value: unknown) => { const studyName = (payload: StudyPayload) => { const name = optionalText(payload.study_name); - if (!name) throw new Error("请填写研究名称"); + if (!name) throw new Error("请填写研究名"); return name; }; @@ -241,7 +241,7 @@ const toRequestBody = (payload: StudyPayload & Record) => { }; export async function fetchStudyRows(): Promise { - const response = await request>("/brapi/v2/studies?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/studies?page=0&pageSize=10"); return response.result.data.map(mapStudy); } @@ -251,7 +251,7 @@ export async function fetchStudyDetail(studyDbId: string): Promise } const studyTrialOptionsLoader = createCachedLoader(async () => { - const response = await request>("/brapi/v2/trials?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/trials?page=0&pageSize=10"); return response.result.data.map((trial) => ({ value: trial.trialDbId, label: `${trial.trialName || trial.trial_name || trial.trialDbId}${trial.programName || trial.program_name ? ` / ${trial.programName || trial.program_name}` : ""}`, diff --git a/frontend/src/app/(app)/project/trial/api.ts b/frontend/src/app/(app)/project/trial/api.ts index b8da831..7d96cc5 100644 --- a/frontend/src/app/(app)/project/trial/api.ts +++ b/frontend/src/app/(app)/project/trial/api.ts @@ -104,7 +104,7 @@ const optionalBoolean = (value: unknown) => { const trialName = (payload: TrialPayload) => { const name = optionalText(payload.trial_name); - if (!name) throw new Error("请填写试验名称"); + if (!name) throw new Error("请填写试验名"); return name; }; @@ -164,7 +164,7 @@ const toRequestBody = (payload: TrialPayload) => { }; export async function fetchTrialRows(): Promise { - const response = await request>("/brapi/v2/trials?page=0&pageSize=1000"); + const response = await request>("/brapi/v2/trials?page=0&pageSize=10"); return response.result.data.map(mapTrial); } diff --git a/frontend/src/components/brapi/BrapiEntityPage.tsx b/frontend/src/components/brapi/BrapiEntityPage.tsx index 575933d..c295645 100644 --- a/frontend/src/components/brapi/BrapiEntityPage.tsx +++ b/frontend/src/components/brapi/BrapiEntityPage.tsx @@ -252,6 +252,12 @@ export function BrapiEntityPage({ ?? row.crossDbId ?? row.plateDbId ?? row.sampleDbId + ?? row.referenceSetDbId + ?? row.referenceDbId + ?? row.referenceBasesDbId + ?? row.variantSetDbId + ?? row.variantDbId + ?? row.callDbId ?? "", ), []); diff --git a/frontend/src/components/brapi/navigation.ts b/frontend/src/components/brapi/navigation.ts index 91759aa..d179d46 100644 --- a/frontend/src/components/brapi/navigation.ts +++ b/frontend/src/components/brapi/navigation.ts @@ -137,9 +137,15 @@ export const brapiNavSections: BrapiNavSection[] = [ { title: "样品管理", items: [{ title: "Sample / Plate", href: "/genotyping/sample-plate", icon: TestTube }] }, { title: "参考基因组", - items: [{ title: "ReferenceSet / Reference / Bases", href: "/genotyping/reference-set", icon: BookOpen }], + items: [{ title: "ReferenceSet / Reference", href: "/genotyping/reference-set", icon: BookOpen }], + }, + { + title: "变异数据", + items: [ + { title: "VariantSet", href: "/genotyping/variant-set", icon: Layers }, + { title: "Variant / Call", href: "/genotyping/variant", icon: Sigma }, + ], }, - { title: "变异数据", items: [{ title: "Variant / Call", href: "/genotyping/variant", icon: Sigma }] }, ], }, { diff --git a/frontend/src/constants/api.ts b/frontend/src/constants/api.ts new file mode 100644 index 0000000..9b865ff --- /dev/null +++ b/frontend/src/constants/api.ts @@ -0,0 +1,11 @@ +/** 前端列表/下拉 BrAPI 查询默认每页条数 */ +export const DEFAULT_LIST_PAGE_SIZE = 10; + +export const DEFAULT_LIST_PAGE = 0; + +/** 标准分页查询串:page=0&pageSize=10 */ +export const DEFAULT_PAGE_QUERY = `page=${DEFAULT_LIST_PAGE}&pageSize=${DEFAULT_LIST_PAGE_SIZE}`; + +export function buildPageQuery(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): string { + return `page=${page}&pageSize=${pageSize}`; +} diff --git a/frontend/src/constants/menu.ts b/frontend/src/constants/menu.ts index c86549f..313f470 100644 --- a/frontend/src/constants/menu.ts +++ b/frontend/src/constants/menu.ts @@ -84,7 +84,10 @@ export const mockBackendMenus: BackendMenuResponse[] = [ children: [ { title: "样品管理", path: "/genotyping/sample", menu_type: "folder", icon: "test-tube", order_index: 1, children: [{ title: "Sample / Plate", path: "/genotyping/sample-plate", menu_type: "menu", icon: "test-tube", order_index: 1, children: [] }] }, { title: "参考基因组", path: "/genotyping/reference", menu_type: "folder", icon: "book-open", order_index: 2, children: [{ title: "ReferenceSet / Reference / Bases", path: "/genotyping/reference-set", menu_type: "menu", icon: "book-open", order_index: 1, children: [] }] }, - { title: "变异数据", path: "/genotyping/variant-group", menu_type: "folder", icon: "sigma", order_index: 3, children: [{ title: "Variant / Call", path: "/genotyping/variant", menu_type: "menu", icon: "sigma", order_index: 1, children: [] }] } + { title: "变异数据", path: "/genotyping/variant-group", menu_type: "folder", icon: "sigma", order_index: 3, children: [ + { title: "VariantSet", path: "/genotyping/variant-set", menu_type: "menu", icon: "layers", order_index: 1, children: [] }, + { title: "Variant / Call", path: "/genotyping/variant", menu_type: "menu", icon: "sigma", order_index: 2, children: [] }, + ] } ] }, { diff --git a/frontend/src/lib/api/types.gen.ts b/frontend/src/lib/api/types.gen.ts index a99c554..e1ca7bd 100644 --- a/frontend/src/lib/api/types.gen.ts +++ b/frontend/src/lib/api/types.gen.ts @@ -3342,9 +3342,11 @@ export type TraitsTraitDbIdDeleteResponses = { /** * OK */ - 200: unknown; + 200: TraitSingleResponse; }; +export type TraitsTraitDbIdDeleteResponse = TraitsTraitDbIdDeleteResponses[keyof TraitsTraitDbIdDeleteResponses]; + export type TraitsTraitDbIdGetData = { body?: never; headers?: { diff --git a/frontend/src/services/dictionaryService.ts b/frontend/src/services/dictionaryService.ts index bcf3d5e..73af25d 100644 --- a/frontend/src/services/dictionaryService.ts +++ b/frontend/src/services/dictionaryService.ts @@ -1,4 +1,5 @@ -import { getAuthToken } from "@/utils/token"; +import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api"; +import { getAuthToken } from "@/utils/token"; export interface CropRecord { id: string; @@ -141,7 +142,7 @@ async function request(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `请求失败:${response.status}`); + throw new Error(detail || `请求失败��?${response.status}`); } return response.json() as Promise; } @@ -160,7 +161,7 @@ export async function listCountries(keyword?: string): Promise return request(`/api/dictionaries/countries?${params.toString()}`); } -export async function listCrops(page = 0, pageSize = 10): Promise { +export async function listCrops(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise { const encodedPage = encodeURIComponent(String(page)); const encodedPageSize = encodeURIComponent(String(pageSize)); const response = await request>(`/brapi/v2/commoncropnames?page=${encodedPage}&pageSize=${encodedPageSize}`); @@ -278,7 +279,7 @@ export function deleteSeason(seasonDbId: string) { }).then(() => undefined); } -export function listLocations(page = 0, pageSize = 1000) { +export function listLocations(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE) { const encodedPage = encodeURIComponent(String(page)); const encodedPageSize = encodeURIComponent(String(pageSize)); return request>>( @@ -329,7 +330,7 @@ export function deleteLocation(locationDbId: string) { }).then(() => undefined); } -export function listOntologies(page = 0, pageSize = 1000) { +export function listOntologies(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE) { const encodedPage = encodeURIComponent(String(page)); const encodedPageSize = encodeURIComponent(String(pageSize)); return request>>( @@ -372,7 +373,7 @@ const toOntologyRequestBody = (payload: OntologyPayload) => ({ export function createOntology(payload: OntologyPayload) { const body = toOntologyRequestBody(payload); if (!body.ontologyName) { - return Promise.reject(new Error("请填写本体名称")); + return Promise.reject(new Error("请填写本体名��?")); } return request>>("/brapi/v2/ontologies", { method: "POST", @@ -380,7 +381,7 @@ export function createOntology(payload: OntologyPayload) { }).then((response) => { const ontology = response.result.data[0]; if (!ontology) { - throw new Error("新增本体失败:后端未返回数据"); + throw new Error("新�?�本体失败:后�??��?返回数据"); } return { ...ontology, diff --git a/frontend/src/services/dropdownCache.ts b/frontend/src/services/dropdownCache.ts index e10a6ca..813315e 100644 --- a/frontend/src/services/dropdownCache.ts +++ b/frontend/src/services/dropdownCache.ts @@ -1,3 +1,4 @@ +import { DEFAULT_PAGE_QUERY } from "@/constants/api"; import { getAuthToken } from "@/utils/token"; export interface SelectOption { @@ -73,7 +74,7 @@ interface BreedingMethodResponse { abbreviation: string | null; } -const PAGE_QUERY = "page=0&pageSize=1000"; +const PAGE_QUERY = DEFAULT_PAGE_QUERY; const apiBase = () => { if (typeof window !== "undefined") return ""; @@ -93,7 +94,7 @@ async function request(path: string, init?: RequestInit): Promise { if (!response.ok) { const detail = await response.text(); - throw new Error(detail || `请求失败:${response.status}`); + throw new Error(detail || `?????${response.status}`); } return response.json() as Promise; } @@ -297,7 +298,7 @@ export function invalidateAllDropdownCache() { commonCropNameLoader.invalidate(); } -/** 并行加载多个下拉,共享 in-flight 去重 */ +/** ??????????? in-flight ?? */ export async function loadDropdownBundle(keys: { locations?: boolean; programs?: boolean; diff --git a/src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingVariantWriteController.java b/src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingVariantWriteController.java new file mode 100644 index 0000000..e535a76 --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/controller/geno/GenotypingVariantWriteController.java @@ -0,0 +1,131 @@ +package org.brapi.test.BrAPITestServer.controller.geno; + +import java.util.List; + +import org.brapi.test.BrAPITestServer.controller.core.BrAPIController; +import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; +import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetWriteRequest; +import org.brapi.test.BrAPITestServer.model.dto.geno.VariantWriteRequest; +import org.brapi.test.BrAPITestServer.service.geno.VariantService; +import org.brapi.test.BrAPITestServer.service.geno.VariantSetService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.model.geno.Variant; +import io.swagger.model.geno.VariantSet; +import io.swagger.model.geno.VariantSetResponse; +import io.swagger.model.geno.VariantSetsListResponse; +import io.swagger.model.geno.VariantSetsListResponseResult; +import io.swagger.model.geno.VariantSingleResponse; +import io.swagger.model.geno.VariantsListResponse; +import io.swagger.model.geno.VariantsListResponseResult; +import jakarta.servlet.http.HttpServletRequest; + +@RestController +public class GenotypingVariantWriteController extends BrAPIController { + + private static final Logger log = LoggerFactory.getLogger(GenotypingVariantWriteController.class); + + private final VariantSetService variantSetService; + private final VariantService variantService; + private final HttpServletRequest request; + + @Autowired + public GenotypingVariantWriteController(VariantSetService variantSetService, VariantService variantService, + HttpServletRequest request) { + this.variantSetService = variantSetService; + this.variantService = variantService; + this.request = request; + } + + @CrossOrigin + @RequestMapping(value = "/variantsets", produces = { "application/json" }, consumes = { + "application/json" }, method = RequestMethod.POST) + public ResponseEntity variantSetsPost(@RequestBody VariantSetWriteRequest body, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + VariantSet data = variantSetService.saveVariantSet(body); + return responseOK(new VariantSetsListResponse(), new VariantSetsListResponseResult(), List.of(data)); + } + + @CrossOrigin + @RequestMapping(value = "/variantsets/{variantSetDbId}", produces = { "application/json" }, consumes = { + "application/json" }, method = RequestMethod.PUT) + public ResponseEntity variantSetsVariantSetDbIdPut( + @PathVariable("variantSetDbId") String variantSetDbId, @RequestBody VariantSetWriteRequest body, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + VariantSet data = variantSetService.updateVariantSet(variantSetDbId, body); + return responseOK(new VariantSetResponse(), data); + } + + @CrossOrigin + @RequestMapping(value = "/variantsets/{variantSetDbId}", produces = { + "application/json" }, method = RequestMethod.DELETE) + public ResponseEntity variantSetsVariantSetDbIdDelete( + @PathVariable("variantSetDbId") String variantSetDbId, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + VariantSet data = variantSetService.deleteVariantSet(variantSetDbId); + return responseOK(new VariantSetResponse(), data); + } + + @CrossOrigin + @RequestMapping(value = "/variants", produces = { "application/json" }, consumes = { + "application/json" }, method = RequestMethod.POST) + public ResponseEntity variantsPost(@RequestBody VariantWriteRequest body, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + Variant data = variantService.saveVariant(body); + return responseOK(new VariantsListResponse(), new VariantsListResponseResult(), List.of(data)); + } + + @CrossOrigin + @RequestMapping(value = "/variants/{variantDbId}", produces = { "application/json" }, consumes = { + "application/json" }, method = RequestMethod.PUT) + public ResponseEntity variantsVariantDbIdPut( + @PathVariable("variantDbId") String variantDbId, @RequestBody VariantWriteRequest body, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + Variant data = variantService.updateVariant(variantDbId, body); + return responseOK(new VariantSingleResponse(), data); + } + + @CrossOrigin + @RequestMapping(value = "/variants/{variantDbId}", produces = { + "application/json" }, method = RequestMethod.DELETE) + public ResponseEntity variantsVariantDbIdDelete( + @PathVariable("variantDbId") String variantDbId, + @RequestHeader(value = "Authorization", required = false) String authorization) + throws BrAPIServerException { + log.debug("Request: " + request.getRequestURI()); + validateSecurityContext(request, "ROLE_USER"); + validateAcceptHeader(request); + Variant data = variantService.deleteVariant(variantDbId); + return responseOK(new VariantSingleResponse(), data); + } +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetWriteRequest.java b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetWriteRequest.java new file mode 100644 index 0000000..d0134ee --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantSetWriteRequest.java @@ -0,0 +1,40 @@ +package org.brapi.test.BrAPITestServer.model.dto.geno; + +public class VariantSetWriteRequest { + private String variantSetDbId; + private String variantSetName; + private String referenceSetDbId; + private String studyDbId; + + public String getVariantSetDbId() { + return variantSetDbId; + } + + public void setVariantSetDbId(String variantSetDbId) { + this.variantSetDbId = variantSetDbId; + } + + public String getVariantSetName() { + return variantSetName; + } + + public void setVariantSetName(String variantSetName) { + this.variantSetName = variantSetName; + } + + public String getReferenceSetDbId() { + return referenceSetDbId; + } + + public void setReferenceSetDbId(String referenceSetDbId) { + this.referenceSetDbId = referenceSetDbId; + } + + public String getStudyDbId() { + return studyDbId; + } + + public void setStudyDbId(String studyDbId) { + this.studyDbId = studyDbId; + } +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantWriteRequest.java b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantWriteRequest.java new file mode 100644 index 0000000..31bf642 --- /dev/null +++ b/src/main/java/org/brapi/test/BrAPITestServer/model/dto/geno/VariantWriteRequest.java @@ -0,0 +1,141 @@ +package org.brapi.test.BrAPITestServer.model.dto.geno; + +import java.util.List; + +public class VariantWriteRequest { + private String variantDbId; + private String variantName; + private String variantType; + private String variantSetDbId; + private String referenceSetDbId; + private Integer start; + private Integer end; + private String referenceBases; + private Integer svlen; + private Boolean filtersApplied; + private Boolean filtersPassed; + private List alternateBases; + private List ciend; + private List cipos; + private List filtersFailed; + + public String getVariantDbId() { + return variantDbId; + } + + public void setVariantDbId(String variantDbId) { + this.variantDbId = variantDbId; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getVariantType() { + return variantType; + } + + public void setVariantType(String variantType) { + this.variantType = variantType; + } + + public String getVariantSetDbId() { + return variantSetDbId; + } + + public void setVariantSetDbId(String variantSetDbId) { + this.variantSetDbId = variantSetDbId; + } + + public String getReferenceSetDbId() { + return referenceSetDbId; + } + + public void setReferenceSetDbId(String referenceSetDbId) { + this.referenceSetDbId = referenceSetDbId; + } + + public Integer getStart() { + return start; + } + + public void setStart(Integer start) { + this.start = start; + } + + public Integer getEnd() { + return end; + } + + public void setEnd(Integer end) { + this.end = end; + } + + public String getReferenceBases() { + return referenceBases; + } + + public void setReferenceBases(String referenceBases) { + this.referenceBases = referenceBases; + } + + public Integer getSvlen() { + return svlen; + } + + public void setSvlen(Integer svlen) { + this.svlen = svlen; + } + + public Boolean getFiltersApplied() { + return filtersApplied; + } + + public void setFiltersApplied(Boolean filtersApplied) { + this.filtersApplied = filtersApplied; + } + + public Boolean getFiltersPassed() { + return filtersPassed; + } + + public void setFiltersPassed(Boolean filtersPassed) { + this.filtersPassed = filtersPassed; + } + + public List getAlternateBases() { + return alternateBases; + } + + public void setAlternateBases(List alternateBases) { + this.alternateBases = alternateBases; + } + + public List getCiend() { + return ciend; + } + + public void setCiend(List ciend) { + this.ciend = ciend; + } + + public List getCipos() { + return cipos; + } + + public void setCipos(List cipos) { + this.cipos = cipos; + } + + public List getFiltersFailed() { + return filtersFailed; + } + + public void setFiltersFailed(List filtersFailed) { + this.filtersFailed = filtersFailed; + } +} diff --git a/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/CallSetRepository.java b/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/CallSetRepository.java index cf6f557..432f2db 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/CallSetRepository.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/CallSetRepository.java @@ -1,7 +1,19 @@ package org.brapi.test.BrAPITestServer.repository.geno; +import java.util.Collection; +import java.util.List; + import org.brapi.test.BrAPITestServer.model.entity.geno.CallSetEntity; import org.brapi.test.BrAPITestServer.repository.BrAPIRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CallSetRepository extends BrAPIRepository { + + @Query("SELECT COUNT(cs) FROM CallSetEntity cs JOIN cs.variantSets vs WHERE vs.id = :variantSetDbId") + long countByVariantSetDbId(@Param("variantSetDbId") String variantSetDbId); + + @Query("SELECT vs.id, COUNT(cs) FROM CallSetEntity cs JOIN cs.variantSets vs WHERE vs.id IN :variantSetDbIds GROUP BY vs.id") + List countCallSetsGroupedByVariantSetId(@Param("variantSetDbIds") Collection variantSetDbIds); -public interface CallSetRepository extends BrAPIRepository{ } diff --git a/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantRepository.java b/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantRepository.java index d568419..72111ac 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantRepository.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/repository/geno/VariantRepository.java @@ -1,8 +1,18 @@ package org.brapi.test.BrAPITestServer.repository.geno; +import java.util.Collection; +import java.util.List; + import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity; import org.brapi.test.BrAPITestServer.repository.BrAPIRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface VariantRepository extends BrAPIRepository { + long countByVariantSet_Id(String variantSetId); + + @Query("SELECT v.variantSet.id, COUNT(v) FROM VariantEntity v WHERE v.variantSet.id IN :variantSetDbIds GROUP BY v.variantSet.id") + List countVariantsGroupedByVariantSetId(@Param("variantSetDbIds") Collection variantSetDbIds); + } diff --git a/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantService.java b/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantService.java index a70b1d8..a5032e9 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantService.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantService.java @@ -1,18 +1,29 @@ package org.brapi.test.BrAPITestServer.service.geno; import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; +import org.brapi.test.BrAPITestServer.model.dto.geno.VariantWriteRequest; +import org.brapi.test.BrAPITestServer.model.entity.geno.CallEntity; +import org.brapi.test.BrAPITestServer.model.entity.geno.MarkerPositionEntity; +import org.brapi.test.BrAPITestServer.model.entity.geno.ReferenceSetEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity; +import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity; +import org.brapi.test.BrAPITestServer.repository.geno.CallRepository; +import org.brapi.test.BrAPITestServer.repository.geno.MarkerPositionRepository; import org.brapi.test.BrAPITestServer.repository.geno.VariantRepository; +import org.brapi.test.BrAPITestServer.repository.geno.VariantSetRepository; import org.brapi.test.BrAPITestServer.service.DateUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -25,9 +36,19 @@ import io.swagger.model.geno.VariantsSearchRequest; public class VariantService { private final VariantRepository variantRepository; + private final CallRepository callRepository; + private final MarkerPositionRepository markerPositionRepository; + private final VariantSetRepository variantSetRepository; + private final ReferenceSetService referenceSetService; - public VariantService(VariantRepository variantRepository) { + public VariantService(VariantRepository variantRepository, CallRepository callRepository, + MarkerPositionRepository markerPositionRepository, VariantSetRepository variantSetRepository, + ReferenceSetService referenceSetService) { this.variantRepository = variantRepository; + this.callRepository = callRepository; + this.markerPositionRepository = markerPositionRepository; + this.variantSetRepository = variantSetRepository; + this.referenceSetService = referenceSetService; } public List findVariants(String variantDbId, String variantSetDbId, String referenceDbId, @@ -61,7 +82,8 @@ public class VariantService { "*callSet.id"); } searchQuery = searchQuery.appendList(request.getVariantSetDbIds(), "variantSet.id") - .appendList(request.getVariantDbIds(), "id"); + .appendList(request.getVariantDbIds(), "id") + .appendList(request.getReferenceSetDbIds(), "referenceSet.id"); Page page = variantRepository.findAllBySearch(searchQuery, pageReq); PagingUtility.calculateMetaData(metadata, page); @@ -99,14 +121,19 @@ public class VariantService { variant.setFiltersFailed(entity.getFiltersFailed()); variant.setFiltersPassed(entity.getFiltersPassed()); variant.setReferenceBases(entity.getReferenceBases()); - if (entity.getReferenceSet() != null) + if (entity.getReferenceSet() != null) { + variant.setReferenceSetDbId(entity.getReferenceSet().getId()); + variant.setReferenceSetName(entity.getReferenceSet().getReferenceSetName()); variant.setReferenceName(entity.getReferenceSet().getReferenceSetName()); + } variant.setStart(entity.getStart()); variant.setSvlen(entity.getSvlen()); variant.setUpdated(DateUtility.toOffsetDateTime(entity.getUpdated())); variant.setVariantDbId(entity.getId()); variant.setVariantNames(Arrays.asList(entity.getVariantName())); - variant.setVariantSetDbId(Arrays.asList(entity.getVariantSet().getId())); + if (entity.getVariantSet() != null) { + variant.setVariantSetDbId(Arrays.asList(entity.getVariantSet().getId())); + } variant.setVariantType(entity.getVariantType()); return variant; @@ -116,4 +143,108 @@ public class VariantService { return variantRepository.save(entity); } + public Variant saveVariant(VariantWriteRequest request) throws BrAPIServerException { + if (request.getVariantName() == null || request.getVariantName().isBlank()) { + throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "variantName is required"); + } + if (request.getVariantDbId() != null && variantRepository.findById(request.getVariantDbId()).isPresent()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "Variant already exists: " + request.getVariantDbId()); + } + VariantEntity entity = new VariantEntity(); + if (request.getVariantDbId() != null && !request.getVariantDbId().isBlank()) { + entity.setId(request.getVariantDbId().trim()); + } + entity.setCreated(new Date()); + entity.setUpdated(new Date()); + updateEntity(entity, request); + return convertFromEntity(variantRepository.save(entity)); + } + + public Variant updateVariant(String variantDbId, VariantWriteRequest request) throws BrAPIServerException { + VariantEntity entity = getVariantEntity(variantDbId, HttpStatus.NOT_FOUND); + entity.setUpdated(new Date()); + updateEntity(entity, request); + return convertFromEntity(variantRepository.save(entity)); + } + + public Variant deleteVariant(String variantDbId) throws BrAPIServerException { + VariantEntity entity = getVariantEntity(variantDbId, HttpStatus.NOT_FOUND); + assertNoVariantDependencies(variantDbId); + Variant deleted = convertFromEntity(entity); + try { + variantRepository.delete(entity); + variantRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "Variant is in use and cannot be deleted"); + } + return deleted; + } + + private void assertNoVariantDependencies(String variantDbId) throws BrAPIServerException { + Pageable pageReq = PageRequest.of(0, 1); + SearchQueryBuilder callQuery = new SearchQueryBuilder(CallEntity.class) + .appendSingle(variantDbId, "variant.id"); + if (callRepository.findAllBySearch(callQuery, pageReq).getTotalElements() > 0) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "Variant is referenced by allele_call records"); + } + SearchQueryBuilder markerQuery = new SearchQueryBuilder( + MarkerPositionEntity.class).appendSingle(variantDbId, "variant.id"); + if (markerPositionRepository.findAllBySearch(markerQuery, pageReq).getTotalElements() > 0) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "Variant is referenced by marker_position records"); + } + } + + private void updateEntity(VariantEntity entity, VariantWriteRequest request) throws BrAPIServerException { + entity.setVariantName(request.getVariantName().trim()); + entity.setVariantType(request.getVariantType()); + entity.setStart(request.getStart()); + entity.setEnd(request.getEnd()); + if (request.getStart() != null && request.getEnd() != null && request.getEnd() < request.getStart()) { + throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "variant end cannot be less than start"); + } + entity.setReferenceBases(request.getReferenceBases()); + entity.setSvlen(request.getSvlen()); + entity.setFiltersApplied(request.getFiltersApplied()); + entity.setFiltersPassed(request.getFiltersPassed()); + if (request.getAlternateBases() != null) { + entity.setAlternateBases(request.getAlternateBases()); + } + if (request.getCiend() != null) { + entity.setCiend(request.getCiend()); + } + if (request.getCipos() != null) { + entity.setCipos(request.getCipos()); + } + if (request.getFiltersFailed() != null) { + entity.setFiltersFailed(request.getFiltersFailed()); + } + + VariantSetEntity variantSet = null; + if (request.getVariantSetDbId() != null && !request.getVariantSetDbId().isBlank()) { + variantSet = variantSetRepository.findById(request.getVariantSetDbId()) + .orElseThrow(() -> new BrAPIServerDbIdNotFoundException("variantSet", request.getVariantSetDbId(), + HttpStatus.BAD_REQUEST)); + entity.setVariantSet(variantSet); + } else if (request.getVariantSetDbId() != null) { + entity.setVariantSet(null); + } + + ReferenceSetEntity referenceSet = null; + if (request.getReferenceSetDbId() != null && !request.getReferenceSetDbId().isBlank()) { + referenceSet = referenceSetService.getReferenceSetEntity(request.getReferenceSetDbId()); + entity.setReferenceSet(referenceSet); + } else if (request.getReferenceSetDbId() != null) { + entity.setReferenceSet(null); + } else if (variantSet != null && variantSet.getReferenceSet() != null) { + entity.setReferenceSet(variantSet.getReferenceSet()); + } + + if (variantSet != null && referenceSet != null + && variantSet.getReferenceSet() != null + && !variantSet.getReferenceSet().getId().equals(referenceSet.getId())) { + throw new BrAPIServerException(HttpStatus.BAD_REQUEST, + "referenceSetDbId must match the reference set of the selected variantSet"); + } + } + } diff --git a/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetService.java b/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetService.java index 507b1a1..b2aee41 100644 --- a/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetService.java +++ b/src/main/java/org/brapi/test/BrAPITestServer/service/geno/VariantSetService.java @@ -9,17 +9,24 @@ import java.util.stream.Collectors; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; +import org.brapi.test.BrAPITestServer.model.dto.geno.VariantSetWriteRequest; +import org.brapi.test.BrAPITestServer.model.entity.core.StudyEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.CallEntity; +import org.brapi.test.BrAPITestServer.model.entity.geno.ReferenceSetEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.CallSetEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAnalysisEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAvailableFormatEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity; +import org.brapi.test.BrAPITestServer.repository.geno.CallSetRepository; +import org.brapi.test.BrAPITestServer.repository.geno.VariantRepository; import org.brapi.test.BrAPITestServer.repository.geno.VariantSetRepository; +import org.brapi.test.BrAPITestServer.service.core.StudyService; import org.brapi.test.BrAPITestServer.service.DateUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.UpdateUtility; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; @@ -41,16 +48,25 @@ import io.swagger.model.geno.VariantSetsExtractRequest; public class VariantSetService { private final VariantSetRepository variantSetRepository; + private final VariantRepository variantRepository; + private final CallSetRepository callSetRepository; private final VariantService variantService; private final CallSetService callSetService; private final CallService callService; + private final ReferenceSetService referenceSetService; + private final StudyService studyService; - public VariantSetService(VariantSetRepository variantSetRepository, VariantService variantService, - CallSetService callSetService, CallService callService) { + public VariantSetService(VariantSetRepository variantSetRepository, VariantRepository variantRepository, + CallSetRepository callSetRepository, VariantService variantService, CallSetService callSetService, + CallService callService, ReferenceSetService referenceSetService, StudyService studyService) { this.variantSetRepository = variantSetRepository; + this.variantRepository = variantRepository; + this.callSetRepository = callSetRepository; this.variantService = variantService; this.callSetService = callSetService; this.callService = callService; + this.referenceSetService = referenceSetService; + this.studyService = studyService; } public List findVariantSets(String variantSetDbId, String variantDbId, String callSetDbId, @@ -79,9 +95,7 @@ public class VariantSetService { } public List findVariantSets(VariantSetsSearchRequest request, Metadata metadata) { - List variantSets = findVariantSetEntities(request, metadata).stream().map(this::convertFromEntity) - .collect(Collectors.toList()); - return variantSets; + return convertFromEntities(findVariantSetEntities(request, metadata)); } private List findVariantSetEntities(VariantSetsSearchRequest request, Metadata metadata) { @@ -95,7 +109,9 @@ public class VariantSetService { searchQuery.join("variants", "variant").appendList(request.getVariantDbIds(), "*variant.id"); } searchQuery.appendList(request.getStudyDbIds(), "study.id") - .appendList(request.getStudyNames(), "study.studyName").appendList(request.getVariantSetDbIds(), "id"); + .appendList(request.getStudyNames(), "study.studyName") + .appendList(request.getReferenceSetDbIds(), "referenceSet.id") + .appendList(request.getVariantSetDbIds(), "id"); Page page = variantSetRepository.findAllBySearch(searchQuery, pageReq); PagingUtility.calculateMetaData(metadata, page); @@ -106,6 +122,74 @@ public class VariantSetService { return convertFromEntity(getVariantSetEntity(variantSetDbId, HttpStatus.NOT_FOUND)); } + public VariantSet saveVariantSet(VariantSetWriteRequest request) throws BrAPIServerException { + if (request.getVariantSetName() == null || request.getVariantSetName().isBlank()) { + throw new BrAPIServerException(HttpStatus.BAD_REQUEST, "variantSetName is required"); + } + if (request.getVariantSetDbId() != null + && variantSetRepository.findById(request.getVariantSetDbId()).isPresent()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, + "VariantSet already exists: " + request.getVariantSetDbId()); + } + VariantSetEntity entity = new VariantSetEntity(); + if (request.getVariantSetDbId() != null && !request.getVariantSetDbId().isBlank()) { + entity.setId(request.getVariantSetDbId().trim()); + } + updateEntity(entity, request); + return convertFromEntity(variantSetRepository.save(entity)); + } + + public VariantSet updateVariantSet(String variantSetDbId, VariantSetWriteRequest request) + throws BrAPIServerException { + VariantSetEntity entity = getVariantSetEntity(variantSetDbId, HttpStatus.NOT_FOUND); + updateEntity(entity, request); + return convertFromEntity(variantSetRepository.save(entity)); + } + + public VariantSet deleteVariantSet(String variantSetDbId) throws BrAPIServerException { + VariantSetEntity entity = getVariantSetEntity(variantSetDbId, HttpStatus.NOT_FOUND); + assertNoVariantSetDependencies(entity); + VariantSet deleted = convertFromEntity(entity); + try { + variantSetRepository.delete(entity); + variantSetRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "VariantSet is in use and cannot be deleted"); + } + return deleted; + } + + private void assertNoVariantSetDependencies(VariantSetEntity entity) throws BrAPIServerException { + if (entity.getVariants() != null && !entity.getVariants().isEmpty()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "VariantSet is referenced by Variant records"); + } + if (entity.getCallSets() != null && !entity.getCallSets().isEmpty()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "VariantSet is referenced by CallSet records"); + } + if (entity.getAnalysis() != null && !entity.getAnalysis().isEmpty()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "VariantSet is referenced by analysis records"); + } + if (entity.getAvailableFormats() != null && !entity.getAvailableFormats().isEmpty()) { + throw new BrAPIServerException(HttpStatus.CONFLICT, "VariantSet is referenced by available format records"); + } + } + + private void updateEntity(VariantSetEntity entity, VariantSetWriteRequest request) throws BrAPIServerException { + entity.setVariantSetName(request.getVariantSetName().trim()); + if (request.getReferenceSetDbId() != null && !request.getReferenceSetDbId().isBlank()) { + ReferenceSetEntity referenceSet = referenceSetService.getReferenceSetEntity(request.getReferenceSetDbId()); + entity.setReferenceSet(referenceSet); + } else if (request.getReferenceSetDbId() != null) { + entity.setReferenceSet(null); + } + if (request.getStudyDbId() != null && !request.getStudyDbId().isBlank()) { + StudyEntity study = studyService.getStudyEntity(request.getStudyDbId()); + entity.setStudy(study); + } else if (request.getStudyDbId() != null) { + entity.setStudy(null); + } + } + public VariantSetEntity getVariantSetEntity(String variantSetDbId) throws BrAPIServerException { return getVariantSetEntity(variantSetDbId, HttpStatus.BAD_REQUEST); } @@ -122,7 +206,34 @@ public class VariantSetService { return variantSet; } + private List convertFromEntities(List entities) { + if (entities.isEmpty()) { + return List.of(); + } + List variantSetDbIds = entities.stream().map(VariantSetEntity::getId).collect(Collectors.toList()); + Map variantCounts = toCountMap(variantRepository.countVariantsGroupedByVariantSetId(variantSetDbIds)); + Map callSetCounts = toCountMap(callSetRepository.countCallSetsGroupedByVariantSetId(variantSetDbIds)); + return entities.stream() + .map(entity -> convertFromEntity(entity, + variantCounts.getOrDefault(entity.getId(), 0L), + callSetCounts.getOrDefault(entity.getId(), 0L))) + .collect(Collectors.toList()); + } + + private Map toCountMap(List rows) { + Map counts = new HashMap<>(); + for (Object[] row : rows) { + counts.put((String) row[0], ((Number) row[1]).longValue()); + } + return counts; + } + private VariantSet convertFromEntity(VariantSetEntity entity) { + return convertFromEntity(entity, variantRepository.countByVariantSet_Id(entity.getId()), + callSetRepository.countByVariantSetDbId(entity.getId())); + } + + private VariantSet convertFromEntity(VariantSetEntity entity, long variantCount, long callSetCount) { VariantSet variantSet = new VariantSet(); UpdateUtility.convertFromEntity(entity, variantSet); if (entity.getAnalysis() != null) @@ -131,14 +242,12 @@ public class VariantSetService { if (entity.getAvailableFormats() != null) variantSet.setAvailableFormats( entity.getAvailableFormats().stream().map(this::convertFromEntity).collect(Collectors.toList())); - if (entity.getCallSets() != null) - variantSet.setCallSetCount(entity.getCallSets().size()); + variantSet.setCallSetCount(toCountInteger(callSetCount)); if (entity.getReferenceSet() != null) variantSet.setReferenceSetDbId(entity.getReferenceSet().getId()); if (entity.getStudy() != null) variantSet.setStudyDbId(entity.getStudy().getId()); - if (entity.getVariants() != null) - variantSet.setVariantCount(entity.getVariants().size()); + variantSet.setVariantCount(toCountInteger(variantCount)); variantSet.setVariantSetDbId(entity.getId()); variantSet.setVariantSetName(entity.getVariantSetName()); @@ -163,6 +272,10 @@ public class VariantSetService { return variantSet; } + private Integer toCountInteger(long count) { + return count > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) count; + } + private Analysis convertFromEntity(VariantSetAnalysisEntity entity) { Analysis analysis = new Analysis(); analysis.setAnalysisDbId(entity.getId());