fix:java项目性能优化

This commit is contained in:
彭帅
2026-05-28 15:51:39 +08:00
parent 8b65de36b8
commit 3bdd16cbd2
54 changed files with 3178 additions and 624 deletions

View File

@@ -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. 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 ## Prerequisites
* Maven 3.9
* Java 21 | Tool | Required version | Notes |
* Postgres 17.2 | --- | --- | --- |
| **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 ## Auth Configuration
BrAPI has provided a [sample central authentication service for the test server](https://brapi.org/oauth). BrAPI has provided a [sample central authentication service for the test server](https://brapi.org/oauth).

View File

@@ -31,9 +31,16 @@
- VariantSet 列表页支持按 referenceSet、study、variantSetName 查询。 - VariantSet 列表页支持按 referenceSet、study、variantSetName 查询。
- 详情页展示 variants、callsets、analysis、available formats。 - 详情页展示 variants、callsets、analysis、available formats。
- 从 Study 工作台创建时默认带出 `study_id` - 从 Study 工作台创建时默认带出 `study_id`
- **本版本不做**:单条删除、批量删除;放到下一版本实现。
## 关键校验 ## 关键校验
1. `reference_set_id` 与下属 `variant.reference_set_id` 应保持一致。 1. `reference_set_id` 与下属 `variant.reference_set_id` 应保持一致。
2. 删除 variantset 前检查 `variant``callset_variant_sets``variantset_analysis``variantset_format` 2. 导入大型 variantset 时建议先建 variantset再异步导入 variants 和 calls
3. 导入大型 variantset 时建议先建 variantset再异步导入 variants 和 calls 3. **下一版本再做**:删除 variantset 前检查 `variant``callset_variant_sets``variantset_analysis``variantset_format`(含单条删除与批量删除)
## 开发状态
**已完成**2026-05-28列表查询、新增、编辑、详情。
**下一版本**:单条删除、批量删除。

View File

@@ -47,11 +47,18 @@
## 页面与交互 ## 页面与交互
- Variant 列表页支持按 variantSet、referenceSet、variantName、variantType 查询。 - Variant 列表页支持按 variantSet、referenceSet、variantName、variantType 查询。
- 大批量位点建议通过文件导入,不建议普通表单逐条录入。
- 详情页展示 allele_call 数量和 marker_position 入口。 - 详情页展示 allele_call 数量和 marker_position 入口。
- **本版本不做**:单条删除、批量删除、大批量文件导入;放到下一版本实现。
- 本版本仅支持少量位点的表单逐条录入;大批量位点导入下一版本再做。
## 关键校验 ## 关键校验
1. `variant` 是位点定义,不能把样本 genotype 写在本表。 1. `variant` 是位点定义,不能把样本 genotype 写在本表。
2. `variant_set_id``reference_set_id` 应与所属 variantset 保持一致。 2. `variant_set_id``reference_set_id` 应与所属 variantset 保持一致。
3. 删除 variant 前检查 `allele_call``marker_position` 引用。 3. **下一版本再做**删除 variant 前检查 `allele_call``marker_position` 引用(含单条删除与批量删除)
## 开发状态
**已完成**2026-05-28列表查询、新增、编辑、详情。
**下一版本**:单条删除、批量删除、大批量文件导入。

View File

@@ -1,5 +1,9 @@
# Frontend # Frontend
## Prerequisites
Backend API proxy targets `http://localhost:8081` (see root [README](../README.md): **Java 21 + Maven 3.9.9**).
## Start ## Start
```bash ```bash

View File

@@ -109,7 +109,7 @@ export const mapListRecord = (list: ListSummary | ListDetails): ListRecord => ({
}); });
const toRequestBody = (payload: ListPayload, data?: string[]): ListNewRequest => ({ 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), listType: requiredListType(payload.listType ?? payload.list_type),
listDescription: optionalText(payload.listDescription ?? payload.list_description), listDescription: optionalText(payload.listDescription ?? payload.list_description),
listSource: optionalText(payload.listSource ?? payload.list_source), listSource: optionalText(payload.listSource ?? payload.list_source),
@@ -119,7 +119,7 @@ const toRequestBody = (payload: ListPayload, data?: string[]): ListNewRequest =>
}); });
export async function fetchListRows(): Promise<ListRecord[]> { export async function fetchListRows(): Promise<ListRecord[]> {
const response = await request<BrapiListResponse<ListSummary>>("/brapi/v2/lists?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ListSummary>>("/brapi/v2/lists?page=0&pageSize=10");
return response.result.data.map(mapListRecord); return response.result.data.map(mapListRecord);
} }
@@ -129,7 +129,7 @@ export async function fetchListDetail(listDbId: string): Promise<ListRecord> {
} }
export async function fetchPersonOptions(): Promise<SelectOption[]> { export async function fetchPersonOptions(): Promise<SelectOption[]> {
const response = await request<BrapiListResponse<PersonResponse>>("/brapi/v2/people?page=0&pageSize=1000"); const response = await request<BrapiListResponse<PersonResponse>>("/brapi/v2/people?page=0&pageSize=10");
return response.result.data.map((person) => { return response.result.data.map((person) => {
const name = [person.firstName, person.lastName].filter(Boolean).join(" ").trim(); const name = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
const label = name const label = name
@@ -199,7 +199,7 @@ export function normalizeNewItems(existing: string[], incoming: string[]): strin
throw new Error(`以下列表项已存在:${duplicates.join("、")}`); throw new Error(`以下列表项已存在:${duplicates.join("、")}`);
} }
if (added.length === 0) { if (added.length === 0) {
throw new Error("请至少填写一个有效的列表"); throw new Error("请至少填写一个有效的列表");
} }
return added; return added;
} }

View File

@@ -1,3 +1,4 @@
import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api";
import { getAuthToken } from "@/utils/token"; import { getAuthToken } from "@/utils/token";
import type { LocationRecord } from "@/services/dictionaryService"; import type { LocationRecord } from "@/services/dictionaryService";
@@ -29,7 +30,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`); throw new Error(detail || `?????${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -63,7 +64,7 @@ const toRequestBody = (payload: LocationPayload) => ({
topography: emptyToNull(payload.topography), topography: emptyToNull(payload.topography),
}); });
export async function fetchLocationRows(page = 0, pageSize = 1000): Promise<LocationRecord[]> { export async function fetchLocationRows(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise<LocationRecord[]> {
const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>( const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>(
`/brapi/v2/locations?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`, `/brapi/v2/locations?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`,
); );
@@ -79,7 +80,7 @@ export async function fetchLocationDetail(locationDbId: string): Promise<Locatio
export async function createLocationRow(payload: LocationPayload): Promise<LocationRecord> { export async function createLocationRow(payload: LocationPayload): Promise<LocationRecord> {
if (!emptyToNull(payload.locationName)) { if (!emptyToNull(payload.locationName)) {
throw new Error("请填写地点名称"); throw new Error("???????");
} }
const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>("/brapi/v2/locations", { const response = await request<BrapiListResponse<Omit<LocationRecord, "id">>>("/brapi/v2/locations", {
method: "POST", method: "POST",
@@ -87,7 +88,7 @@ export async function createLocationRow(payload: LocationPayload): Promise<Locat
}); });
const location = response.result.data[0]; const location = response.result.data[0];
if (!location) { if (!location) {
throw new Error("新增地点失败:后端未返回数据"); throw new Error("??????????????");
} }
return mapLocation(location); return mapLocation(location);
} }

View File

@@ -1,3 +1,4 @@
import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api";
import { getAuthToken } from "@/utils/token"; import { getAuthToken } from "@/utils/token";
import type { OntologyRecord } from "@/services/dictionaryService"; import type { OntologyRecord } from "@/services/dictionaryService";
@@ -33,7 +34,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`); throw new Error(detail || `?????${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -62,7 +63,7 @@ const toRequestBody = (payload: Record<string, unknown>): OntologyPayload => ({
description: emptyToNull(payload.description), description: emptyToNull(payload.description),
}); });
export async function fetchOntologyRows(page = 0, pageSize = 1000): Promise<OntologyRecord[]> { export async function fetchOntologyRows(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise<OntologyRecord[]> {
const response = await request<BrapiListResponse<OntologyApiRecord>>( const response = await request<BrapiListResponse<OntologyApiRecord>>(
`/brapi/v2/ontologies?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`, `/brapi/v2/ontologies?page=${encodeURIComponent(String(page))}&pageSize=${encodeURIComponent(String(pageSize))}`,
); );
@@ -79,7 +80,7 @@ export async function fetchOntologyDetail(ontologyDbId: string): Promise<Ontolog
export async function createOntologyRow(payload: Record<string, unknown>): Promise<OntologyRecord> { export async function createOntologyRow(payload: Record<string, unknown>): Promise<OntologyRecord> {
const body = toRequestBody(payload); const body = toRequestBody(payload);
if (!body.ontologyName) { if (!body.ontologyName) {
throw new Error("请填写本体名称"); throw new Error("???????");
} }
const response = await request<BrapiListResponse<OntologyApiRecord>>("/brapi/v2/ontologies", { const response = await request<BrapiListResponse<OntologyApiRecord>>("/brapi/v2/ontologies", {
method: "POST", method: "POST",
@@ -87,7 +88,7 @@ export async function createOntologyRow(payload: Record<string, unknown>): Promi
}); });
const ontology = response.result.data[0]; const ontology = response.result.data[0];
if (!ontology) { if (!ontology) {
throw new Error("新增本体失败:后端未返回数据"); throw new Error("??????????????");
} }
return mapOntology(ontology); return mapOntology(ontology);
} }

View File

@@ -118,7 +118,7 @@ const compositeId = (kind: TraitMethodScaleKind, dbId: string) => `${kind}:${dbI
const parseCompositeId = (id: string): { kind: TraitMethodScaleKind; dbId: string } => { const parseCompositeId = (id: string): { kind: TraitMethodScaleKind; dbId: string } => {
const [kind, ...rest] = id.split(":"); const [kind, ...rest] = id.split(":");
if (kind !== "Trait" && kind !== "Method" && kind !== "Scale") { if (kind !== "Trait" && kind !== "Method" && kind !== "Scale") {
throw new Error("标准项类型无"); throw new Error("标准项类型无");
} }
return { kind, dbId: rest.join(":") }; return { kind, dbId: rest.join(":") };
}; };
@@ -188,12 +188,12 @@ const payloadKind = (payload: TraitMethodScalePayload): TraitMethodScaleKind =>
const commonName = (payload: TraitMethodScalePayload) => { const commonName = (payload: TraitMethodScalePayload) => {
const name = optionalText(payload.name); const name = optionalText(payload.name);
if (!name) throw new Error("请填写名称"); if (!name) throw new Error("?????");
return name; return name;
}; };
export async function fetchOntologyOptions(): Promise<OntologyOption[]> { export async function fetchOntologyOptions(): Promise<OntologyOption[]> {
const response = await request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=1000"); const response = await request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=10");
return response.result.data.map((ontology) => ({ return response.result.data.map((ontology) => ({
value: ontology.ontologyDbId, value: ontology.ontologyDbId,
label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`, label: `${ontology.ontologyName || ontology.ontology_name || ontology.ontologyDbId}${ontology.version ? ` / ${ontology.version}` : ""}`,
@@ -202,24 +202,24 @@ export async function fetchOntologyOptions(): Promise<OntologyOption[]> {
export async function fetchTraitMethodScaleRows(kind?: TraitMethodScaleKind): Promise<TraitMethodScaleRecord[]> { export async function fetchTraitMethodScaleRows(kind?: TraitMethodScaleKind): Promise<TraitMethodScaleRecord[]> {
if (kind === "Trait") { if (kind === "Trait") {
const response = await request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"); const response = await request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=10");
return response.result.data.map(mapTrait); return response.result.data.map(mapTrait);
} }
if (kind === "Method") { if (kind === "Method") {
const response = await request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"); const response = await request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=10");
return response.result.data.map(mapMethod); return response.result.data.map(mapMethod);
} }
if (kind === "Scale") { if (kind === "Scale") {
const response = await request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=10");
return response.result.data.map(mapScale); return response.result.data.map(mapScale);
} }
const [traits, methods, scales] = await Promise.all([ const [traits, methods, scales] = await Promise.all([
request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"), request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=10"),
request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"), request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=10"),
request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"), request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=10"),
]); ]);
return [ return [

View File

@@ -3,7 +3,10 @@ import { getAuthToken } from "@/utils/token";
import { import {
NONE_SELECT_VALUE, NONE_SELECT_VALUE,
type ReferenceBasesRecord, type ReferenceBasesRecord,
type ReferenceQuery,
type ReferenceRecord, type ReferenceRecord,
type ReferenceSetPageOptions,
type ReferenceSetQuery,
type ReferenceSetRecord, type ReferenceSetRecord,
type SelectOption, type SelectOption,
} from "./types"; } from "./types";
@@ -41,6 +44,11 @@ interface VariantSetResponse {
referenceSetDbId: string | null; referenceSetDbId: string | null;
} }
interface VariantResponse {
variantDbId: string;
referenceSetDbId: string | null;
}
type ReferenceSetPayload = Partial<Record< type ReferenceSetPayload = Partial<Record<
| "id" | "id"
| "reference_set_name" | "reference_set_name"
@@ -86,7 +94,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `Request failed: ${response.status}`); throw new Error(detail || `请求失败:${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -105,7 +113,7 @@ const requiredText = (value: unknown, message: string) => {
const optionalNumber = (value: unknown) => { const optionalNumber = (value: unknown) => {
const normalized = optionalText(value); const normalized = optionalText(value);
if (!normalized) return null; if (normalized === null) return null;
const parsed = Number(normalized); const parsed = Number(normalized);
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
}; };
@@ -125,19 +133,22 @@ const optionalUrl = (value: unknown, label: string) => {
return normalized; return normalized;
}; };
const validateBases = (value: unknown) => { const validateBases = (value: unknown, required = false) => {
const normalized = optionalText(value); const normalized = optionalText(value);
if (!normalized) return null; if (!normalized) {
if (required) throw new Error("碱基序列片段不能为空");
return null;
}
if (normalized.length > 2048) { if (normalized.length > 2048) {
throw new Error("碱基序列片段不能超过 2048 字符"); throw new Error("碱基序列片段不能超过 2048 字符");
} }
if (!BASES_PATTERN.test(normalized)) { if (!BASES_PATTERN.test(normalized)) {
throw new Error("碱基序列仅允A/C/G/T/N 及常见占位符"); throw new Error("碱基序列仅允<EFBFBD>?A/C/G/T/N 及常见占位符");
} }
return normalized.toUpperCase(); return normalized.toUpperCase();
}; };
const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({ export const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({
...item, ...item,
id: item.referenceSetDbId || item.id, id: item.referenceSetDbId || item.id,
reference_set_name: item.reference_set_name || item.referenceSetName || null, reference_set_name: item.reference_set_name || item.referenceSetName || null,
@@ -166,7 +177,7 @@ const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({
|| null, || null,
}); });
const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({ export const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({
...reference, ...reference,
id: reference.referenceDbId || reference.id, id: reference.referenceDbId || reference.id,
reference_name: reference.reference_name || reference.referenceName || null, 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, source_divergence: reference.source_divergence ?? reference.sourceDivergence ?? null,
}); });
const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({ export const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({
...item, ...item,
id: item.referenceBasesDbId || item.id, id: item.referenceBasesDbId || item.id,
reference_id: item.reference_id || item.referenceDbId || null, 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, page_number: item.page_number ?? item.pageNumber ?? null,
}); });
const referenceSetBody = (payload: ReferenceSetPayload) => ({ function buildSpecies(payload: ReferenceSetPayload) {
referenceSetName: requiredText(payload.reference_set_name, "ReferenceSet 名称不能为空"), const term = optionalText(payload.species_ontology_term);
assemblyPUI: optionalText(payload.assembly_pui), const termURI = optionalUrl(payload.species_ontology_termuri, "物种本体 URI");
description: optionalText(payload.description), if (!term && !termURI) return undefined;
isDerived: optionalBoolean(payload.is_derived), return { term, termURI };
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),
});
const referenceBody = (payload: ReferencePayload) => ({ function buildReferenceSetWriteBody(payload: ReferenceSetPayload) {
referenceName: requiredText(payload.reference_name, "Reference 名称不能为空"), const body: Record<string, unknown> = {
referenceSetDbId: requiredText(payload.reference_set_id, "ReferenceSet 不能为空"), referenceSetName: requiredText(payload.reference_set_name, "请填<EFBFBD>?ReferenceSet 名称"),
length: optionalNumber(payload.length), };
md5checksum: optionalText(payload.md5checksum), const optionalFields: Array<[string, unknown]> = [
sourceDivergence: optionalNumber(payload.source_divergence), ["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) => ({ function buildReferenceWriteBody(payload: ReferencePayload) {
referenceDbId: requiredText(payload.reference_id, "Reference 不能为空"), const body: Record<string, unknown> = {
pageNumber: optionalNumber(payload.page_number), referenceName: requiredText(payload.reference_name, "请填<E8AFB7>?Reference 名称"),
bases: validateBases(payload.bases), 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[], rows: ReferenceSetRecord[],
references: ReferenceRecord[], references: ReferenceRecord[],
variantSets: VariantSetResponse[], variantSets: VariantSetResponse[],
) => rows.map((row) => ({ variants: VariantResponse[],
...row, ) {
reference_count: references.filter((item) => item.reference_set_id === row.id).length, return rows.map((row) => ({
variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length, ...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 referenceSetRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets?page=0&pageSize=10");
return response.result.data.map(mapReferenceSet); return response.result.data.map(mapReferenceSet);
}); });
const referenceRowsLoader = createCachedLoader(async () => { const referenceRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references?page=0&pageSize=10");
return response.result.data.map(mapReference); return response.result.data.map(mapReference);
}); });
const variantSetRowsLoader = createCachedLoader(async () => { const variantSetRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=1000"); const response = await request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=10");
return response.result.data;
});
const variantRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantResponse>>("/brapi/v2/variants?page=0&pageSize=10");
return response.result.data; return response.result.data;
}); });
const referenceBasesRowsLoader = createCachedLoader(async () => { const referenceBasesRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases?page=0&pageSize=10");
return response.result.data.map(mapReferenceBases); return response.result.data.map(mapReferenceBases);
}); });
@@ -247,32 +350,11 @@ export function invalidateReferenceSetPageCache() {
referenceSetRowsLoader.invalidate(); referenceSetRowsLoader.invalidate();
referenceRowsLoader.invalidate(); referenceRowsLoader.invalidate();
variantSetRowsLoader.invalidate(); variantSetRowsLoader.invalidate();
variantRowsLoader.invalidate();
referenceBasesRowsLoader.invalidate(); referenceBasesRowsLoader.invalidate();
} }
export async function fetchReferenceSetRows(force = false): Promise<ReferenceSetRecord[]> { export async function fetchReferenceSetOptions(force = false): Promise<ReferenceSetPageOptions> {
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<ReferenceRecord[]> {
return referenceRowsLoader.load(force);
}
export async function fetchReferenceBasesRows(force = false): Promise<ReferenceBasesRecord[]> {
return referenceBasesRowsLoader.load(force);
}
export async function fetchReferenceSetOptions(force = false): Promise<{
referenceSets: SelectOption[];
references: SelectOption[];
germplasm: SelectOption[];
}> {
const [sharedOptions, referenceSets, references] = await Promise.all([ const [sharedOptions, referenceSets, references] = await Promise.all([
loadDropdownBundle({ germplasms: true }, force), loadDropdownBundle({ germplasms: true }, force),
referenceSetRowsLoader.load(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<ReferenceSetRecord[]> {
const { referenceSets } = await loadReferenceSetPageData({ referenceSetQuery: query, force });
return referenceSets;
}
export async function fetchReferenceRows(query?: ReferenceQuery, force = false): Promise<ReferenceRecord[]> {
const { references } = await loadReferenceSetPageData({ referenceQuery: query, force });
return references;
}
export async function fetchReferenceSetDetail(id: string): Promise<ReferenceSetRecord> {
const response = await request<BrapiSingleResponse<ReferenceSetRecord>>(
`/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<ReferenceRecord> {
const response = await request<BrapiSingleResponse<ReferenceRecord>>(
`/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<ReferenceBasesRecord[]> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
return {
id: record.id,
reference_id: record.reference_id ?? "",
page_number: record.page_number ?? "",
bases: record.bases ?? "",
};
}
export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise<ReferenceSetRecord> { export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise<ReferenceSetRecord> {
const body = {
...buildReferenceSetWriteBody(payload),
...(optionalText(payload.id) ? { referenceSetDbId: optionalText(payload.id) } : {}),
};
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets", { const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify(body),
referenceSetDbId: requiredText(payload.id, "ReferenceSet ID 不能为空"),
...referenceSetBody(payload),
}),
}); });
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();
return mapReferenceSet(response.result.data[0]); return mapReferenceSet(response.result.data[0]);
@@ -313,7 +509,7 @@ export async function updateReferenceSetRow(id: string, payload: ReferenceSetPay
`/brapi/v2/referencesets/${encodeURIComponent(id)}`, `/brapi/v2/referencesets/${encodeURIComponent(id)}`,
{ {
method: "PUT", method: "PUT",
body: JSON.stringify(referenceSetBody(payload)), body: JSON.stringify(buildReferenceSetWriteBody(payload)),
}, },
); );
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();
@@ -329,12 +525,13 @@ export async function deleteReferenceSetRow(id: string): Promise<void> {
} }
export async function createReferenceRow(payload: ReferencePayload): Promise<ReferenceRecord> { export async function createReferenceRow(payload: ReferencePayload): Promise<ReferenceRecord> {
const body = {
...buildReferenceWriteBody(payload),
...(optionalText(payload.id) ? { referenceDbId: optionalText(payload.id) } : {}),
};
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references", { const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify(body),
referenceDbId: requiredText(payload.id, "Reference ID 不能为空"),
...referenceBody(payload),
}),
}); });
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();
return mapReference(response.result.data[0]); return mapReference(response.result.data[0]);
@@ -349,7 +546,7 @@ export async function updateReferenceRow(id: string, payload: ReferencePayload):
`/brapi/v2/references/${encodeURIComponent(id)}`, `/brapi/v2/references/${encodeURIComponent(id)}`,
{ {
method: "PUT", method: "PUT",
body: JSON.stringify(referenceBody(payload)), body: JSON.stringify(buildReferenceWriteBody(payload)),
}, },
); );
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();
@@ -365,12 +562,13 @@ export async function deleteReferenceRow(id: string): Promise<void> {
} }
export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> { export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> {
const body = {
...buildReferenceBasesWriteBody(payload, true),
...(optionalText(payload.id) ? { referenceBasesDbId: optionalText(payload.id) } : {}),
};
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases", { const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify(body),
referenceBasesDbId: requiredText(payload.id, "ReferenceBases ID 不能为空"),
...referenceBasesBody(payload),
}),
}); });
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();
return mapReferenceBases(response.result.data[0]); return mapReferenceBases(response.result.data[0]);
@@ -385,7 +583,7 @@ export async function updateReferenceBasesRow(id: string, payload: ReferenceBase
`/brapi/v2/referencebases/${encodeURIComponent(id)}`, `/brapi/v2/referencebases/${encodeURIComponent(id)}`,
{ {
method: "PUT", method: "PUT",
body: JSON.stringify(referenceBasesBody(payload)), body: JSON.stringify(buildReferenceBasesWriteBody(payload, false)),
}, },
); );
invalidateReferenceSetPageCache(); invalidateReferenceSetPageCache();

View File

@@ -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<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<ReferenceSetQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<ReferenceSetQuery>(emptyQuery);
const loadRows = useCallback(async () => {
const { options, referenceSets } = await loadReferenceSetPageData({ referenceSetQuery: appliedQuery });
setGermplasmOptions(options.germplasm);
return referenceSets as unknown as Record<string, unknown>[];
}, [appliedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchReferenceSetDetail(id);
return normalizeReferenceSetFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ 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<string, unknown> }) => {
const md5Warning = warnMd5Checksum(props.formData.md5checksum);
return (
<div className="col-span-2 space-y-2 rounded-lg border border-indigo-100 bg-indigo-50/60 p-3 text-xs text-indigo-900 dark:border-indigo-900/40 dark:bg-indigo-950/30 dark:text-indigo-100">
<p className="font-medium"></p>
<p> Reference / VariantSet / Variant Germplasm </p>
{md5Warning ? <p className="text-amber-700 dark:text-amber-300">{md5Warning}</p> : null}
</div>
);
}, []);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">ReferenceSet </Label>
<Input
value={draftQuery.reference_set_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, reference_set_name: event.target.value }))}
placeholder="名称模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Assembly PUI</Label>
<Input
value={draftQuery.assembly_pui ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, assembly_pui: event.target.value }))}
placeholder="PUI 模糊匹配"
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => { const reset = emptyQuery(); setDraftQuery(reset); setAppliedQuery(reset); }}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Layers}
iconBg="bg-gradient-to-br from-indigo-500 to-violet-600"
title="ReferenceSet 参考基因组集合"
description="维护参考基因组集合、Assembly 标识、物种信息与来源 Germplasm。点击进入详情可管理下属 Reference。"
addLabel="新增 ReferenceSet"
columns={[
{
key: "reference_set_name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.referenceSetDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link href={`/genotyping/reference-set/referencesets/${encodeURIComponent(id)}`} className="font-medium text-indigo-600 hover:underline dark:text-indigo-400">
{name}
</Link>
);
},
},
{ 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<Record<string, unknown>>}
updateRecord={(id, payload) => updateReferenceSetRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteReferenceSetRow}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -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<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<ReferenceQuery>(() => ({
...emptyQuery(),
reference_set_id: searchParams.get("reference_set_id") ?? NONE_SELECT_VALUE,
}));
const [appliedQuery, setAppliedQuery] = useState<ReferenceQuery>(() => ({
...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<string, unknown>[];
}, [appliedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchReferenceDetail(id);
return normalizeReferenceFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ 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(() => (
<div className="col-span-2 rounded-lg border border-emerald-100 bg-emerald-50/60 p-3 text-xs text-emerald-900 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-100">
<p className="font-medium"></p>
<p>ReferenceSet ReferenceBases </p>
</div>
), []);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Reference </Label>
<Input
value={draftQuery.reference_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, reference_name: event.target.value }))}
placeholder="名称模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">ReferenceSet</Label>
<Select
value={toSelectValue(draftQuery.reference_set_id)}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, reference_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}> ReferenceSet</SelectItem>
{referenceSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => { const reset = emptyQuery(); setDraftQuery(reset); setAppliedQuery(reset); }}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, referenceSetOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={BookOpen}
iconBg="bg-gradient-to-br from-emerald-500 to-teal-600"
title="Reference 参考序列"
description="维护染色体、Contig 或 Scaffold 等参考序列,隶属于 ReferenceSet。"
addLabel="新增 Reference"
defaultFormValues={urlDefaultFormValues}
columns={[
{
key: "reference_name",
label: "序列名称",
render: (value, row) => {
const id = String(row.id ?? row.referenceDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link href={`/genotyping/reference-set/references/${encodeURIComponent(id)}`} className="font-medium text-emerald-600 hover:underline dark:text-emerald-400">
{name}
</Link>
);
},
},
{ 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<Record<string, unknown>>}
updateRecord={(id, payload) => updateReferenceRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteReferenceRow}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -1,159 +1,23 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { BookOpen, Dna, Layers } from "lucide-react"; import { useSearchParams } from "next/navigation";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; import { BookOpen, Layers } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import { Skeleton } from "@/components/ui/skeleton";
createReferenceBasesRow, import { ReferenceSetTab } from "./components/ReferenceSetTab";
createReferenceRow, import { ReferenceTab } from "./components/ReferenceTab";
createReferenceSetRow,
deleteReferenceBasesRow,
deleteReferenceRow,
deleteReferenceSetRow,
fetchReferenceBasesRows,
fetchReferenceRows,
fetchReferenceSetOptions,
fetchReferenceSetRows,
updateReferenceBasesRow,
updateReferenceRow,
updateReferenceSetRow,
} from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const booleanOptions: SelectOption[] = [ function ReferenceSetPageContent() {
{ value: NONE_SELECT_VALUE, label: "不指定" }, const searchParams = useSearchParams();
{ 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() {
const [tab, setTab] = useState("reference-sets"); const [tab, setTab] = useState("reference-sets");
const [referenceSetOptions, setReferenceSetOptions] = useState<SelectOption[]>([]);
const [referenceOptions, setReferenceOptions] = useState<SelectOption[]>([]);
const [germplasmOptions, setGermplasmOptions] = useState<SelectOption[]>([]);
const applyOptions = useCallback((options: Awaited<ReturnType<typeof fetchReferenceSetOptions>>) => {
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(() => { useEffect(() => {
let mounted = true; const nextTab = searchParams.get("tab");
refreshOptions() if (nextTab === "references" || nextTab === "reference-sets") {
.catch(() => undefined) setTab(nextTab);
.finally(() => { }
if (!mounted) return; }, [searchParams]);
});
return () => {
mounted = false;
};
}, [refreshOptions]);
const loadReferenceSets = useCallback(async () => {
const rows = await fetchReferenceSetRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadReferences = useCallback(async () => {
const rows = await fetchReferenceRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const loadReferenceBases = useCallback(async () => {
const rows = await fetchReferenceBasesRows();
return rows as unknown as Record<string, unknown>[];
}, []);
const refreshAfterMutation = useCallback(async <T,>(action: () => Promise<T>) => {
const result = await action();
await refreshOptions(true);
return result;
}, [refreshOptions]);
const referenceSetFields = useMemo<BrapiFormField[]>(() => [
{ 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<BrapiFormField[]>(() => [
{ 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<BrapiFormField[]>(() => [
{ 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]);
return ( return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4"> <Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
@@ -166,116 +30,31 @@ export default function ReferenceSetPage() {
<BookOpen className="h-4 w-4" /> <BookOpen className="h-4 w-4" />
Reference Reference
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="reference-bases" className="gap-2">
<Dna className="h-4 w-4" />
ReferenceBases
</TabsTrigger>
</TabsList> </TabsList>
{tab === "reference-sets" ? ( {tab === "reference-sets" ? (
<TabsContent value="reference-sets" className="mt-0 min-h-0 flex-1"> <TabsContent value="reference-sets" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage <ReferenceSetTab />
useEnhancedDialog
icon={Layers}
iconBg="bg-gradient-to-br from-indigo-500 to-violet-600"
title="ReferenceSet 参考基因组集合"
description="维护参考基因组集合、Assembly 标识、物种信息和来源 Germplasm。"
addLabel="新增 ReferenceSet"
columns={[
{ key: "referenceSetDbId", label: "ReferenceSet ID" },
{ key: "reference_set_name", label: "名称" },
{ 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: "is_derived", label: "派生", render: boolLabel },
]}
fields={referenceSetFields}
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={loadReferenceSets}
createRecord={(payload) => refreshAfterMutation(() => createReferenceSetRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceSetRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceSetRow(id);
await refreshOptions(true);
}}
/>
</TabsContent> </TabsContent>
) : null} ) : null}
{tab === "references" ? ( {tab === "references" ? (
<TabsContent value="references" className="mt-0 min-h-0 flex-1"> <TabsContent value="references" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage <ReferenceTab />
useEnhancedDialog
icon={BookOpen}
iconBg="bg-gradient-to-br from-emerald-500 to-teal-600"
title="Reference 参考序列"
description="维护 ReferenceSet 下的染色体、Contig 或 Scaffold 等参考序列。"
addLabel="新增 Reference"
columns={[
{ key: "referenceDbId", label: "Reference ID" },
{ key: "reference_name", label: "序列名称" },
{ key: "reference_set_name", label: "ReferenceSet" },
{ key: "length", label: "长度" },
{ key: "source_divergence", label: "来源差异" },
{ key: "md5checksum", label: "MD5" },
]}
fields={referenceFields}
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={loadReferences}
createRecord={(payload) => refreshAfterMutation(() => createReferenceRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceRow(id);
await refreshOptions(true);
}}
/>
</TabsContent>
) : null}
{tab === "reference-bases" ? (
<TabsContent value="reference-bases" className="mt-0 min-h-0 flex-1">
<BrapiEntityPage
useEnhancedDialog
icon={Dna}
iconBg="bg-gradient-to-br from-cyan-500 to-blue-600"
title="ReferenceBases 序列片段"
description="按 Reference 分页维护碱基序列片段,适合导入或补录分页内容。"
addLabel="新增 ReferenceBases"
columns={[
{ key: "referenceBasesDbId", label: "ID" },
{ key: "reference_name", label: "Reference" },
{ key: "page_number", label: "分页序号" },
{ key: "bases", label: "碱基片段", render: truncateBases },
]}
fields={referenceBasesFields}
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={loadReferenceBases}
createRecord={(payload) => refreshAfterMutation(() => createReferenceBasesRow(payload)) as unknown as Promise<Record<string, unknown>>}
updateRecord={(id, payload) => refreshAfterMutation(() => updateReferenceBasesRow(id, payload)) as unknown as Promise<Record<string, unknown>>}
deleteRecord={async (id) => {
await deleteReferenceBasesRow(id);
await refreshOptions(true);
}}
/>
</TabsContent> </TabsContent>
) : null} ) : null}
</Tabs> </Tabs>
); );
} }
function PageFallback() {
return <Skeleton className="h-96 w-full rounded-xl" />;
}
export default function ReferenceSetPage() {
return (
<Suspense fallback={<PageFallback />}>
<ReferenceSetPageContent />
</Suspense>
);
}

View File

@@ -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);
}

View File

@@ -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<string | null>(null);
const [detail, setDetail] = useState<ReferenceRecord | null>(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<string, unknown>[];
}, [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<BrapiFormField[]>(() => [
{ 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 (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "Reference 不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/reference-set"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href={detail.reference_set_id ? `/genotyping/reference-set/referencesets/${encodeURIComponent(detail.reference_set_id)}` : "/genotyping/reference-set"}>
<ArrowLeft className="mr-2 h-4 w-4" />
ReferenceSet
</Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<BookOpen className="h-5 w-5 text-emerald-500" />
{detail.reference_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Reference ID</span>{detail.id}</div>
<div><span className="text-slate-500">ReferenceSet</span>{detail.reference_set_name || detail.reference_set_id || "—"}</div>
<div><span className="text-slate-500"></span>{detail.length ?? "—"}</div>
<div><span className="text-slate-500">MD5</span>{detail.md5checksum || "—"}</div>
<div><span className="text-slate-500"></span>{detail.source_divergence ?? "—"}</div>
<div><span className="text-slate-500">Bases </span>{detail.bases_page_count ?? 0}</div>
<div><span className="text-slate-500">Bases </span>{detail.bases_total_length ?? 0}</div>
</CardContent>
</Card>
{lengthMismatch ? (
<Badge variant="outline" className="w-fit border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
Bases ({detail.bases_total_length}) ({detail.length})
</Badge>
) : null}
<BrapiEntityPage
useEnhancedDialog
icon={Dna}
iconBg="bg-gradient-to-br from-cyan-500 to-blue-600"
title="ReferenceBases 序列分页"
description="按 page_number 维护碱基片段;长序列建议走文件导入,不建议手工逐页录入。"
addLabel="新增分页"
defaultFormValues={defaultFormValues}
columns={[
{ key: "page_number", label: "分页序号" },
{ key: "bases", label: "碱基片段", render: (value) => 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<Record<string, unknown>>}
updateRecord={(id, payload) => updateReferenceBasesRow(id, { ...payload, reference_id: referenceDbId }) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteReferenceBasesRow}
/>
</div>
);
}

View File

@@ -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<string | null>(null);
const [detail, setDetail] = useState<ReferenceSetRecord | null>(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<string, unknown>[];
}, [referenceSetDbId]);
const referenceColumns = useMemo(() => [
{
key: "reference_name",
label: "序列名称",
render: (value: unknown, row: Record<string, unknown>) => {
const id = String(row.id ?? row.referenceDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link href={`/genotyping/reference-set/references/${encodeURIComponent(id)}`} className="font-medium text-emerald-600 hover:underline dark:text-emerald-400">
{name}
</Link>
);
},
},
{ key: "length", label: "长度" },
{ key: "bases_page_count", label: "Bases 分页数" },
{ key: "md5checksum", label: "MD5" },
], []);
const emptyFields = useMemo<BrapiFormField[]>(() => [], []);
if (loading) {
return (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "ReferenceSet 不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/reference-set"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<Button asChild variant="outline" size="sm" className="w-fit">
<Link href="/genotyping/reference-set"><ArrowLeft className="mr-2 h-4 w-4" /> ReferenceSet </Link>
</Button>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Layers className="h-5 w-5 text-indigo-500" />
{detail.reference_set_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">ReferenceSet ID</span>{detail.id}</div>
<div><span className="text-slate-500">Assembly PUI</span>{detail.assembly_pui || "—"}</div>
<div><span className="text-slate-500"></span>{detail.species_ontology_term || "—"}</div>
<div><span className="text-slate-500"> Germplasm</span>{detail.source_germplasm_name || "—"}</div>
<div><span className="text-slate-500">Reference </span>{detail.reference_count ?? 0}</div>
<div><span className="text-slate-500">VariantSet </span>{detail.variantset_count ?? 0}</div>
<div><span className="text-slate-500">Variant </span>{detail.variant_count ?? 0}</div>
<div><span className="text-slate-500"></span>{boolLabel(detail.is_derived)}</div>
<div className="sm:col-span-2"><span className="text-slate-500"></span>{detail.description || "—"}</div>
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/genotyping/reference-set?tab=references&reference_set_id=${encodeURIComponent(detail.id)}`}>
<BookOpen className="mr-2 h-4 w-4" />
Reference
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/genotyping/variant">
<Sigma className="mr-2 h-4 w-4" />
Variant / VariantSet
</Link>
</Button>
</div>
<BrapiEntityPage
icon={BookOpen}
iconBg="bg-gradient-to-br from-emerald-500 to-teal-600"
title="下属 Reference"
description="该 ReferenceSet 下的参考序列列表,点击名称进入 Reference 详情维护 Bases。"
columns={referenceColumns}
fields={emptyFields}
data={[]}
loadData={loadReferences}
/>
</div>
);
}

View File

@@ -5,6 +5,16 @@ export interface SelectOption {
label: string; 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 { export interface ReferenceSetRecord {
id: string; id: string;
referenceSetDbId: string; referenceSetDbId: string;
@@ -28,6 +38,7 @@ export interface ReferenceSetRecord {
source_germplasm_name: string | null; source_germplasm_name: string | null;
reference_count?: number | null; reference_count?: number | null;
variantset_count?: number | null; variantset_count?: number | null;
variant_count?: number | null;
} }
export interface ReferenceRecord { export interface ReferenceRecord {
@@ -43,6 +54,8 @@ export interface ReferenceRecord {
md5checksum: string | null; md5checksum: string | null;
sourceDivergence: number | string | null; sourceDivergence: number | string | null;
source_divergence: number | string | null; source_divergence: number | string | null;
bases_page_count?: number | null;
bases_total_length?: number | null;
} }
export interface ReferenceBasesRecord { export interface ReferenceBasesRecord {
@@ -56,3 +69,9 @@ export interface ReferenceBasesRecord {
pageNumber: number | string | null; pageNumber: number | string | null;
bases: string | null; bases: string | null;
} }
export interface ReferenceSetPageOptions {
referenceSets: SelectOption[];
references: SelectOption[];
germplasm: SelectOption[];
}

View File

@@ -143,7 +143,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`); throw new Error(detail || `?????${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -168,7 +168,7 @@ const optionalNumber = (value: unknown) => {
}; };
const trialContextLoader = createCachedLoader(async () => { const trialContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<TrialResponse>>("/brapi/v2/trials?page=0&pageSize=1000"); const response = await request<BrapiListResponse<TrialResponse>>("/brapi/v2/trials?page=0&pageSize=10");
return response.result.data.map((trial) => ({ return response.result.data.map((trial) => ({
value: trial.trialDbId, value: trial.trialDbId,
label: trial.trialName || trial.trialDbId, label: trial.trialName || trial.trialDbId,
@@ -177,7 +177,7 @@ const trialContextLoader = createCachedLoader(async () => {
}); });
const studyContextLoader = createCachedLoader(async () => { const studyContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=1000"); const response = await request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=10");
return response.result.data.map((study) => ({ return response.result.data.map((study) => ({
value: study.studyDbId, value: study.studyDbId,
label: study.studyName || study.studyDbId, label: study.studyName || study.studyDbId,
@@ -188,7 +188,7 @@ const studyContextLoader = createCachedLoader(async () => {
const observationUnitContextLoader = createCachedLoader(async () => { const observationUnitContextLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<ObservationUnitResponse>>( const response = await request<BrapiListResponse<ObservationUnitResponse>>(
"/brapi/v2/observationunits?page=0&pageSize=1000", "/brapi/v2/observationunits?page=0&pageSize=10",
); );
return response.result.data.map((unit) => ({ return response.result.data.map((unit) => ({
value: unit.observationUnitDbId, value: unit.observationUnitDbId,
@@ -198,12 +198,12 @@ const observationUnitContextLoader = createCachedLoader(async () => {
}); });
const plateRowsLoader = createCachedLoader(async () => { const plateRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<PlateRecord>>("/brapi/v2/plates?page=0&pageSize=1000"); const response = await request<BrapiListResponse<PlateRecord>>("/brapi/v2/plates?page=0&pageSize=10");
return response.result.data.map(mapPlate); return response.result.data.map(mapPlate);
}); });
const sampleRowsAllLoader = createCachedLoader(async () => { const sampleRowsAllLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SampleRecord>>("/brapi/v2/samples?page=0&pageSize=1000"); const response = await request<BrapiListResponse<SampleRecord>>("/brapi/v2/samples?page=0&pageSize=10");
return response.result.data.map(mapSample); return response.result.data.map(mapSample);
}); });
@@ -238,7 +238,7 @@ function hasSampleServerFilter(query?: SampleQuery, plateDbId?: string) {
async function loadRawPlates(query?: PlateQuery, force = false): Promise<PlateRecord[]> { async function loadRawPlates(query?: PlateQuery, force = false): Promise<PlateRecord[]> {
if (hasPlateServerFilter(query)) { 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?.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?.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); 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<PlateRe
async function loadRawSamples(query?: SampleQuery, plateDbId?: string, force = false): Promise<SampleRecord[]> { async function loadRawSamples(query?: SampleQuery, plateDbId?: string, force = false): Promise<SampleRecord[]> {
if (hasSampleServerFilter(query, plateDbId)) { 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); const effectivePlateId = plateDbId || optionalText(query?.plate_id);
if (effectivePlateId) params.set("plateDbId", effectivePlateId); if (effectivePlateId) params.set("plateDbId", effectivePlateId);
if (query?.sample_name) params.set("sampleName", query.sample_name); 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 plateBody = (payload: PlatePayload) => {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
plateName: requiredText(payload.plate_name, "请填写样本板名称"), plateName: requiredText(payload.plate_name, "????????"),
}; };
const plateBarcode = optionalText(payload.plate_barcode); const plateBarcode = optionalText(payload.plate_barcode);
const plateFormat = optionalText(payload.plate_format); const plateFormat = optionalText(payload.plate_format);
@@ -409,7 +409,7 @@ const sampleBody = (payload: SamplePayload, plateFormat?: string | null) => {
validatePlateWell(plateFormat ?? null, row, column, well); validatePlateWell(plateFormat ?? null, row, column, well);
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
sampleName: requiredText(payload.sample_name, "请填写样本名称"), sampleName: requiredText(payload.sample_name, "???????"),
}; };
const optionalFields: Array<[string, unknown]> = [ const optionalFields: Array<[string, unknown]> = [
@@ -675,15 +675,15 @@ export function normalizeSampleFormData(record: SampleRecord): Record<string, un
export async function deleteSampleRow(id: string): Promise<void> { export async function deleteSampleRow(id: string): Promise<void> {
const callsetCount = await countCallsetsBySample(id); const callsetCount = await countCallsetsBySample(id);
if (callsetCount > 0) { 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<void> { export async function deletePlateRow(id: string): Promise<void> {
const count = await countSamplesByPlate(id); const count = await countSamplesByPlate(id);
if (count > 0) { 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???????????????");
} }

View File

@@ -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<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: {
data: T[];
};
}
interface BrapiSingleResponse<T> {
metadata: {
pagination: BrapiPagination;
status: Array<Record<string, unknown>>;
datafiles: Array<Record<string, unknown>>;
};
result: T;
}
interface ReferenceSetResponse {
referenceSetDbId: string;
referenceSetName: string | null;
}
interface StudyLookup {
studyDbId: string;
studyName: string | null;
}
type VariantSetPayload = Partial<Record<
"id" | "variant_set_name" | "reference_set_id" | "study_id",
unknown
>>;
const apiBase = () => {
if (typeof window !== "undefined") return "";
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const response = await fetch(`${apiBase()}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(detail || `Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
const optionalText = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized || normalized === NONE_SELECT_VALUE) return null;
return normalized;
};
const requiredText = (value: unknown, message: string) => {
const normalized = optionalText(value);
if (!normalized) throw new Error(message);
return normalized;
};
const 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<BrapiListResponse<ReferenceSetResponse>>("/brapi/v2/referencesets?page=0&pageSize=10");
return response.result.data;
});
const variantSetListLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantSetRecord>>("/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<VariantSetRecord[]> {
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<VariantSetDetail> {
const [detail, options] = await Promise.all([
request<BrapiSingleResponse<VariantSetDetail>>(`/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<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/variants?pageSize=10`,
);
return response.result.data;
}
export async function fetchVariantSetCallsets(variantSetDbId: string) {
const response = await request<BrapiListResponse<Record<string, unknown>>>(
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/callsets?page=0&pageSize=10`,
);
return response.result.data;
}
export async function createVariantSetRow(payload: VariantSetPayload): Promise<VariantSetRecord> {
const response = await request<BrapiListResponse<VariantSetRecord>>("/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<VariantSetRecord> {
const requestedId = optionalText(payload.id);
if (requestedId && requestedId !== id) {
throw new Error("VariantSet ID 不可修改,请新建记录");
}
const response = await request<BrapiSingleResponse<VariantSetRecord>>(
`/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<void> {
await request<BrapiSingleResponse<VariantSetRecord>>(`/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)),
};
}

View File

@@ -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<SelectOption[]>([]);
const [studyOptions, setStudyOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<VariantSetQuery>(emptyQuery);
const [appliedQuery, setAppliedQuery] = useState<VariantSetQuery>(emptyQuery);
const loadRows = useCallback(async () => {
const { options, rows } = await loadVariantSetPageData({ query: appliedQuery });
setReferenceSetOptions(options.referenceSets);
setStudyOptions(options.studies);
return rows as unknown as Record<string, unknown>[];
}, [appliedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchVariantSetDetail(id);
return normalizeVariantSetFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ 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(() => (
<div className="col-span-2 rounded-lg border border-violet-100 bg-violet-50/60 p-3 text-xs text-violet-900 dark:border-violet-900/40 dark:bg-violet-950/30 dark:text-violet-100">
<p className="font-medium">ReferenceSet </p>
<p className="mt-1 text-violet-800/80 dark:text-violet-200/80">
VariantSet ReferenceSet Variant reference_set_id
</p>
</div>
), []);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.variant_set_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, variant_set_name: event.target.value }))}
placeholder="variantSetName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">ReferenceSet</Label>
<Select
value={draftQuery.reference_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, reference_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{referenceSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">Study</Label>
<Select
value={draftQuery.study_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, study_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{studyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, referenceSetOptions, studyOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Layers}
iconBg="bg-gradient-to-br from-violet-500 to-purple-600"
title="VariantSet 变异集合"
description="维护测序、芯片或 Study 下的变异位点集合,并关联 ReferenceSet 与 Study。"
addLabel="新增 VariantSet"
columns={[
{
key: "variant_set_name",
label: "名称",
render: (value, row) => {
const id = String(row.id ?? row.variantSetDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/genotyping/variant-set/variant-sets/${encodeURIComponent(id)}`}
className="font-medium text-violet-600 hover:underline dark:text-violet-400"
>
{name}
</Link>
);
},
},
{ key: "reference_set_name", label: "ReferenceSet" },
{ key: "study_name", label: "Study" },
{
key: "variant_count",
label: "Variant 数",
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
},
{
key: "callset_count",
label: "CallSet 数",
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
},
{ 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<Record<string, unknown>>}
updateRecord={(id, payload) => updateVariantSetRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteVariantSetRow}
renderQueryForm={renderQueryForm}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -0,0 +1,11 @@
"use client";
import { VariantSetTab } from "./components/VariantSetTab";
export default function VariantSetPage() {
return (
<div className="flex min-h-full flex-col gap-4">
<VariantSetTab />
</div>
);
}

View File

@@ -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[];
}

View File

@@ -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<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchVariantSetDetail>> | null>(null);
const [variants, setVariants] = useState<Array<Record<string, unknown>>>([]);
const [callsets, setCallsets] = useState<Array<Record<string, unknown>>>([]);
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 (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "VariantSet 不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/variant-set"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
const hasDependencies = (detail.variant_count ?? 0) > 0
|| (detail.callset_count ?? 0) > 0
|| (detail.analysis_count ?? 0) > 0
|| (detail.format_count ?? 0) > 0;
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/genotyping/variant-set"><ArrowLeft className="mr-2 h-4 w-4" /> VariantSet </Link>
</Button>
{hasDependencies ? (
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
VariantCallSetAnalysis Format
</Badge>
) : null}
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Layers className="h-5 w-5 text-violet-500" />
{detail.variant_set_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">VariantSet ID</span>{detail.id}</div>
<div><span className="text-slate-500">ReferenceSet</span>{detail.reference_set_name || "N/A"}</div>
<div><span className="text-slate-500">Study</span>{detail.study_name || "N/A"}</div>
<div><span className="text-slate-500">Variant </span>{detail.variant_count ?? 0}</div>
<div><span className="text-slate-500">CallSet </span>{detail.callset_count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Sigma className="h-4 w-4 text-rose-500" />
Variants
</CardTitle>
</CardHeader>
<CardContent>
{variants.length === 0 ? (
<p className="text-sm text-slate-500"> Variant </p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Variant ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={id}>
<TableCell>
{id ? (
<Link href={`/genotyping/variant/variants/${encodeURIComponent(id)}`} className="text-rose-600 hover:underline dark:text-rose-400">
{id}
</Link>
) : "—"}
</TableCell>
<TableCell>{names}</TableCell>
<TableCell>{String(row.variantType ?? "—")}</TableCell>
<TableCell>{String(row.start ?? "—")}</TableCell>
<TableCell>{String(row.end ?? "—")}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
{variants.length > 20 ? (
<p className="mt-2 text-xs text-slate-500"> 20 Variant </p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Binary className="h-4 w-4 text-sky-500" />
CallSets
</CardTitle>
</CardHeader>
<CardContent>
{callsets.length === 0 ? (
<p className="text-sm text-slate-500"> CallSet</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>CallSet ID</TableHead>
<TableHead></TableHead>
<TableHead>Sample</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{callsets.map((row) => (
<TableRow key={String(row.callSetDbId ?? row.id ?? Math.random())}>
<TableCell>{String(row.callSetDbId ?? "—")}</TableCell>
<TableCell>{String(row.callSetName ?? "—")}</TableCell>
<TableCell>{String(row.sampleName ?? row.sampleDbId ?? "—")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Analysis</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{(detail.analysis || []).length === 0 ? (
<p className="text-slate-500"> Analysis</p>
) : (
detail.analysis.map((item) => (
<div key={item.analysisDbId || item.analysisName || Math.random()} className="rounded-lg border p-3 dark:border-slate-800">
<div className="font-medium">{item.analysisName || item.analysisDbId}</div>
<div className="mt-1 text-slate-500">{item.type || "—"} · {item.software || "—"}</div>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Available Formats</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{(detail.availableFormats || []).length === 0 ? (
<p className="text-slate-500"></p>
) : (
detail.availableFormats.map((item) => (
<div key={`${item.dataFormat}-${item.fileFormat}-${item.fileURL}`} className="rounded-lg border p-3 dark:border-slate-800">
<div className="font-medium">{item.fileFormat || item.dataFormat || "Format"}</div>
<div className="mt-1 break-all text-slate-500">{item.fileURL || "—"}</div>
</div>
))
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
import { createCachedLoader } from "@/services/dropdownCache";
import { getAuthToken } from "@/utils/token"; import { getAuthToken } from "@/utils/token";
import { import {
NONE_SELECT_VALUE, NONE_SELECT_VALUE,
type CallRecord, type CallRecord,
type SelectOption, type SelectOption,
type VariantQuery,
type VariantRecord, type VariantRecord,
} from "./types"; } from "./types";
@@ -135,12 +137,18 @@ const genotypeToText = (value: unknown) => {
return null; return null;
}; };
const mapVariant = (variant: VariantRecord): VariantRecord => ({ export const mapVariant = (variant: VariantRecord): VariantRecord => ({
...variant, ...variant,
id: variant.variantDbId || variant.variantId || variant.id, 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_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, variant_set_name: variant.variant_set_name || variant.variantSetName || null,
reference_set_id: variant.reference_set_id || variant.referenceSetDbId || null, reference_set_id: variant.reference_set_id || variant.referenceSetDbId || null,
reference_set_name: variant.reference_set_name || variant.referenceSetName || null, reference_set_name: variant.reference_set_name || variant.referenceSetName || null,
@@ -188,49 +196,154 @@ const callBody = (payload: CallPayload) => ({
phaseSet: optionalText(payload.phase_set), phaseSet: optionalText(payload.phase_set),
}); });
export async function fetchVariantRows(): Promise<VariantRecord[]> { export const normalizeVariantFormData = (row: VariantRecord) => ({
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=1000"); 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<BrapiListResponse<ReferenceSetResponse>>("/brapi/v2/referencesets?page=0&pageSize=10");
return response.result.data;
});
const variantSetLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=10");
return response.result.data;
});
const callSetLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<CallSetResponse>>("/brapi/v2/callsets?page=0&pageSize=10");
return response.result.data;
});
const variantRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=10");
return response.result.data.map(mapVariant); 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<CallRecord[]> { function filterVariantRows(rows: VariantRecord[], query?: VariantQuery): VariantRecord[] {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls?page=0&pageSize=1000"); const nameFilter = String(query?.variant_name ?? "").trim().toLowerCase();
return response.result.data.map(mapCall); 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[]; referenceSets: SelectOption[];
variantSets: SelectOption[]; variantSets: SelectOption[];
callSets: SelectOption[]; callSets: SelectOption[];
variants: SelectOption[]; variants: SelectOption[];
}> { }> {
const [referenceSets, variantSets, callSets, variants] = await Promise.all([ const [referenceSets, variantSets, callSets, variants] = await Promise.all([
request<BrapiListResponse<ReferenceSetResponse>>("/brapi/v2/referencesets?page=0&pageSize=1000"), referenceSetLoader.load(force),
request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=1000"), variantSetLoader.load(force),
request<BrapiListResponse<CallSetResponse>>("/brapi/v2/callsets?page=0&pageSize=1000"), callSetLoader.load(force),
request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants?page=0&pageSize=1000"), variantRowsLoader.load(force),
]); ]);
return buildVariantOptions(referenceSets, variantSets, callSets, variants);
}
export async function fetchVariantRows(query?: VariantQuery): Promise<VariantRecord[]> {
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<VariantRecord> {
const [detail, options, callsResponse] = await Promise.all([
request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}`),
fetchVariantOptions(),
request<BrapiListResponse<CallRecord>>(`/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 { return {
referenceSets: referenceSets.result.data.map((item) => ({ ...mapped,
value: item.referenceSetDbId, reference_set_name: mapped.reference_set_name || referenceSet?.label || null,
label: item.referenceSetName || item.referenceSetDbId, variant_set_name: mapped.variant_set_name || variantSet?.label || null,
})), allele_call_count: callsResponse.result.data.length,
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}` : ""}`,
})),
}; };
} }
export async function fetchCallRows(): Promise<CallRecord[]> {
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls?page=0&pageSize=10");
return response.result.data.map(mapCall);
}
export async function createVariantRow(payload: VariantPayload): Promise<VariantRecord> { export async function createVariantRow(payload: VariantPayload): Promise<VariantRecord> {
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", { const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", {
method: "POST", method: "POST",
@@ -239,6 +352,7 @@ export async function createVariantRow(payload: VariantPayload): Promise<Variant
...variantBody(payload), ...variantBody(payload),
}), }),
}); });
invalidateVariantPageCache();
return mapVariant(response.result.data[0]); return mapVariant(response.result.data[0]);
} }
@@ -249,6 +363,7 @@ export async function updateVariantRow(id: string, payload: VariantPayload): Pro
method: "PUT", method: "PUT",
body: JSON.stringify(variantBody(payload)), body: JSON.stringify(variantBody(payload)),
}); });
invalidateVariantPageCache();
return mapVariant(response.result); return mapVariant(response.result);
} }
@@ -256,6 +371,7 @@ export async function deleteVariantRow(id: string): Promise<void> {
await request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(id)}`, { await request<BrapiSingleResponse<VariantRecord>>(`/brapi/v2/variants/${encodeURIComponent(id)}`, {
method: "DELETE", method: "DELETE",
}); });
invalidateVariantPageCache();
} }
export async function createCallRow(payload: CallPayload): Promise<CallRecord> { export async function createCallRow(payload: CallPayload): Promise<CallRecord> {
@@ -284,3 +400,19 @@ export async function deleteCallRow(id: string): Promise<void> {
method: "DELETE", 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),
};
}

View File

@@ -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<SelectOption[]>([]);
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
const [draftQuery, setDraftQuery] = useState<VariantQuery>(() => ({
...emptyQuery(),
variant_set_id: toSelectValue(searchParams.get("variantSetDbId")),
}));
const [appliedQuery, setAppliedQuery] = useState<VariantQuery>(() => ({
...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<string, unknown>[];
}, [appliedQuery]);
const fetchRecord = useCallback(async (id: string) => {
const detail = await fetchVariantDetail(id);
return normalizeVariantFormData(detail);
}, []);
const fields = useMemo<BrapiFormField[]>(() => [
{ 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(() => (
<div className="col-span-2 rounded-lg border border-rose-100 bg-rose-50/60 p-3 text-xs text-rose-900 dark:border-rose-900/40 dark:bg-rose-950/30 dark:text-rose-100">
<p className="font-medium"></p>
<p className="mt-1 text-rose-800/80 dark:text-rose-200/80">
Variant genotype allele_call
</p>
</div>
), []);
const renderQueryForm = useCallback(() => (
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Input
value={draftQuery.variant_name ?? ""}
onChange={(event) => setDraftQuery((current) => ({ ...current, variant_name: event.target.value }))}
placeholder="variantName 模糊匹配"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500"></Label>
<Select
value={draftQuery.variant_type ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_type: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{variantTypeOptions.filter((item) => item.value !== NONE_SELECT_VALUE).map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">VariantSet</Label>
<Select
value={draftQuery.variant_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{variantSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-slate-500">ReferenceSet</Label>
<Select
value={draftQuery.reference_set_id ?? NONE_SELECT_VALUE}
onValueChange={(value) => setDraftQuery((current) => ({ ...current, reference_set_id: value }))}
>
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}></SelectItem>
{referenceSetOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" variant="outline" className="gap-2" onClick={() => {
const reset = emptyQuery();
setDraftQuery(reset);
setAppliedQuery(reset);
}}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button type="button" className="gap-2" onClick={() => setAppliedQuery({ ...draftQuery })}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
), [draftQuery, referenceSetOptions, variantSetOptions]);
return (
<BrapiEntityPage
useEnhancedDialog
icon={Sigma}
iconBg="bg-gradient-to-br from-rose-600 to-pink-700"
title="Variant 变异位点"
description="维护变异集合中的 SNP、INDEL、SV 等位点和坐标信息。"
addLabel="新增 Variant"
columns={[
{
key: "variant_name",
label: "变异名称",
render: (value, row) => {
const id = String(row.id ?? row.variantDbId ?? "");
const name = String(value ?? "—");
if (!id) return name;
return (
<Link
href={`/genotyping/variant/variants/${encodeURIComponent(id)}`}
className="font-medium text-rose-600 hover:underline dark:text-rose-400"
>
{name}
</Link>
);
},
},
{ 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<Record<string, unknown>>}
updateRecord={(id, payload) => updateVariantRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteVariantRow}
renderQueryForm={() => renderQueryForm()}
renderFormExtra={renderFormExtra}
/>
);
}

View File

@@ -1,106 +1,41 @@
/**
* filekorolheader: Variant / Call - 变异数据管理页面
* 功能Variant 变异位点维护、Call 基因型判读维护
* 路径:/genotyping/variant
* 规范:遵循开发项目规范.md使用 shadcn 语义化样式和 BrAPI 数据接口
*/
"use client"; "use client";
import { useCallback, useMemo, useState } from "react"; import { Suspense, useCallback, useMemo, useState } from "react";
import { Binary, Sigma } from "lucide-react"; import { Binary, Sigma } from "lucide-react";
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage"; import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { VariantTab } from "./components/VariantTab";
import { import {
createCallRow, createCallRow,
createVariantRow,
deleteCallRow, deleteCallRow,
deleteVariantRow,
fetchCallRows, fetchCallRows,
fetchVariantOptions, fetchVariantOptions,
fetchVariantRows,
updateCallRow, updateCallRow,
updateVariantRow,
} from "./api"; } from "./api";
import { NONE_SELECT_VALUE, type SelectOption } from "./types"; import { NONE_SELECT_VALUE, type SelectOption } from "./types";
const variantTypeOptions: SelectOption[] = [ function VariantTabFallback() {
{ value: NONE_SELECT_VALUE, label: "不指定类型" }, return <Skeleton className="h-96 w-full rounded-xl" />;
{ 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";
};
export default function VariantPage() { export default function VariantPage() {
const [referenceSetOptions, setReferenceSetOptions] = useState<SelectOption[]>([]); const [tab, setTab] = useState("variants");
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]); const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]); const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
const loadOptions = useCallback(async () => { const loadOptions = useCallback(async () => {
const options = await fetchVariantOptions(); const options = await fetchVariantOptions();
setReferenceSetOptions(options.referenceSets);
setVariantSetOptions(options.variantSets);
setCallSetOptions(options.callSets); setCallSetOptions(options.callSets);
setVariantOptions(options.variants); setVariantOptions(options.variants);
return options;
}, []); }, []);
const loadVariants = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchVariantRows()]);
return rows as unknown as Record<string, unknown>[];
}, [loadOptions]);
const loadCalls = useCallback(async () => { const loadCalls = useCallback(async () => {
const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]); const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]);
return rows as unknown as Record<string, unknown>[]; return rows as unknown as Record<string, unknown>[];
}, [loadOptions]); }, [loadOptions]);
const variantFields = useMemo<BrapiFormField[]>(() => [
{ 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<BrapiFormField[]>(() => [ const callFields = useMemo<BrapiFormField[]>(() => [
{ key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" }, { key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" },
{ {
@@ -124,67 +59,48 @@ export default function VariantPage() {
], [callSetOptions, variantOptions]); ], [callSetOptions, variantOptions]);
return ( return (
<Tabs defaultValue="variants" className="flex min-h-full flex-col gap-4"> <Tabs value={tab} onValueChange={setTab} className="flex min-h-full flex-col gap-4">
<TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit"> <TabsList className="w-full justify-start overflow-x-auto rounded-lg border bg-white p-1 dark:border-slate-800 dark:bg-slate-950 sm:w-fit">
<TabsTrigger value="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger> <TabsTrigger value="variants" className="gap-2"><Sigma className="h-4 w-4" />Variants</TabsTrigger>
<TabsTrigger value="calls" className="gap-2"><Binary className="h-4 w-4" />Calls</TabsTrigger> <TabsTrigger value="calls" className="gap-2"><Binary className="h-4 w-4" />Calls</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="variants" className="mt-0 min-h-0 flex-1"> {tab === "variants" ? (
<BrapiEntityPage <TabsContent value="variants" className="mt-0 min-h-0 flex-1">
useEnhancedDialog <Suspense fallback={<VariantTabFallback />}>
icon={Sigma} <VariantTab />
iconBg="bg-gradient-to-br from-rose-600 to-pink-700" </Suspense>
title="Variant 变异位点" </TabsContent>
description="维护变异集合中的 SNP、INDEL、SV 等位点和坐标信息。" ) : null}
addLabel="新增 Variant"
columns={[
{ key: "variantDbId", label: "Variant ID" },
{ key: "variant_name", label: "变异名称" },
{ 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={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<Record<string, unknown>>}
updateRecord={(id, payload) => updateVariantRow(id, payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteVariantRow}
/>
</TabsContent>
<TabsContent value="calls" className="mt-0 min-h-0 flex-1"> {tab === "calls" ? (
<BrapiEntityPage <TabsContent value="calls" className="mt-0 min-h-0 flex-1">
useEnhancedDialog <BrapiEntityPage
icon={Binary} useEnhancedDialog
iconBg="bg-gradient-to-br from-sky-500 to-cyan-600" icon={Binary}
title="Call 基因型判读" iconBg="bg-gradient-to-br from-sky-500 to-cyan-600"
description="维护样品 CallSet 在指定 Variant 上的 genotype、深度和相位信息。" title="Call 基因型判读"
addLabel="新增 Call" description="维护样品 CallSet 在指定 Variant 上的 genotype、深度和相位信息。"
columns={[ addLabel="新增 Call"
{ key: "callDbId", label: "Call ID" }, columns={[
{ key: "call_set_name", label: "CallSet" }, { key: "callDbId", label: "Call ID" },
{ key: "variant_name", label: "Variant" }, { key: "call_set_name", label: "CallSet" },
{ key: "genotype_text", label: "Genotype" }, { key: "variant_name", label: "Variant" },
{ key: "genotype_likelihood", label: "似然值" }, { key: "genotype_text", label: "Genotype" },
{ key: "read_depth", label: "深度" }, { key: "genotype_likelihood", label: "似然值" },
{ key: "phase_set", label: "Phase Set" }, { key: "read_depth", label: "深度" },
]} { key: "phase_set", label: "Phase Set" },
fields={callFields} ]}
data={[]} fields={callFields}
stats={[{ label: "/brapi/v2/calls", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]} data={[]}
loadData={loadCalls} stats={[{ label: "/brapi/v2/calls", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]}
createRecord={(payload) => createCallRow(payload) as unknown as Promise<Record<string, unknown>>} loadData={loadCalls}
updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise<Record<string, unknown>>} createRecord={(payload) => createCallRow(payload) as unknown as Promise<Record<string, unknown>>}
deleteRecord={deleteCallRow} updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise<Record<string, unknown>>}
/> deleteRecord={deleteCallRow}
</TabsContent> />
</TabsContent>
) : null}
</Tabs> </Tabs>
); );
} }

View File

@@ -5,6 +5,13 @@ export interface SelectOption {
label: string; label: string;
} }
export interface VariantQuery {
variant_name?: string;
variant_type?: string;
variant_set_id?: string;
reference_set_id?: string;
}
export interface ReferenceRecord { export interface ReferenceRecord {
id: string; id: string;
referenceId: string; referenceId: string;
@@ -27,9 +34,10 @@ export interface VariantRecord {
variantDbId: string; variantDbId: string;
variantName: string | null; variantName: string | null;
variant_name: string | null; variant_name: string | null;
variantNames?: string[] | null;
variantType: string | null; variantType: string | null;
variant_type: string | null; variant_type: string | null;
variantSetDbId: string | null; variantSetDbId: string | string[] | null;
variant_set_id: string | null; variant_set_id: string | null;
variantSetName: string | null; variantSetName: string | null;
variant_set_name: string | null; variant_set_name: string | null;
@@ -50,6 +58,7 @@ export interface VariantRecord {
filters_applied: boolean | null; filters_applied: boolean | null;
filtersPassed: boolean | null; filtersPassed: boolean | null;
filters_passed: boolean | null; filters_passed: boolean | null;
allele_call_count?: number | null;
} }
export interface CallGenotype { export interface CallGenotype {

View File

@@ -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<string | null>(null);
const [detail, setDetail] = useState<Awaited<ReturnType<typeof fetchVariantDetail>> | 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 (
<div className="space-y-4 p-1">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-36 w-full" />
</div>
);
}
if (error || !detail) {
return (
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
{error || "Variant 不存在"}
<div className="mt-4">
<Button asChild variant="outline">
<Link href="/genotyping/variant"><ArrowLeft className="mr-2 h-4 w-4" /></Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex min-h-full flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/genotyping/variant"><ArrowLeft className="mr-2 h-4 w-4" /> Variant </Link>
</Button>
{(detail.allele_call_count ?? 0) > 0 ? (
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
{detail.allele_call_count} allele_call
</Badge>
) : null}
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Sigma className="h-5 w-5 text-rose-500" />
{detail.variant_name || detail.id}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-slate-500">Variant ID</span>{detail.id}</div>
<div><span className="text-slate-500"></span>{detail.variant_type || "N/A"}</div>
<div><span className="text-slate-500">VariantSet</span>{detail.variant_set_name || "N/A"}</div>
<div><span className="text-slate-500">ReferenceSet</span>{detail.reference_set_name || "N/A"}</div>
<div><span className="text-slate-500"></span>{detail.start ?? "N/A"}</div>
<div><span className="text-slate-500"></span>{detail.end ?? "N/A"}</div>
<div><span className="text-slate-500"></span>{detail.reference_bases || "N/A"}</div>
<div><span className="text-slate-500">SV </span>{detail.svlen ?? "N/A"}</div>
<div><span className="text-slate-500"></span>{boolLabel(detail.filters_applied)}</div>
<div><span className="text-slate-500"></span>{boolLabel(detail.filters_passed)}</div>
<div><span className="text-slate-500">allele_call </span>{detail.allele_call_count ?? 0}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="h-4 w-4 text-emerald-500" />
Marker Position
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-slate-600 dark:text-slate-300">
<p>
Marker Position linkage group Marker / GenomeMap
Variant allele_call 便
</p>
{detail.variant_set_id ? (
<Button asChild variant="link" className="mt-2 h-auto px-0 text-violet-600 dark:text-violet-400">
<Link href={`/genotyping/variant-set/variant-sets/${encodeURIComponent(detail.variant_set_id)}`}>
VariantSet
</Link>
</Button>
) : null}
</CardContent>
</Card>
</div>
);
}

View File

@@ -71,14 +71,14 @@ export const mapBreedingMethod = (method: BreedingMethod): BreedingMethodRecord
}; };
async function fetchBreedingMethodList(): Promise<BreedingMethod[]> { async function fetchBreedingMethodList(): Promise<BreedingMethod[]> {
const response = await request<BrapiListResponse<BreedingMethod>>("/brapi/v2/breedingmethods?page=0&pageSize=1000"); const response = await request<BrapiListResponse<BreedingMethod>>("/brapi/v2/breedingmethods?page=0&pageSize=10");
return response.result?.data ?? []; return response.result?.data ?? [];
} }
const toRequestBody = (payload: Record<string, unknown>) => { const toRequestBody = (payload: Record<string, unknown>) => {
const breedingMethodName = emptyToNull(payload.breedingMethodName ?? payload.name); const breedingMethodName = emptyToNull(payload.breedingMethodName ?? payload.name);
if (!breedingMethodName) { if (!breedingMethodName) {
throw new Error("请填写方法名称"); throw new Error("???????");
} }
return { return {
breedingMethodName, breedingMethodName,

View File

@@ -105,7 +105,7 @@ const buildCrossParent = (
const observationUnitDbId = optionalText(observationUnitId); const observationUnitDbId = optionalText(observationUnitId);
if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null; if (!parentTypeValue && !germplasmDbId && !observationUnitDbId) return null;
if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type"); if (!parentTypeValue) throw new Error("请为已填亲本选择 parent_type");
if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm observation_unit 至少一"); if (!germplasmDbId && !observationUnitDbId) throw new Error("亲本必须填写 germplasm <EFBFBD>?observation_unit 至少一");
return { return {
parentType: parentTypeValue as CrossParent["parentType"], parentType: parentTypeValue as CrossParent["parentType"],
...(germplasmDbId ? { germplasmDbId } : {}), ...(germplasmDbId ? { germplasmDbId } : {}),
@@ -181,23 +181,23 @@ export function buildCrossParentFormState(
} }
const crossingProjectBody = (payload: CrossingProjectPayload) => { const crossingProjectBody = (payload: CrossingProjectPayload) => {
const programDbId = requiredText(payload.program_id, "请选择所Program"); const programDbId = requiredText(payload.program_id, "请选择所<EFBFBD>?Program");
return { return {
crossingProjectName: requiredText(payload.name, "请填写杂交项目名"), crossingProjectName: requiredText(payload.name, "请填写杂交项目名"),
crossingProjectDescription: optionalText(payload.description), crossingProjectDescription: optionalText(payload.description),
programDbId, programDbId,
}; };
}; };
const plannedCrossBody = (payload: PlannedCrossPayload) => ({ const plannedCrossBody = (payload: PlannedCrossPayload) => ({
plannedCrossName: requiredText(payload.name, "请填写计划杂交名"), plannedCrossName: requiredText(payload.name, "请填写计划杂交名"),
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"), crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}), ...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }), ...(optionalText(payload.status) ? { status: optionalText(payload.status) } : { status: "TODO" }),
}); });
const crossBody = (payload: CrossPayload) => ({ const crossBody = (payload: CrossPayload) => ({
crossName: requiredText(payload.name, "请填写实际杂交名"), crossName: requiredText(payload.name, "请填写实际杂交名"),
crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"), crossingProjectDbId: requiredText(payload.crossing_project_id, "请选择杂交项目"),
...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}), ...(optionalText(payload.cross_type) ? { crossType: optionalText(payload.cross_type) } : {}),
...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}), ...(optionalText(payload.planned_cross_id) ? { plannedCrossDbId: optionalText(payload.planned_cross_id) } : {}),
@@ -430,7 +430,7 @@ export async function fetchCrossParentRows(): Promise<CrossParentRow[]> {
} }
export async function updateCrossParents(payload: CrossParentFormState): Promise<void> { export async function updateCrossParents(payload: CrossParentFormState): Promise<void> {
const crossId = requiredText(payload.cross_id, "请选择所Cross"); const crossId = requiredText(payload.cross_id, "请选择所<EFBFBD>?Cross");
const parent1 = buildCrossParent( const parent1 = buildCrossParent(
payload.parent1_type, payload.parent1_type,
payload.parent1_germplasm_id, payload.parent1_germplasm_id,
@@ -466,14 +466,14 @@ export async function updateCrossParents(payload: CrossParentFormState): Promise
export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> { export async function fetchPedigreeRows(): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>( const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/brapi/v2/pedigree?page=0&pageSize=1000", "/brapi/v2/pedigree?page=0&pageSize=10",
); );
return response.result.data.map(mapPedigree); return response.result.data.map(mapPedigree);
} }
export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> { export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]> {
const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>( const response = await request<BrapiListResponse<PedigreeRecord & PedigreeNode>>(
"/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); return response.result.data.map(mapPedigree);
} }
@@ -481,7 +481,7 @@ export async function fetchPedigreeRowsWithRelations(): Promise<PedigreeRecord[]
export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> { export async function fetchPedigreeDetail(id: string): Promise<PedigreeRecord> {
const rows = await fetchPedigreeRows(); const rows = await fetchPedigreeRows();
const found = rows.find((row) => row.id === id || row.germplasm_id === id); const found = rows.find((row) => row.id === id || row.germplasm_id === id);
if (!found) throw new Error("系谱节点不存"); if (!found) throw new Error("系谱节点不存");
return found; return found;
} }
@@ -526,7 +526,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料"); const thisNodeId = requiredText(payload.this_node_id, "请选择当前材料");
const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料"); const connectedNodeId = requiredText(payload.connected_node_id, "请选择关联材料");
if (thisNodeId === connectedNodeId) { if (thisNodeId === connectedNodeId) {
throw new Error("当前材料与关联材料不能相"); throw new Error("当前材料与关联材料不能相");
} }
if (edgeType === "sibling") { if (edgeType === "sibling") {
throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护"); throw new Error("同胞关系由共享亲本自动推断,请通过 parent 关系维护");
@@ -560,7 +560,7 @@ export async function upsertPedigreeEdge(payload: PedigreeEdgeFormState, origina
export async function removePedigreeEdge(edgeId: string): Promise<void> { export async function removePedigreeEdge(edgeId: string): Promise<void> {
const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":"); const [edgeType, thisNodeId, connectedNodeId] = edgeId.split(":");
if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) { if (edgeType !== "parent" || !thisNodeId || !connectedNodeId) {
throw new Error("仅支持删parent 关系"); throw new Error("仅支持删<EFBFBD>?parent 关系");
} }
const nodes = await fetchPedigreeRowsWithRelations(); const nodes = await fetchPedigreeRowsWithRelations();

View File

@@ -118,9 +118,9 @@ async function fetchSnapshotFromNetwork(): Promise<CrossPedigreeSnapshot> {
const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([ const [programs, germplasm, crossingProjects, plannedCrosses, actualCrosses, observationUnits] = await Promise.all([
loadProgramOptions(), loadProgramOptions(),
loadGermplasmOptions(), loadGermplasmOptions(),
request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects?page=0&pageSize=1000"), request<BrapiListResponse<CrossingProject>>("/brapi/v2/crossingprojects?page=0&pageSize=10"),
request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses?page=0&pageSize=1000"), request<BrapiListResponse<PlannedCross>>("/brapi/v2/plannedcrosses?page=0&pageSize=10"),
request<BrapiListResponse<Cross>>("/brapi/v2/crosses?page=0&pageSize=1000"), request<BrapiListResponse<Cross>>("/brapi/v2/crosses?page=0&pageSize=10"),
loadObservationUnitOptions().catch(() => [] as SelectOption[]), loadObservationUnitOptions().catch(() => [] as SelectOption[]),
]); ]);

View File

@@ -99,7 +99,7 @@ const optionalNumber = (value: unknown) => {
const germplasmName = (payload: GermplasmPayload) => { const germplasmName = (payload: GermplasmPayload) => {
const name = optionalText(payload.germplasm_name); const name = optionalText(payload.germplasm_name);
if (!name) throw new Error("请填写种质名"); if (!name) throw new Error("请填写种质名");
return name; return name;
}; };
@@ -154,7 +154,7 @@ const toRequestBody = (payload: GermplasmPayload) => ({
}); });
export async function fetchGermplasmRows(): Promise<GermplasmRecord[]> { export async function fetchGermplasmRows(): Promise<GermplasmRecord[]> {
const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm?page=0&pageSize=1000"); const response = await request<BrapiListResponse<GermplasmRecord>>("/brapi/v2/germplasm?page=0&pageSize=10");
return response.result.data.map(mapGermplasm); return response.result.data.map(mapGermplasm);
} }

View File

@@ -105,7 +105,7 @@ export function normalizeAttributeFormData(record: AttributeRecord): Record<stri
const toRequestBody = (payload: AttributePayload) => { const toRequestBody = (payload: AttributePayload) => {
const attributeName = optionalText(payload.attributeName); const attributeName = optionalText(payload.attributeName);
if (!attributeName) throw new Error("请填写属性名"); if (!attributeName) throw new Error("请填写属性名");
const dataType = optionalText(payload.dataType) as AttributeDataType | null; const dataType = optionalText(payload.dataType) as AttributeDataType | null;
const scaleName = `${attributeName} scale`; const scaleName = `${attributeName} scale`;
@@ -128,7 +128,7 @@ const toRequestBody = (payload: AttributePayload) => {
}; };
export async function fetchAttributeRows(): Promise<AttributeRecord[]> { export async function fetchAttributeRows(): Promise<AttributeRecord[]> {
const response = await request<BrapiListResponse<GermplasmAttribute>>("/brapi/v2/attributes?page=0&pageSize=1000"); const response = await request<BrapiListResponse<GermplasmAttribute>>("/brapi/v2/attributes?page=0&pageSize=10");
return (response.result?.data ?? []).map(mapAttribute); return (response.result?.data ?? []).map(mapAttribute);
} }
@@ -150,7 +150,7 @@ export async function fetchAttributeOptions(): Promise<SelectOption[]> {
} }
export async function fetchAttributeFormOptions(): Promise<{ crops: SelectOption[] }> { export async function fetchAttributeFormOptions(): Promise<{ crops: SelectOption[] }> {
const cropsResult = await request<CommonCropNamesResponse>("/brapi/v2/commoncropnames?page=0&pageSize=1000").catch(() => null); const cropsResult = await request<CommonCropNamesResponse>("/brapi/v2/commoncropnames?page=0&pageSize=10").catch(() => null);
return { return {
crops: (cropsResult?.result?.data ?? []).map((cropName) => ({ crops: (cropsResult?.result?.data ?? []).map((cropName) => ({
value: cropName, value: cropName,

View File

@@ -103,9 +103,9 @@ const toRequestBody = (payload: AttributeValuePayload) => {
const germplasmDbId = optionalText(payload.germplasm_id); const germplasmDbId = optionalText(payload.germplasm_id);
const value = optionalText(payload.value); const value = optionalText(payload.value);
if (!attributeDbId) throw new Error("请选择属性定"); if (!attributeDbId) throw new Error("请选择属性定");
if (!germplasmDbId) throw new Error("请选择种质材料"); if (!germplasmDbId) throw new Error("请选择种质材料");
if (!value) throw new Error("请填写属性"); if (!value) throw new Error("请填写属性");
return { return {
attributeDbId, attributeDbId,
@@ -119,8 +119,8 @@ const toRequestBody = (payload: AttributeValuePayload) => {
export async function fetchAttributeValueRows(germplasmDbId?: string): Promise<AttributeValueRecord[]> { export async function fetchAttributeValueRows(germplasmDbId?: string): Promise<AttributeValueRecord[]> {
const query = germplasmDbId const query = germplasmDbId
? `?page=0&pageSize=1000&germplasmDbId=${encodeURIComponent(germplasmDbId)}` ? `?page=0&pageSize=10&germplasmDbId=${encodeURIComponent(germplasmDbId)}`
: "?page=0&pageSize=1000"; : "?page=0&pageSize=10";
const response = await request<BrapiListResponse<GermplasmAttributeValue>>(`/brapi/v2/attributevalues${query}`); const response = await request<BrapiListResponse<GermplasmAttributeValue>>(`/brapi/v2/attributevalues${query}`);
return (response.result?.data ?? []).map(mapAttributeValue); return (response.result?.data ?? []).map(mapAttributeValue);
} }

View File

@@ -109,7 +109,7 @@ const optionalNumber = (value: unknown) => {
const seedLotName = (payload: SeedLotPayload) => { const seedLotName = (payload: SeedLotPayload) => {
const name = optionalText(payload.name); const name = optionalText(payload.name);
if (!name) throw new Error("请填写批次名"); if (!name) throw new Error("请填写批次名");
return name; return name;
}; };
@@ -145,7 +145,7 @@ export const buildContentMixturePayload = (payload: SeedLotPayload) => {
} }
if (rows.length === 0) { if (rows.length === 0) {
throw new Error("请至少录入一条批次组成,或选择主材"); throw new Error("请至少录入一条批次组成,或选择主材");
} }
const normalized = rows.map((row) => { const normalized = rows.map((row) => {
@@ -154,10 +154,10 @@ export const buildContentMixturePayload = (payload: SeedLotPayload) => {
const mixturePercentage = optionalNumber(row.mixture_percentage); const mixturePercentage = optionalNumber(row.mixture_percentage);
if (!germplasmDbId && !crossDbId) { if (!germplasmDbId && !crossDbId) {
throw new Error("批次组成每行需选择材料或杂交来"); throw new Error("批次组成每行需选择材料或杂交来");
} }
if (mixturePercentage === null || mixturePercentage < 0 || mixturePercentage > 100) { if (mixturePercentage === null || mixturePercentage < 0 || mixturePercentage > 100) {
throw new Error("批次组成占比需在 0 到 100 之间"); throw new Error("批次组成占比需<EFBFBD>?0 <20>?100 之间");
} }
return { return {
@@ -268,7 +268,7 @@ export function normalizeSeedLotFormData(record: SeedLotRecord): Record<string,
} }
const seedLotRowsLoader = createCachedLoader(async () => { const seedLotRowsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots?page=0&pageSize=1000"); const response = await request<BrapiListResponse<SeedLotRecord>>("/brapi/v2/seedlots?page=0&pageSize=10");
return response.result.data.map(mapSeedLot); return response.result.data.map(mapSeedLot);
}); });
@@ -349,8 +349,8 @@ export async function fetchSeedLotTransactions(seedLotDbId?: string): Promise<Se
const [transactionsResponse, seedLots] = await Promise.all([ const [transactionsResponse, seedLots] = await Promise.all([
request<BrapiListResponse<SeedLotTransactionRecord>>( request<BrapiListResponse<SeedLotTransactionRecord>>(
seedLotDbId seedLotDbId
? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=1000` ? `/brapi/v2/seedlots/${encodeURIComponent(seedLotDbId)}/transactions?page=0&pageSize=10`
: "/brapi/v2/seedlots/transactions?page=0&pageSize=1000", : "/brapi/v2/seedlots/transactions?page=0&pageSize=10",
), ),
fetchSeedLotRows().catch(() => [] as SeedLotRecord[]), fetchSeedLotRows().catch(() => [] as SeedLotRecord[]),
]); ]);
@@ -365,7 +365,7 @@ export function inferTransactionAction(transaction: SeedLotTransactionRecord): T
if (!fromId && toId) return "in"; if (!fromId && toId) return "in";
if (fromId && !toId) { if (fromId && !toId) {
const description = String(transaction.description ?? "").toLowerCase(); const description = String(transaction.description ?? "").toLowerCase();
if (description.includes("报废") || description.includes("消")) return "consume"; if (description.includes("报废") || description.includes("消")) return "consume";
return "out"; return "out";
} }
if (fromId && toId) { if (fromId && toId) {
@@ -388,7 +388,7 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed
const toId = optionalText(payload.to_seed_lot_id); const toId = optionalText(payload.to_seed_lot_id);
if (fromId && toId && fromId === toId) { if (fromId && toId && fromId === toId) {
throw new Error("来源批次与目标批次不能相"); throw new Error("来源批次与目标批次不能相");
} }
let fromSeedLotDbId: string | undefined; let fromSeedLotDbId: string | undefined;
@@ -405,38 +405,38 @@ export async function createSeedLotTransaction(payload: TransactionPayload, seed
case "consume": case "consume":
if (!fromId) throw new Error("出库/消耗需选择来源批次"); if (!fromId) throw new Error("出库/消耗需选择来源批次");
if ((payload.action === "out" || payload.action === "consume") && !description) { if ((payload.action === "out" || payload.action === "consume") && !description) {
throw new Error("出库/消耗/报废建议填写流转说明"); throw new Error("出库/消<EFBFBD>?报废建议填写流转说明");
} }
fromSeedLotDbId = fromId; fromSeedLotDbId = fromId;
units = units || seedLotMap.get(fromId)?.units || null; units = units || seedLotMap.get(fromId)?.units || null;
break; break;
case "transfer": case "transfer":
case "split": case "split":
if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批"); if (!fromId || !toId) throw new Error("转移/分装需同时选择来源与目标批");
fromSeedLotDbId = fromId; fromSeedLotDbId = fromId;
toSeedLotDbId = toId; toSeedLotDbId = toId;
units = units || seedLotMap.get(fromId)?.units || seedLotMap.get(toId)?.units || null; units = units || seedLotMap.get(fromId)?.units || seedLotMap.get(toId)?.units || null;
break; break;
default: default:
throw new Error("未知的库存动"); throw new Error("未知的库存动");
} }
if (!fromSeedLotDbId && !toSeedLotDbId) { if (!fromSeedLotDbId && !toSeedLotDbId) {
throw new Error("来源批次与目标批次至少填写一"); throw new Error("来源批次与目标批次至少填写一");
} }
const sourceLot = fromSeedLotDbId ? seedLotMap.get(fromSeedLotDbId) : undefined; const sourceLot = fromSeedLotDbId ? seedLotMap.get(fromSeedLotDbId) : undefined;
if (fromSeedLotDbId && sourceLot) { if (fromSeedLotDbId && sourceLot) {
const currentAmount = Number(sourceLot.amount ?? 0); const currentAmount = Number(sourceLot.amount ?? 0);
if (amount > currentAmount) { 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("消") const transactionDescription = payload.action === "consume" && description && !description.includes("消")
? `耗/报废:${description}` ? `<EFBFBD>?报废:${description}`
: payload.action === "split" && description && !description.includes("分装") : payload.action === "split" && description && !description.includes("分装")
? `分装:${description}` ? `分装:${description}`
: description; : description;

View File

@@ -183,12 +183,12 @@ const imageBody = (payload: ImagePayload) => ({
}); });
export async function fetchEventRows(): Promise<EventRecord[]> { export async function fetchEventRows(): Promise<EventRecord[]> {
const response = await request<BrapiListResponse<EventRecord>>("/brapi/v2/events?page=0&pageSize=1000"); const response = await request<BrapiListResponse<EventRecord>>("/brapi/v2/events?page=0&pageSize=10");
return response.result.data.map(mapEvent); return response.result.data.map(mapEvent);
} }
export async function fetchImageRows(): Promise<ImageRecord[]> { export async function fetchImageRows(): Promise<ImageRecord[]> {
const response = await request<BrapiListResponse<ImageRecord>>("/brapi/v2/images?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ImageRecord>>("/brapi/v2/images?page=0&pageSize=10");
return response.result.data.map(mapImage); return response.result.data.map(mapImage);
} }
@@ -198,9 +198,9 @@ export async function fetchEventImageOptions(): Promise<{
observations: SelectOption[]; observations: SelectOption[];
}> { }> {
const [studies, observationUnits, observations] = await Promise.all([ const [studies, observationUnits, observations] = await Promise.all([
request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=1000"), request<BrapiListResponse<StudyResponse>>("/brapi/v2/studies?page=0&pageSize=10"),
request<BrapiListResponse<ObservationUnitResponse>>("/brapi/v2/observationunits?page=0&pageSize=1000"), request<BrapiListResponse<ObservationUnitResponse>>("/brapi/v2/observationunits?page=0&pageSize=10"),
request<BrapiListResponse<ObservationResponse>>("/brapi/v2/observations?page=0&pageSize=1000"), request<BrapiListResponse<ObservationResponse>>("/brapi/v2/observations?page=0&pageSize=10"),
]); ]);
return { return {

View File

@@ -101,7 +101,7 @@ const mapObservationUnit = (unit: ObservationUnitRecord): ObservationUnitRecord
}; };
const toRequestBody = (payload: ObservationUnitPayload) => ({ const toRequestBody = (payload: ObservationUnitPayload) => ({
observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名"), observationUnitName: requiredText(payload.observation_unit_name, "请填写观测单元名"),
studyDbId: optionalText(payload.study_id), studyDbId: optionalText(payload.study_id),
germplasmDbId: optionalText(payload.germplasm_id), germplasmDbId: optionalText(payload.germplasm_id),
observationLevel: { observationLevel: {
@@ -113,7 +113,7 @@ const toRequestBody = (payload: ObservationUnitPayload) => ({
}); });
export async function fetchObservationUnitRows(): Promise<ObservationUnitRecord[]> { export async function fetchObservationUnitRows(): Promise<ObservationUnitRecord[]> {
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits?page=0&pageSize=10");
return response.result.data.map(mapObservationUnit); return response.result.data.map(mapObservationUnit);
} }
@@ -133,7 +133,7 @@ export async function createObservationUnitRow(payload: ObservationUnitPayload):
const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", { const response = await request<BrapiListResponse<ObservationUnitRecord>>("/brapi/v2/observationunits", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
observationUnitDbId: requiredText(payload.id, "请填写观测单ID"), observationUnitDbId: requiredText(payload.id, "请填写观测单<EFBFBD>?ID"),
...toRequestBody(payload), ...toRequestBody(payload),
}), }),
}); });

View File

@@ -136,7 +136,7 @@ const mapObservationVariable = (variable: ObservationVariableRecord): Observatio
}); });
const toRequestBody = (payload: ObservationVariablePayload) => ({ const toRequestBody = (payload: ObservationVariablePayload) => ({
observationVariableName: requiredText(payload.name, "请填写变量名"), observationVariableName: requiredText(payload.name, "请填写变量名"),
pui: optionalText(payload.pui), pui: optionalText(payload.pui),
defaultValue: optionalText(payload.default_value), defaultValue: optionalText(payload.default_value),
documentationurl: optionalText(payload.documentationurl), documentationurl: optionalText(payload.documentationurl),
@@ -154,7 +154,7 @@ const toRequestBody = (payload: ObservationVariablePayload) => ({
}); });
export async function fetchObservationVariableRows(): Promise<ObservationVariableRecord[]> { export async function fetchObservationVariableRows(): Promise<ObservationVariableRecord[]> {
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables?page=0&pageSize=10");
return response.result.data.map(mapObservationVariable); return response.result.data.map(mapObservationVariable);
} }
@@ -167,10 +167,10 @@ export async function fetchObservationVariableOptions(): Promise<{
}> { }> {
const [crops, ontologies, traits, methods, scales] = await Promise.all([ const [crops, ontologies, traits, methods, scales] = await Promise.all([
request<CropResponse[]>("/api/dictionaries/crops"), request<CropResponse[]>("/api/dictionaries/crops"),
request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=1000"), request<BrapiListResponse<OntologyResponse>>("/brapi/v2/ontologies?page=0&pageSize=10"),
request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=1000"), request<BrapiListResponse<TraitResponse>>("/brapi/v2/traits?page=0&pageSize=10"),
request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=1000"), request<BrapiListResponse<MethodResponse>>("/brapi/v2/methods?page=0&pageSize=10"),
request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=1000"), request<BrapiListResponse<ScaleResponse>>("/brapi/v2/scales?page=0&pageSize=10"),
]); ]);
return { return {
@@ -201,7 +201,7 @@ export async function createObservationVariableRow(payload: ObservationVariableP
const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables", { const response = await request<BrapiListResponse<ObservationVariableRecord>>("/brapi/v2/variables", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
id: requiredText(payload.id, "请填写变ID"), id: requiredText(payload.id, "请填写变<EFBFBD>?ID"),
...toRequestBody(payload), ...toRequestBody(payload),
}), }),
}); });

View File

@@ -77,7 +77,7 @@ const optionalNumber = (value: unknown) => {
const programName = (payload: ProgramPayload) => { const programName = (payload: ProgramPayload) => {
const name = optionalText(payload.name); const name = optionalText(payload.name);
if (!name) throw new Error("请填写项目名"); if (!name) throw new Error("请填写项目名");
return name; return name;
}; };
@@ -106,7 +106,7 @@ const toRequestBody = (payload: ProgramPayload) => ({
}); });
export async function fetchProgramRows(): Promise<ProgramRecord[]> { export async function fetchProgramRows(): Promise<ProgramRecord[]> {
const response = await request<BrapiListResponse<ProgramRecord>>("/brapi/v2/programs?page=0&pageSize=1000"); const response = await request<BrapiListResponse<ProgramRecord>>("/brapi/v2/programs?page=0&pageSize=10");
return response.result.data.map(mapProgram); return response.result.data.map(mapProgram);
} }

View File

@@ -137,7 +137,7 @@ const optionalBoolean = (value: unknown) => {
const studyName = (payload: StudyPayload) => { const studyName = (payload: StudyPayload) => {
const name = optionalText(payload.study_name); const name = optionalText(payload.study_name);
if (!name) throw new Error("请填写研究名"); if (!name) throw new Error("请填写研究名");
return name; return name;
}; };
@@ -241,7 +241,7 @@ const toRequestBody = (payload: StudyPayload & Record<string, unknown>) => {
}; };
export async function fetchStudyRows(): Promise<StudyRecord[]> { export async function fetchStudyRows(): Promise<StudyRecord[]> {
const response = await request<BrapiListResponse<Study>>("/brapi/v2/studies?page=0&pageSize=1000"); const response = await request<BrapiListResponse<Study>>("/brapi/v2/studies?page=0&pageSize=10");
return response.result.data.map(mapStudy); return response.result.data.map(mapStudy);
} }
@@ -251,7 +251,7 @@ export async function fetchStudyDetail(studyDbId: string): Promise<StudyRecord>
} }
const studyTrialOptionsLoader = createCachedLoader(async () => { const studyTrialOptionsLoader = createCachedLoader(async () => {
const response = await request<BrapiListResponse<TrialResponse>>("/brapi/v2/trials?page=0&pageSize=1000"); const response = await request<BrapiListResponse<TrialResponse>>("/brapi/v2/trials?page=0&pageSize=10");
return response.result.data.map((trial) => ({ return response.result.data.map((trial) => ({
value: trial.trialDbId, value: trial.trialDbId,
label: `${trial.trialName || trial.trial_name || trial.trialDbId}${trial.programName || trial.program_name ? ` / ${trial.programName || trial.program_name}` : ""}`, label: `${trial.trialName || trial.trial_name || trial.trialDbId}${trial.programName || trial.program_name ? ` / ${trial.programName || trial.program_name}` : ""}`,

View File

@@ -104,7 +104,7 @@ const optionalBoolean = (value: unknown) => {
const trialName = (payload: TrialPayload) => { const trialName = (payload: TrialPayload) => {
const name = optionalText(payload.trial_name); const name = optionalText(payload.trial_name);
if (!name) throw new Error("请填写试验名"); if (!name) throw new Error("请填写试验名");
return name; return name;
}; };
@@ -164,7 +164,7 @@ const toRequestBody = (payload: TrialPayload) => {
}; };
export async function fetchTrialRows(): Promise<TrialRecord[]> { export async function fetchTrialRows(): Promise<TrialRecord[]> {
const response = await request<BrapiListResponse<Trial>>("/brapi/v2/trials?page=0&pageSize=1000"); const response = await request<BrapiListResponse<Trial>>("/brapi/v2/trials?page=0&pageSize=10");
return response.result.data.map(mapTrial); return response.result.data.map(mapTrial);
} }

View File

@@ -252,6 +252,12 @@ export function BrapiEntityPage({
?? row.crossDbId ?? row.crossDbId
?? row.plateDbId ?? row.plateDbId
?? row.sampleDbId ?? row.sampleDbId
?? row.referenceSetDbId
?? row.referenceDbId
?? row.referenceBasesDbId
?? row.variantSetDbId
?? row.variantDbId
?? row.callDbId
?? "", ?? "",
), []); ), []);

View File

@@ -137,9 +137,15 @@ export const brapiNavSections: BrapiNavSection[] = [
{ title: "样品管理", items: [{ title: "Sample / Plate", href: "/genotyping/sample-plate", icon: TestTube }] }, { title: "样品管理", items: [{ title: "Sample / Plate", href: "/genotyping/sample-plate", icon: TestTube }] },
{ {
title: "参考基因组", 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 }] },
], ],
}, },
{ {

View File

@@ -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}`;
}

View File

@@ -84,7 +84,10 @@ export const mockBackendMenus: BackendMenuResponse[] = [
children: [ 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/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/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: [] },
] }
] ]
}, },
{ {

View File

@@ -3342,9 +3342,11 @@ export type TraitsTraitDbIdDeleteResponses = {
/** /**
* OK * OK
*/ */
200: unknown; 200: TraitSingleResponse;
}; };
export type TraitsTraitDbIdDeleteResponse = TraitsTraitDbIdDeleteResponses[keyof TraitsTraitDbIdDeleteResponses];
export type TraitsTraitDbIdGetData = { export type TraitsTraitDbIdGetData = {
body?: never; body?: never;
headers?: { headers?: {

View File

@@ -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 { export interface CropRecord {
id: string; id: string;
@@ -141,7 +142,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `请求失败${response.status}`); throw new Error(detail || `请求失败<EFBFBD><EFBFBD>?${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -160,7 +161,7 @@ export async function listCountries(keyword?: string): Promise<CountryRecord[]>
return request<CountryRecord[]>(`/api/dictionaries/countries?${params.toString()}`); return request<CountryRecord[]>(`/api/dictionaries/countries?${params.toString()}`);
} }
export async function listCrops(page = 0, pageSize = 10): Promise<CropRecord[]> { export async function listCrops(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): Promise<CropRecord[]> {
const encodedPage = encodeURIComponent(String(page)); const encodedPage = encodeURIComponent(String(page));
const encodedPageSize = encodeURIComponent(String(pageSize)); const encodedPageSize = encodeURIComponent(String(pageSize));
const response = await request<BrapiListResponse<string>>(`/brapi/v2/commoncropnames?page=${encodedPage}&pageSize=${encodedPageSize}`); const response = await request<BrapiListResponse<string>>(`/brapi/v2/commoncropnames?page=${encodedPage}&pageSize=${encodedPageSize}`);
@@ -278,7 +279,7 @@ export function deleteSeason(seasonDbId: string) {
}).then(() => undefined); }).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 encodedPage = encodeURIComponent(String(page));
const encodedPageSize = encodeURIComponent(String(pageSize)); const encodedPageSize = encodeURIComponent(String(pageSize));
return request<BrapiListResponse<Omit<LocationRecord, "id">>>( return request<BrapiListResponse<Omit<LocationRecord, "id">>>(
@@ -329,7 +330,7 @@ export function deleteLocation(locationDbId: string) {
}).then(() => undefined); }).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 encodedPage = encodeURIComponent(String(page));
const encodedPageSize = encodeURIComponent(String(pageSize)); const encodedPageSize = encodeURIComponent(String(pageSize));
return request<BrapiListResponse<Omit<OntologyRecord, "id">>>( return request<BrapiListResponse<Omit<OntologyRecord, "id">>>(
@@ -372,7 +373,7 @@ const toOntologyRequestBody = (payload: OntologyPayload) => ({
export function createOntology(payload: OntologyPayload) { export function createOntology(payload: OntologyPayload) {
const body = toOntologyRequestBody(payload); const body = toOntologyRequestBody(payload);
if (!body.ontologyName) { if (!body.ontologyName) {
return Promise.reject(new Error("请填写本体名")); return Promise.reject(new Error("请填写本体名<EFBFBD><EFBFBD>?"));
} }
return request<BrapiListResponse<Omit<OntologyRecord, "id">>>("/brapi/v2/ontologies", { return request<BrapiListResponse<Omit<OntologyRecord, "id">>>("/brapi/v2/ontologies", {
method: "POST", method: "POST",
@@ -380,7 +381,7 @@ export function createOntology(payload: OntologyPayload) {
}).then((response) => { }).then((response) => {
const ontology = response.result.data[0]; const ontology = response.result.data[0];
if (!ontology) { if (!ontology) {
throw new Error("新本体失败:后端未返回数据"); throw new Error("新<EFBFBD>?<3F>本体失败:后<EFBFBD>??<3F><>?返回数据");
} }
return { return {
...ontology, ...ontology,

View File

@@ -1,3 +1,4 @@
import { DEFAULT_PAGE_QUERY } from "@/constants/api";
import { getAuthToken } from "@/utils/token"; import { getAuthToken } from "@/utils/token";
export interface SelectOption { export interface SelectOption {
@@ -73,7 +74,7 @@ interface BreedingMethodResponse {
abbreviation: string | null; abbreviation: string | null;
} }
const PAGE_QUERY = "page=0&pageSize=1000"; const PAGE_QUERY = DEFAULT_PAGE_QUERY;
const apiBase = () => { const apiBase = () => {
if (typeof window !== "undefined") return ""; if (typeof window !== "undefined") return "";
@@ -93,7 +94,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
if (!response.ok) { if (!response.ok) {
const detail = await response.text(); const detail = await response.text();
throw new Error(detail || `请求失败:${response.status}`); throw new Error(detail || `?????${response.status}`);
} }
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
@@ -297,7 +298,7 @@ export function invalidateAllDropdownCache() {
commonCropNameLoader.invalidate(); commonCropNameLoader.invalidate();
} }
/** 并行加载多个下拉,共享 in-flight 去重 */ /** ??????????? in-flight ?? */
export async function loadDropdownBundle(keys: { export async function loadDropdownBundle(keys: {
locations?: boolean; locations?: boolean;
programs?: boolean; programs?: boolean;

View File

@@ -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<VariantSetsListResponse> 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<VariantSetResponse> 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<VariantSetResponse> 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<VariantsListResponse> 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<VariantSingleResponse> 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<VariantSingleResponse> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<String> alternateBases;
private List<Integer> ciend;
private List<Integer> cipos;
private List<String> 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<String> getAlternateBases() {
return alternateBases;
}
public void setAlternateBases(List<String> alternateBases) {
this.alternateBases = alternateBases;
}
public List<Integer> getCiend() {
return ciend;
}
public void setCiend(List<Integer> ciend) {
this.ciend = ciend;
}
public List<Integer> getCipos() {
return cipos;
}
public void setCipos(List<Integer> cipos) {
this.cipos = cipos;
}
public List<String> getFiltersFailed() {
return filtersFailed;
}
public void setFiltersFailed(List<String> filtersFailed) {
this.filtersFailed = filtersFailed;
}
}

View File

@@ -1,7 +1,19 @@
package org.brapi.test.BrAPITestServer.repository.geno; 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.model.entity.geno.CallSetEntity;
import org.brapi.test.BrAPITestServer.repository.BrAPIRepository; 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<CallSetEntity, String> {
@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<Object[]> countCallSetsGroupedByVariantSetId(@Param("variantSetDbIds") Collection<String> variantSetDbIds);
public interface CallSetRepository extends BrAPIRepository<CallSetEntity, String>{
} }

View File

@@ -1,8 +1,18 @@
package org.brapi.test.BrAPITestServer.repository.geno; 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.model.entity.geno.VariantEntity;
import org.brapi.test.BrAPITestServer.repository.BrAPIRepository; 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<VariantEntity, String> { public interface VariantRepository extends BrAPIRepository<VariantEntity, String> {
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<Object[]> countVariantsGroupedByVariantSetId(@Param("variantSetDbIds") Collection<String> variantSetDbIds);
} }

View File

@@ -1,18 +1,29 @@
package org.brapi.test.BrAPITestServer.service.geno; package org.brapi.test.BrAPITestServer.service.geno;
import java.util.Arrays; import java.util.Arrays;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; 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.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.VariantRepository;
import org.brapi.test.BrAPITestServer.repository.geno.VariantSetRepository;
import org.brapi.test.BrAPITestServer.service.DateUtility; import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -25,9 +36,19 @@ import io.swagger.model.geno.VariantsSearchRequest;
public class VariantService { public class VariantService {
private final VariantRepository variantRepository; 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.variantRepository = variantRepository;
this.callRepository = callRepository;
this.markerPositionRepository = markerPositionRepository;
this.variantSetRepository = variantSetRepository;
this.referenceSetService = referenceSetService;
} }
public List<Variant> findVariants(String variantDbId, String variantSetDbId, String referenceDbId, public List<Variant> findVariants(String variantDbId, String variantSetDbId, String referenceDbId,
@@ -61,7 +82,8 @@ public class VariantService {
"*callSet.id"); "*callSet.id");
} }
searchQuery = searchQuery.appendList(request.getVariantSetDbIds(), "variantSet.id") searchQuery = searchQuery.appendList(request.getVariantSetDbIds(), "variantSet.id")
.appendList(request.getVariantDbIds(), "id"); .appendList(request.getVariantDbIds(), "id")
.appendList(request.getReferenceSetDbIds(), "referenceSet.id");
Page<VariantEntity> page = variantRepository.findAllBySearch(searchQuery, pageReq); Page<VariantEntity> page = variantRepository.findAllBySearch(searchQuery, pageReq);
PagingUtility.calculateMetaData(metadata, page); PagingUtility.calculateMetaData(metadata, page);
@@ -99,14 +121,19 @@ public class VariantService {
variant.setFiltersFailed(entity.getFiltersFailed()); variant.setFiltersFailed(entity.getFiltersFailed());
variant.setFiltersPassed(entity.getFiltersPassed()); variant.setFiltersPassed(entity.getFiltersPassed());
variant.setReferenceBases(entity.getReferenceBases()); 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.setReferenceName(entity.getReferenceSet().getReferenceSetName());
}
variant.setStart(entity.getStart()); variant.setStart(entity.getStart());
variant.setSvlen(entity.getSvlen()); variant.setSvlen(entity.getSvlen());
variant.setUpdated(DateUtility.toOffsetDateTime(entity.getUpdated())); variant.setUpdated(DateUtility.toOffsetDateTime(entity.getUpdated()));
variant.setVariantDbId(entity.getId()); variant.setVariantDbId(entity.getId());
variant.setVariantNames(Arrays.asList(entity.getVariantName())); 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()); variant.setVariantType(entity.getVariantType());
return variant; return variant;
@@ -116,4 +143,108 @@ public class VariantService {
return variantRepository.save(entity); 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<CallEntity> callQuery = new SearchQueryBuilder<CallEntity>(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<MarkerPositionEntity> markerQuery = new SearchQueryBuilder<MarkerPositionEntity>(
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");
}
}
} }

View File

@@ -9,17 +9,24 @@ import java.util.stream.Collectors;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException; import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerDbIdNotFoundException;
import org.brapi.test.BrAPITestServer.exceptions.BrAPIServerException; 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.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.CallSetEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantEntity; 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.VariantSetAnalysisEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAvailableFormatEntity; import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetAvailableFormatEntity;
import org.brapi.test.BrAPITestServer.model.entity.geno.VariantSetEntity; 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.repository.geno.VariantSetRepository;
import org.brapi.test.BrAPITestServer.service.core.StudyService;
import org.brapi.test.BrAPITestServer.service.DateUtility; import org.brapi.test.BrAPITestServer.service.DateUtility;
import org.brapi.test.BrAPITestServer.service.PagingUtility; import org.brapi.test.BrAPITestServer.service.PagingUtility;
import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder; import org.brapi.test.BrAPITestServer.service.SearchQueryBuilder;
import org.brapi.test.BrAPITestServer.service.UpdateUtility; import org.brapi.test.BrAPITestServer.service.UpdateUtility;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@@ -41,16 +48,25 @@ import io.swagger.model.geno.VariantSetsExtractRequest;
public class VariantSetService { public class VariantSetService {
private final VariantSetRepository variantSetRepository; private final VariantSetRepository variantSetRepository;
private final VariantRepository variantRepository;
private final CallSetRepository callSetRepository;
private final VariantService variantService; private final VariantService variantService;
private final CallSetService callSetService; private final CallSetService callSetService;
private final CallService callService; private final CallService callService;
private final ReferenceSetService referenceSetService;
private final StudyService studyService;
public VariantSetService(VariantSetRepository variantSetRepository, VariantService variantService, public VariantSetService(VariantSetRepository variantSetRepository, VariantRepository variantRepository,
CallSetService callSetService, CallService callService) { CallSetRepository callSetRepository, VariantService variantService, CallSetService callSetService,
CallService callService, ReferenceSetService referenceSetService, StudyService studyService) {
this.variantSetRepository = variantSetRepository; this.variantSetRepository = variantSetRepository;
this.variantRepository = variantRepository;
this.callSetRepository = callSetRepository;
this.variantService = variantService; this.variantService = variantService;
this.callSetService = callSetService; this.callSetService = callSetService;
this.callService = callService; this.callService = callService;
this.referenceSetService = referenceSetService;
this.studyService = studyService;
} }
public List<VariantSet> findVariantSets(String variantSetDbId, String variantDbId, String callSetDbId, public List<VariantSet> findVariantSets(String variantSetDbId, String variantDbId, String callSetDbId,
@@ -79,9 +95,7 @@ public class VariantSetService {
} }
public List<VariantSet> findVariantSets(VariantSetsSearchRequest request, Metadata metadata) { public List<VariantSet> findVariantSets(VariantSetsSearchRequest request, Metadata metadata) {
List<VariantSet> variantSets = findVariantSetEntities(request, metadata).stream().map(this::convertFromEntity) return convertFromEntities(findVariantSetEntities(request, metadata));
.collect(Collectors.toList());
return variantSets;
} }
private List<VariantSetEntity> findVariantSetEntities(VariantSetsSearchRequest request, Metadata metadata) { private List<VariantSetEntity> findVariantSetEntities(VariantSetsSearchRequest request, Metadata metadata) {
@@ -95,7 +109,9 @@ public class VariantSetService {
searchQuery.join("variants", "variant").appendList(request.getVariantDbIds(), "*variant.id"); searchQuery.join("variants", "variant").appendList(request.getVariantDbIds(), "*variant.id");
} }
searchQuery.appendList(request.getStudyDbIds(), "study.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<VariantSetEntity> page = variantSetRepository.findAllBySearch(searchQuery, pageReq); Page<VariantSetEntity> page = variantSetRepository.findAllBySearch(searchQuery, pageReq);
PagingUtility.calculateMetaData(metadata, page); PagingUtility.calculateMetaData(metadata, page);
@@ -106,6 +122,74 @@ public class VariantSetService {
return convertFromEntity(getVariantSetEntity(variantSetDbId, HttpStatus.NOT_FOUND)); 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 { public VariantSetEntity getVariantSetEntity(String variantSetDbId) throws BrAPIServerException {
return getVariantSetEntity(variantSetDbId, HttpStatus.BAD_REQUEST); return getVariantSetEntity(variantSetDbId, HttpStatus.BAD_REQUEST);
} }
@@ -122,7 +206,34 @@ public class VariantSetService {
return variantSet; return variantSet;
} }
private List<VariantSet> convertFromEntities(List<VariantSetEntity> entities) {
if (entities.isEmpty()) {
return List.of();
}
List<String> variantSetDbIds = entities.stream().map(VariantSetEntity::getId).collect(Collectors.toList());
Map<String, Long> variantCounts = toCountMap(variantRepository.countVariantsGroupedByVariantSetId(variantSetDbIds));
Map<String, Long> 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<String, Long> toCountMap(List<Object[]> rows) {
Map<String, Long> counts = new HashMap<>();
for (Object[] row : rows) {
counts.put((String) row[0], ((Number) row[1]).longValue());
}
return counts;
}
private VariantSet convertFromEntity(VariantSetEntity entity) { 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(); VariantSet variantSet = new VariantSet();
UpdateUtility.convertFromEntity(entity, variantSet); UpdateUtility.convertFromEntity(entity, variantSet);
if (entity.getAnalysis() != null) if (entity.getAnalysis() != null)
@@ -131,14 +242,12 @@ public class VariantSetService {
if (entity.getAvailableFormats() != null) if (entity.getAvailableFormats() != null)
variantSet.setAvailableFormats( variantSet.setAvailableFormats(
entity.getAvailableFormats().stream().map(this::convertFromEntity).collect(Collectors.toList())); entity.getAvailableFormats().stream().map(this::convertFromEntity).collect(Collectors.toList()));
if (entity.getCallSets() != null) variantSet.setCallSetCount(toCountInteger(callSetCount));
variantSet.setCallSetCount(entity.getCallSets().size());
if (entity.getReferenceSet() != null) if (entity.getReferenceSet() != null)
variantSet.setReferenceSetDbId(entity.getReferenceSet().getId()); variantSet.setReferenceSetDbId(entity.getReferenceSet().getId());
if (entity.getStudy() != null) if (entity.getStudy() != null)
variantSet.setStudyDbId(entity.getStudy().getId()); variantSet.setStudyDbId(entity.getStudy().getId());
if (entity.getVariants() != null) variantSet.setVariantCount(toCountInteger(variantCount));
variantSet.setVariantCount(entity.getVariants().size());
variantSet.setVariantSetDbId(entity.getId()); variantSet.setVariantSetDbId(entity.getId());
variantSet.setVariantSetName(entity.getVariantSetName()); variantSet.setVariantSetName(entity.getVariantSetName());
@@ -163,6 +272,10 @@ public class VariantSetService {
return variantSet; return variantSet;
} }
private Integer toCountInteger(long count) {
return count > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) count;
}
private Analysis convertFromEntity(VariantSetAnalysisEntity entity) { private Analysis convertFromEntity(VariantSetAnalysisEntity entity) {
Analysis analysis = new Analysis(); Analysis analysis = new Analysis();
analysis.setAnalysisDbId(entity.getId()); analysis.setAnalysisDbId(entity.getId());