import { createCachedLoader, loadDropdownBundle } from "@/services/dropdownCache"; import { getAuthToken } from "@/utils/token"; import { NONE_SELECT_VALUE, type ReferenceBasesRecord, type ReferenceQuery, type ReferenceRecord, type ReferenceSetPageOptions, type ReferenceSetQuery, type ReferenceSetRecord, type SelectOption, } from "./types"; interface BrapiPagination { currentPage: number; pageSize: number; totalCount: number; totalPages: number; } interface BrapiListResponse { metadata: { pagination: BrapiPagination; status: Array>; datafiles: Array>; }; result: { data: T[]; }; } interface BrapiSingleResponse { metadata: { pagination: BrapiPagination; status: Array>; datafiles: Array>; }; result: T; } interface VariantSetResponse { variantSetDbId: string; variantSetName: string | null; referenceSetDbId: string | null; } interface VariantResponse { variantDbId: string; referenceSetDbId: string | null; } type ReferenceSetPayload = Partial>; type ReferencePayload = Partial>; type ReferenceBasesPayload = Partial>; 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(path: string, init?: RequestInit): Promise { const token = getAuthToken(); const response = await fetch(`${apiBase()}${path}`, { ...init, headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers || {}), }, }); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `请求失败:${response.status}`); } return response.json() as Promise; } const optionalText = (value: unknown) => { const normalized = String(value ?? "").trim(); if (!normalized || normalized === NONE_SELECT_VALUE) return null; return normalized; }; const requiredText = (value: unknown, message: string) => { const normalized = optionalText(value); if (!normalized) throw new Error(message); return normalized; }; const optionalNumber = (value: unknown) => { const normalized = optionalText(value); if (normalized === null) 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, required = false) => { const normalized = optionalText(value); if (!normalized) { if (required) throw new Error("碱基序列片段不能为空"); return null; } if (normalized.length > 2048) { throw new Error("碱基序列片段不能超过 2048 字符"); } if (!BASES_PATTERN.test(normalized)) { throw new Error("碱基序列仅允�?A/C/G/T/N 及常见占位符"); } return normalized.toUpperCase(); }; export 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, }); export 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, }); export 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, }); function buildSpecies(payload: ReferenceSetPayload) { const term = optionalText(payload.species_ontology_term); const termURI = optionalUrl(payload.species_ontology_termuri, "物种本体 URI"); if (!term && !termURI) return undefined; return { term, termURI }; } function buildReferenceSetWriteBody(payload: ReferenceSetPayload) { const body: Record = { referenceSetName: requiredText(payload.reference_set_name, "请填�?ReferenceSet 名称"), }; const optionalFields: Array<[string, unknown]> = [ ["assemblyPUI", optionalText(payload.assembly_pui)], ["description", optionalText(payload.description)], ["isDerived", optionalBoolean(payload.is_derived)], ["md5checksum", optionalText(payload.md5checksum)], ["sourceURI", optionalUrl(payload.source_uri, "来源 URI")], ["sourceGermplasmDbId", optionalText(payload.source_germplasm_id)], ]; optionalFields.forEach(([key, value]) => { if (value !== null && value !== undefined) body[key] = value; }); const species = buildSpecies(payload); if (species) body.species = species; return body; } function buildReferenceWriteBody(payload: ReferencePayload) { const body: Record = { referenceName: requiredText(payload.reference_name, "请填�?Reference 名称"), referenceSetDbId: requiredText(payload.reference_set_id, "请选择 ReferenceSet"), }; const length = optionalNumber(payload.length); const sourceDivergence = optionalNumber(payload.source_divergence); const md5checksum = optionalText(payload.md5checksum); if (length !== null) { if (length < 0) throw new Error("序列长度不能为负"); body.length = length; } if (sourceDivergence !== null) body.sourceDivergence = sourceDivergence; if (md5checksum) body.md5checksum = md5checksum; return body; } function buildReferenceBasesWriteBody(payload: ReferenceBasesPayload, creating: boolean) { const pageNumber = optionalNumber(payload.page_number); if (pageNumber === null) throw new Error("请填写分页序"); if (pageNumber < 0) throw new Error("分页序号不能为负"); return { referenceDbId: requiredText(payload.reference_id, "请选择 Reference"), pageNumber, bases: validateBases(payload.bases, creating), }; } function filterReferenceSets(rows: ReferenceSetRecord[], query?: ReferenceSetQuery) { const name = optionalText(query?.reference_set_name)?.toLowerCase(); const assembly = optionalText(query?.assembly_pui)?.toLowerCase(); return rows.filter((row) => { if (name && !String(row.reference_set_name ?? "").toLowerCase().includes(name)) return false; if (assembly && !String(row.assembly_pui ?? "").toLowerCase().includes(assembly)) return false; return true; }); } function filterReferences(rows: ReferenceRecord[], query?: ReferenceQuery) { const name = optionalText(query?.reference_name)?.toLowerCase(); const referenceSetId = optionalText(query?.reference_set_id); return rows.filter((row) => { if (referenceSetId && row.reference_set_id !== referenceSetId) return false; if (name && !String(row.reference_name ?? "").toLowerCase().includes(name)) return false; return true; }); } function attachReferenceSetCounts( rows: ReferenceSetRecord[], references: ReferenceRecord[], variantSets: VariantSetResponse[], variants: VariantResponse[], ) { return rows.map((row) => ({ ...row, reference_count: references.filter((item) => item.reference_set_id === row.id).length, variantset_count: variantSets.filter((item) => item.referenceSetDbId === row.id).length, variant_count: variants.filter((item) => item.referenceSetDbId === row.id).length, })); } function attachReferenceBasesStats(references: ReferenceRecord[], basesPages: ReferenceBasesRecord[]) { return references.map((reference) => { const pages = basesPages.filter((page) => page.reference_id === reference.id); const basesTotalLength = pages.reduce((total, page) => total + String(page.bases ?? "").length, 0); return { ...reference, bases_page_count: pages.length, bases_total_length: basesTotalLength, }; }); } function enrichReferenceSetNames( references: ReferenceRecord[], referenceSets: ReferenceSetRecord[], ): ReferenceRecord[] { const label = new Map(referenceSets.map((item) => [item.id, item.reference_set_name || item.id])); return references.map((reference) => ({ ...reference, reference_set_name: reference.reference_set_id ? label.get(reference.reference_set_id) ?? reference.reference_set_name : reference.reference_set_name, })); } function enrichGermplasmNames( rows: ReferenceSetRecord[], germplasm: SelectOption[], ): ReferenceSetRecord[] { const label = new Map(germplasm.map((item) => [item.value, item.label])); return rows.map((row) => ({ ...row, source_germplasm_name: row.source_germplasm_id ? label.get(row.source_germplasm_id) ?? row.source_germplasm_name ?? row.source_germplasm_id : row.source_germplasm_name, })); } const referenceSetRowsLoader = createCachedLoader(async () => { const response = await request>("/brapi/v2/referencesets?page=0&pageSize=10"); return response.result.data.map(mapReferenceSet); }); const referenceRowsLoader = createCachedLoader(async () => { const response = await request>("/brapi/v2/references?page=0&pageSize=10"); return response.result.data.map(mapReference); }); const variantSetRowsLoader = createCachedLoader(async () => { const response = await request>("/brapi/v2/variantsets?page=0&pageSize=10"); return response.result.data; }); const variantRowsLoader = createCachedLoader(async () => { const response = await request>("/brapi/v2/variants?page=0&pageSize=10"); return response.result.data; }); const referenceBasesRowsLoader = createCachedLoader(async () => { const response = await request>("/brapi/v2/referencebases?page=0&pageSize=10"); return response.result.data.map(mapReferenceBases); }); export function invalidateReferenceSetPageCache() { referenceSetRowsLoader.invalidate(); referenceRowsLoader.invalidate(); variantSetRowsLoader.invalidate(); variantRowsLoader.invalidate(); referenceBasesRowsLoader.invalidate(); } export async function fetchReferenceSetOptions(force = false): Promise { 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 loadReferenceSetPageData(params: { referenceSetQuery?: ReferenceSetQuery; referenceQuery?: ReferenceQuery; force?: boolean; } = {}) { const force = params.force ?? false; const [options, referenceSets, references, variantSets, variants, basesPages] = await Promise.all([ fetchReferenceSetOptions(force), referenceSetRowsLoader.load(force), referenceRowsLoader.load(force), variantSetRowsLoader.load(force), variantRowsLoader.load(force), referenceBasesRowsLoader.load(force), ]); const enrichedSets = enrichGermplasmNames( attachReferenceSetCounts(referenceSets, references, variantSets, variants), options.germplasm, ); const enrichedReferences = attachReferenceBasesStats( enrichReferenceSetNames(references, referenceSets), basesPages, ); return { options, referenceSets: filterReferenceSets(enrichedSets, params.referenceSetQuery), references: filterReferences(enrichedReferences, params.referenceQuery), basesPages, variantSets, variants, }; } export async function fetchReferenceSetRows(query?: ReferenceSetQuery, force = false): Promise { const { referenceSets } = await loadReferenceSetPageData({ referenceSetQuery: query, force }); return referenceSets; } export async function fetchReferenceRows(query?: ReferenceQuery, force = false): Promise { const { references } = await loadReferenceSetPageData({ referenceQuery: query, force }); return references; } export async function fetchReferenceSetDetail(id: string): Promise { const response = await request>( `/brapi/v2/referencesets/${encodeURIComponent(id)}`, ); const { options, references, variantSets, variants } = await loadReferenceSetPageData(); const [detail] = enrichGermplasmNames( attachReferenceSetCounts([mapReferenceSet(response.result)], references, variantSets, variants), options.germplasm, ); return detail; } export async function fetchReferenceDetail(id: string): Promise { const response = await request>( `/brapi/v2/references/${encodeURIComponent(id)}`, ); const { referenceSets, basesPages } = await loadReferenceSetPageData(); const [detail] = attachReferenceBasesStats( enrichReferenceSetNames([mapReference(response.result)], referenceSets), basesPages, ); return detail; } export async function fetchReferenceBasesRows(referenceDbId?: string, force = false): Promise { const pages = await referenceBasesRowsLoader.load(force); const filtered = referenceDbId ? pages.filter((page) => page.reference_id === referenceDbId) : pages; return filtered.sort((a, b) => Number(a.page_number ?? 0) - Number(b.page_number ?? 0)); } export function normalizeReferenceSetFormData(record: ReferenceSetRecord): Record { return { id: record.id, reference_set_name: record.reference_set_name ?? "", assembly_pui: record.assembly_pui ?? "", description: record.description ?? "", is_derived: record.is_derived === true ? "true" : record.is_derived === false ? "false" : NONE_SELECT_VALUE, md5checksum: record.md5checksum ?? "", source_uri: record.source_uri ?? "", species_ontology_term: record.species_ontology_term ?? "", species_ontology_termuri: record.species_ontology_termuri ?? "", source_germplasm_id: record.source_germplasm_id && record.source_germplasm_id !== NONE_SELECT_VALUE ? record.source_germplasm_id : NONE_SELECT_VALUE, }; } export function normalizeReferenceFormData(record: ReferenceRecord): Record { return { id: record.id, reference_name: record.reference_name ?? "", reference_set_id: record.reference_set_id ?? "", length: record.length ?? "", md5checksum: record.md5checksum ?? "", source_divergence: record.source_divergence ?? "", }; } export function normalizeReferenceBasesFormData(record: ReferenceBasesRecord): Record { return { id: record.id, reference_id: record.reference_id ?? "", page_number: record.page_number ?? "", bases: record.bases ?? "", }; } export async function createReferenceSetRow(payload: ReferenceSetPayload): Promise { const body = { ...buildReferenceSetWriteBody(payload), ...(optionalText(payload.id) ? { referenceSetDbId: optionalText(payload.id) } : {}), }; const response = await request>("/brapi/v2/referencesets", { method: "POST", body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReferenceSet(response.result.data[0]); } export async function updateReferenceSetRow(id: string, payload: ReferenceSetPayload): Promise { const requestedId = optionalText(payload.id); if (requestedId && requestedId !== id) { throw new Error("ReferenceSet ID 不可修改,请新建记录"); } const response = await request>( `/brapi/v2/referencesets/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(buildReferenceSetWriteBody(payload)), }, ); invalidateReferenceSetPageCache(); return mapReferenceSet(response.result); } export async function deleteReferenceSetRow(id: string): Promise { await request>( `/brapi/v2/referencesets/${encodeURIComponent(id)}`, { method: "DELETE" }, ); invalidateReferenceSetPageCache(); } export async function createReferenceRow(payload: ReferencePayload): Promise { const body = { ...buildReferenceWriteBody(payload), ...(optionalText(payload.id) ? { referenceDbId: optionalText(payload.id) } : {}), }; const response = await request>("/brapi/v2/references", { method: "POST", body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReference(response.result.data[0]); } export async function updateReferenceRow(id: string, payload: ReferencePayload): Promise { const requestedId = optionalText(payload.id); if (requestedId && requestedId !== id) { throw new Error("Reference ID 不可修改,请新建记录"); } const response = await request>( `/brapi/v2/references/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(buildReferenceWriteBody(payload)), }, ); invalidateReferenceSetPageCache(); return mapReference(response.result); } export async function deleteReferenceRow(id: string): Promise { await request>( `/brapi/v2/references/${encodeURIComponent(id)}`, { method: "DELETE" }, ); invalidateReferenceSetPageCache(); } export async function createReferenceBasesRow(payload: ReferenceBasesPayload): Promise { const body = { ...buildReferenceBasesWriteBody(payload, true), ...(optionalText(payload.id) ? { referenceBasesDbId: optionalText(payload.id) } : {}), }; const response = await request>("/brapi/v2/referencebases", { method: "POST", body: JSON.stringify(body), }); invalidateReferenceSetPageCache(); return mapReferenceBases(response.result.data[0]); } export async function updateReferenceBasesRow(id: string, payload: ReferenceBasesPayload): Promise { const requestedId = optionalText(payload.id); if (requestedId && requestedId !== id) { throw new Error("ReferenceBases ID 不可修改,请新建记录"); } const response = await request>( `/brapi/v2/referencebases/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(buildReferenceBasesWriteBody(payload, false)), }, ); invalidateReferenceSetPageCache(); return mapReferenceBases(response.result); } export async function deleteReferenceBasesRow(id: string): Promise { await request>( `/brapi/v2/referencebases/${encodeURIComponent(id)}`, { method: "DELETE" }, ); invalidateReferenceSetPageCache(); }