fix:java项目性能优化
This commit is contained in:
41
README.md
41
README.md
@@ -9,9 +9,44 @@ This server implements all BrAPI calls. It is backed by a custom database with d
|
|||||||
Use [/calls](https://test-server.brapi.org/brapi/v1/call) (V1) or [/serverinfo](https://test-server.brapi.org/brapi/v2/serverinfo) (V2) to check the available endpoints.
|
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).
|
||||||
|
|||||||
@@ -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):列表查询、新增、编辑、详情。
|
||||||
|
|
||||||
|
**下一版本**:单条删除、批量删除。
|
||||||
|
|||||||
@@ -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):列表查询、新增、编辑、详情。
|
||||||
|
|
||||||
|
**下一版本**:单条删除、批量删除、大批量文件导入。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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???????????????");
|
||||||
}
|
}
|
||||||
|
|||||||
294
frontend/src/app/(app)/genotyping/variant-set/api.ts
Normal file
294
frontend/src/app/(app)/genotyping/variant-set/api.ts
Normal 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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/app/(app)/genotyping/variant-set/page.tsx
Normal file
11
frontend/src/app/(app)/genotyping/variant-set/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/app/(app)/genotyping/variant-set/types.ts
Normal file
51
frontend/src/app/(app)/genotyping/variant-set/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
删除前请确认无 Variant、CallSet、Analysis 或 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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[]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}` : ""}`,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
?? "",
|
?? "",
|
||||||
), []);
|
), []);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }] },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
11
frontend/src/constants/api.ts
Normal file
11
frontend/src/constants/api.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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: [] },
|
||||||
|
] }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>{
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user