fix:sample/plate 之前的开发
This commit is contained in:
401
frontend/src/app/(app)/genotyping/reference-set/api.ts
Normal file
401
frontend/src/app/(app)/genotyping/reference-set/api.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { createCachedLoader, loadDropdownBundle } from "@/services/dropdownCache";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type ReferenceBasesRecord,
|
||||
type ReferenceRecord,
|
||||
type ReferenceSetRecord,
|
||||
type SelectOption,
|
||||
} 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 VariantSetResponse {
|
||||
variantSetDbId: string;
|
||||
variantSetName: string | null;
|
||||
referenceSetDbId: string | null;
|
||||
}
|
||||
|
||||
type ReferenceSetPayload = Partial<Record<
|
||||
| "id"
|
||||
| "reference_set_name"
|
||||
| "assembly_pui"
|
||||
| "description"
|
||||
| "is_derived"
|
||||
| "md5checksum"
|
||||
| "source_uri"
|
||||
| "species_ontology_term"
|
||||
| "species_ontology_termuri"
|
||||
| "source_germplasm_id",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type ReferencePayload = Partial<Record<
|
||||
"id" | "reference_name" | "reference_set_id" | "length" | "md5checksum" | "source_divergence",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type ReferenceBasesPayload = Partial<Record<
|
||||
"id" | "reference_id" | "page_number" | "bases",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
const URL_PATTERN = /^https?:\/\/.+/i;
|
||||
const BASES_PATTERN = /^[ACGTNacgtn*.-]*$/;
|
||||
|
||||
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 optionalNumber = (value: unknown) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
const optionalBoolean = (value: unknown) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
return ["true", "1", "yes"].includes(normalized.toLowerCase());
|
||||
};
|
||||
|
||||
const optionalUrl = (value: unknown, label: string) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
if (!URL_PATTERN.test(normalized)) {
|
||||
throw new Error(`${label} 必须是有效的 URL`);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const validateBases = (value: unknown) => {
|
||||
const normalized = optionalText(value);
|
||||
if (!normalized) return null;
|
||||
if (normalized.length > 2048) {
|
||||
throw new Error("碱基序列片段不能超过 2048 字符");
|
||||
}
|
||||
if (!BASES_PATTERN.test(normalized)) {
|
||||
throw new Error("碱基序列仅允许 A/C/G/T/N 及常见占位符");
|
||||
}
|
||||
return normalized.toUpperCase();
|
||||
};
|
||||
|
||||
const mapReferenceSet = (item: ReferenceSetRecord): ReferenceSetRecord => ({
|
||||
...item,
|
||||
id: item.referenceSetDbId || item.id,
|
||||
reference_set_name: item.reference_set_name || item.referenceSetName || null,
|
||||
assembly_pui: item.assembly_pui || item.assemblyPUI || null,
|
||||
is_derived: item.is_derived ?? item.isDerived ?? null,
|
||||
source_uri: item.source_uri || item.sourceURI || null,
|
||||
species_ontology_term:
|
||||
item.species_ontology_term
|
||||
|| item.speciesOntologyTerm
|
||||
|| (item as { species?: { term?: string } }).species?.term
|
||||
|| null,
|
||||
species_ontology_termuri:
|
||||
item.species_ontology_termuri
|
||||
|| item.speciesOntologyTermURI
|
||||
|| (item as { species?: { termURI?: string } }).species?.termURI
|
||||
|| null,
|
||||
source_germplasm_id:
|
||||
item.source_germplasm_id
|
||||
|| item.sourceGermplasmDbId
|
||||
|| (item as { sourceGermplasm?: Array<{ germplasmDbId?: string }> }).sourceGermplasm?.[0]?.germplasmDbId
|
||||
|| null,
|
||||
source_germplasm_name:
|
||||
item.source_germplasm_name
|
||||
|| item.sourceGermplasmName
|
||||
|| (item as { sourceGermplasm?: Array<{ germplasmName?: string }> }).sourceGermplasm?.[0]?.germplasmName
|
||||
|| null,
|
||||
});
|
||||
|
||||
const mapReference = (reference: ReferenceRecord): ReferenceRecord => ({
|
||||
...reference,
|
||||
id: reference.referenceDbId || reference.id,
|
||||
reference_name: reference.reference_name || reference.referenceName || null,
|
||||
reference_set_id: reference.reference_set_id || reference.referenceSetDbId || null,
|
||||
reference_set_name: reference.reference_set_name || reference.referenceSetName || null,
|
||||
source_divergence: reference.source_divergence ?? reference.sourceDivergence ?? null,
|
||||
});
|
||||
|
||||
const mapReferenceBases = (item: ReferenceBasesRecord): ReferenceBasesRecord => ({
|
||||
...item,
|
||||
id: item.referenceBasesDbId || item.id,
|
||||
reference_id: item.reference_id || item.referenceDbId || null,
|
||||
reference_name: item.reference_name || item.referenceName || null,
|
||||
page_number: item.page_number ?? item.pageNumber ?? null,
|
||||
});
|
||||
|
||||
const referenceSetBody = (payload: ReferenceSetPayload) => ({
|
||||
referenceSetName: requiredText(payload.reference_set_name, "ReferenceSet 名称不能为空"),
|
||||
assemblyPUI: optionalText(payload.assembly_pui),
|
||||
description: optionalText(payload.description),
|
||||
isDerived: optionalBoolean(payload.is_derived),
|
||||
md5checksum: optionalText(payload.md5checksum),
|
||||
sourceURI: optionalUrl(payload.source_uri, "来源 URI"),
|
||||
species: optionalText(payload.species_ontology_term) || optionalUrl(payload.species_ontology_termuri, "物种本体 URI")
|
||||
? {
|
||||
term: optionalText(payload.species_ontology_term),
|
||||
termURI: optionalUrl(payload.species_ontology_termuri, "物种本体 URI"),
|
||||
}
|
||||
: undefined,
|
||||
sourceGermplasmDbId: optionalText(payload.source_germplasm_id),
|
||||
});
|
||||
|
||||
const referenceBody = (payload: ReferencePayload) => ({
|
||||
referenceName: requiredText(payload.reference_name, "Reference 名称不能为空"),
|
||||
referenceSetDbId: requiredText(payload.reference_set_id, "ReferenceSet 不能为空"),
|
||||
length: optionalNumber(payload.length),
|
||||
md5checksum: optionalText(payload.md5checksum),
|
||||
sourceDivergence: optionalNumber(payload.source_divergence),
|
||||
});
|
||||
|
||||
const referenceBasesBody = (payload: ReferenceBasesPayload) => ({
|
||||
referenceDbId: requiredText(payload.reference_id, "Reference 不能为空"),
|
||||
pageNumber: optionalNumber(payload.page_number),
|
||||
bases: validateBases(payload.bases),
|
||||
});
|
||||
|
||||
const attachReferenceSetCounts = (
|
||||
rows: ReferenceSetRecord[],
|
||||
references: ReferenceRecord[],
|
||||
variantSets: VariantSetResponse[],
|
||||
) => rows.map((row) => ({
|
||||
...row,
|
||||
reference_count: references.filter((item) => item.reference_set_id === row.id).length,
|
||||
variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length,
|
||||
}));
|
||||
|
||||
const referenceSetRowsLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets?page=0&pageSize=1000");
|
||||
return response.result.data.map(mapReferenceSet);
|
||||
});
|
||||
|
||||
const referenceRowsLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references?page=0&pageSize=1000");
|
||||
return response.result.data.map(mapReference);
|
||||
});
|
||||
|
||||
const variantSetRowsLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<VariantSetResponse>>("/brapi/v2/variantsets?page=0&pageSize=1000");
|
||||
return response.result.data;
|
||||
});
|
||||
|
||||
const referenceBasesRowsLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases?page=0&pageSize=1000");
|
||||
return response.result.data.map(mapReferenceBases);
|
||||
});
|
||||
|
||||
export function invalidateReferenceSetPageCache() {
|
||||
referenceSetRowsLoader.invalidate();
|
||||
referenceRowsLoader.invalidate();
|
||||
variantSetRowsLoader.invalidate();
|
||||
referenceBasesRowsLoader.invalidate();
|
||||
}
|
||||
|
||||
export async function fetchReferenceSetRows(force = false): Promise<ReferenceSetRecord[]> {
|
||||
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([
|
||||
loadDropdownBundle({ germplasms: true }, force),
|
||||
referenceSetRowsLoader.load(force),
|
||||
referenceRowsLoader.load(force),
|
||||
]);
|
||||
|
||||
return {
|
||||
germplasm: sharedOptions.germplasms,
|
||||
referenceSets: referenceSets.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.reference_set_name || item.id,
|
||||
})),
|
||||
references: references.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.reference_name || item.id}${item.reference_set_name ? ` / ${item.reference_set_name}` : ""}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise<ReferenceSetRecord> {
|
||||
const response = await request<BrapiListResponse<ReferenceSetRecord>>("/brapi/v2/referencesets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
referenceSetDbId: requiredText(payload.id, "ReferenceSet ID 不能为空"),
|
||||
...referenceSetBody(payload),
|
||||
}),
|
||||
});
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReferenceSet(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateReferenceSetRow(id: string, payload: ReferenceSetPayload): Promise<ReferenceSetRecord> {
|
||||
const requestedId = optionalText(payload.id);
|
||||
if (requestedId && requestedId !== id) {
|
||||
throw new Error("ReferenceSet ID 不可修改,请新建记录");
|
||||
}
|
||||
const response = await request<BrapiSingleResponse<ReferenceSetRecord>>(
|
||||
`/brapi/v2/referencesets/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(referenceSetBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReferenceSet(response.result);
|
||||
}
|
||||
|
||||
export async function deleteReferenceSetRow(id: string): Promise<void> {
|
||||
await request<BrapiSingleResponse<ReferenceSetRecord>>(
|
||||
`/brapi/v2/referencesets/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
}
|
||||
|
||||
export async function createReferenceRow(payload: ReferencePayload): Promise<ReferenceRecord> {
|
||||
const response = await request<BrapiListResponse<ReferenceRecord>>("/brapi/v2/references", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
referenceDbId: requiredText(payload.id, "Reference ID 不能为空"),
|
||||
...referenceBody(payload),
|
||||
}),
|
||||
});
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReference(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateReferenceRow(id: string, payload: ReferencePayload): Promise<ReferenceRecord> {
|
||||
const requestedId = optionalText(payload.id);
|
||||
if (requestedId && requestedId !== id) {
|
||||
throw new Error("Reference ID 不可修改,请新建记录");
|
||||
}
|
||||
const response = await request<BrapiSingleResponse<ReferenceRecord>>(
|
||||
`/brapi/v2/references/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(referenceBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReference(response.result);
|
||||
}
|
||||
|
||||
export async function deleteReferenceRow(id: string): Promise<void> {
|
||||
await request<BrapiSingleResponse<ReferenceRecord>>(
|
||||
`/brapi/v2/references/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
}
|
||||
|
||||
export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> {
|
||||
const response = await request<BrapiListResponse<ReferenceBasesRecord>>("/brapi/v2/referencebases", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
referenceBasesDbId: requiredText(payload.id, "ReferenceBases ID 不能为空"),
|
||||
...referenceBasesBody(payload),
|
||||
}),
|
||||
});
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReferenceBases(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateReferenceBasesRow(id: string, payload: ReferenceBasesPayload): Promise<ReferenceBasesRecord> {
|
||||
const requestedId = optionalText(payload.id);
|
||||
if (requestedId && requestedId !== id) {
|
||||
throw new Error("ReferenceBases ID 不可修改,请新建记录");
|
||||
}
|
||||
const response = await request<BrapiSingleResponse<ReferenceBasesRecord>>(
|
||||
`/brapi/v2/referencebases/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(referenceBasesBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
return mapReferenceBases(response.result);
|
||||
}
|
||||
|
||||
export async function deleteReferenceBasesRow(id: string): Promise<void> {
|
||||
await request<BrapiSingleResponse<ReferenceBasesRecord>>(
|
||||
`/brapi/v2/referencebases/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
invalidateReferenceSetPageCache();
|
||||
}
|
||||
Reference in New Issue
Block a user