fix:第三章开发结束
This commit is contained in:
318
frontend/src/app/(app)/genotyping/call-set/api.ts
Normal file
318
frontend/src/app/(app)/genotyping/call-set/api.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { createCachedLoader } from "@/services/dropdownCache";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type AlleleCallQuery,
|
||||
type AlleleCallRecord,
|
||||
type CallSetDetail,
|
||||
type CallSetQuery,
|
||||
type CallSetRecord,
|
||||
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 SampleResponse {
|
||||
sampleDbId: string;
|
||||
sampleName: string | null;
|
||||
}
|
||||
|
||||
interface VariantSetLookup {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type CallSetPayload = Partial<Record<
|
||||
"id" | "call_set_name" | "sample_id" | "variant_set_ids",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type AlleleCallPayload = Partial<Record<
|
||||
"call_set_id" | "variant_id" | "genotype" | "read_depth" | "phase_set",
|
||||
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 normalizeVariantSetIds = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => String(item ?? "").trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const sampleLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<SampleResponse>>("/brapi/v2/samples?page=0&pageSize=10");
|
||||
return response.result.data.map((item) => ({
|
||||
value: item.sampleDbId,
|
||||
label: item.sampleName || item.sampleDbId,
|
||||
}));
|
||||
});
|
||||
|
||||
const variantSetLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<{ variantSetDbId: string; variantSetName: string | null }>>(
|
||||
"/brapi/v2/variantsets?page=0&pageSize=10",
|
||||
);
|
||||
return response.result.data.map((item) => ({
|
||||
value: item.variantSetDbId,
|
||||
label: item.variantSetName || item.variantSetDbId,
|
||||
}));
|
||||
});
|
||||
|
||||
export function invalidateCallSetPageCache() {
|
||||
sampleLoader.invalidate();
|
||||
variantSetLoader.invalidate();
|
||||
}
|
||||
|
||||
const mapCallSet = (
|
||||
item: CallSetRecord,
|
||||
samples: SelectOption[],
|
||||
variantSets: VariantSetLookup[],
|
||||
): CallSetRecord => {
|
||||
const sampleDbId = item.sampleDbId || item.sample_id || null;
|
||||
const sample = samples.find((entry) => entry.value === sampleDbId);
|
||||
const variantSetIds = normalizeVariantSetIds(item.variantSetDbIds ?? item.variant_set_ids);
|
||||
const variantSetNames = variantSetIds
|
||||
.map((id) => variantSets.find((entry) => entry.value === id)?.label || id)
|
||||
.join("、");
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: item.callSetDbId || item.id,
|
||||
call_set_name: item.call_set_name || item.callSetName || null,
|
||||
sample_id: sampleDbId,
|
||||
sample_name: item.sample_name || item.sampleName || sample?.label || null,
|
||||
variant_set_ids: variantSetIds,
|
||||
variant_set_names: variantSetNames || "—",
|
||||
};
|
||||
};
|
||||
|
||||
const filterCallSetRows = (
|
||||
rows: CallSetRecord[],
|
||||
query: CallSetQuery | undefined,
|
||||
samples: SelectOption[],
|
||||
variantSets: VariantSetLookup[],
|
||||
): CallSetRecord[] => {
|
||||
const nameFilter = String(query?.call_set_name ?? "").trim().toLowerCase();
|
||||
const sampleId = optionalText(query?.sample_id);
|
||||
const variantSetId = optionalText(query?.variant_set_id);
|
||||
|
||||
return rows
|
||||
.map((item) => mapCallSet(item, samples, variantSets))
|
||||
.filter((item) => {
|
||||
if (nameFilter && !String(item.call_set_name ?? "").toLowerCase().includes(nameFilter)) return false;
|
||||
if (sampleId && item.sample_id !== sampleId) return false;
|
||||
if (variantSetId && !(item.variant_set_ids ?? []).includes(variantSetId)) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const callSetBody = (payload: CallSetPayload) => ({
|
||||
callSetName: requiredText(payload.call_set_name, "CallSet 名称不能为空"),
|
||||
sampleDbId: requiredText(payload.sample_id, "Sample 不能为空"),
|
||||
variantSetDbIds: normalizeVariantSetIds(payload.variant_set_ids),
|
||||
});
|
||||
|
||||
export const normalizeCallSetFormData = (row: CallSetRecord) => ({
|
||||
id: row.id,
|
||||
call_set_name: row.call_set_name || row.callSetName || "",
|
||||
sample_id: row.sample_id && row.sample_id !== NONE_SELECT_VALUE ? row.sample_id : NONE_SELECT_VALUE,
|
||||
variant_set_ids: normalizeVariantSetIds(row.variant_set_ids ?? row.variantSetDbIds),
|
||||
});
|
||||
|
||||
export async function loadCallSetPageData(params: {
|
||||
query?: CallSetQuery;
|
||||
force?: boolean;
|
||||
} = {}): Promise<{
|
||||
options: { samples: SelectOption[]; variantSets: SelectOption[] };
|
||||
rows: CallSetRecord[];
|
||||
}> {
|
||||
const force = params.force ?? false;
|
||||
const [samples, variantSets] = await Promise.all([
|
||||
sampleLoader.load(force),
|
||||
variantSetLoader.load(force),
|
||||
]);
|
||||
|
||||
const searchParams = new URLSearchParams({ page: "0", pageSize: "10" });
|
||||
const sampleId = optionalText(params.query?.sample_id);
|
||||
const variantSetId = optionalText(params.query?.variant_set_id);
|
||||
if (sampleId) searchParams.set("sampleDbId", sampleId);
|
||||
if (variantSetId) searchParams.set("variantSetDbId", variantSetId);
|
||||
|
||||
const response = await request<BrapiListResponse<CallSetRecord>>(
|
||||
`/brapi/v2/callsets?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
return {
|
||||
options: { samples, variantSets },
|
||||
rows: filterCallSetRows(response.result.data, params.query, samples, variantSets),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCallSetDetail(callSetDbId: string): Promise<CallSetDetail> {
|
||||
const [detail, samples, variantSets] = await Promise.all([
|
||||
request<BrapiSingleResponse<CallSetRecord>>(`/brapi/v2/callsets/${encodeURIComponent(callSetDbId)}`),
|
||||
sampleLoader.load(),
|
||||
variantSetLoader.load(),
|
||||
]);
|
||||
return mapCallSet(detail.result, samples, variantSets) as CallSetDetail;
|
||||
}
|
||||
|
||||
export async function createCallSetRow(payload: CallSetPayload): Promise<CallSetRecord> {
|
||||
const response = await request<BrapiListResponse<CallSetRecord>>("/brapi/v2/callsets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
callSetDbId: optionalText(payload.id),
|
||||
...callSetBody(payload),
|
||||
}),
|
||||
});
|
||||
invalidateCallSetPageCache();
|
||||
const [samples, variantSets] = await Promise.all([
|
||||
sampleLoader.load(true),
|
||||
variantSetLoader.load(true),
|
||||
]);
|
||||
return mapCallSet(response.result.data[0], samples, variantSets);
|
||||
}
|
||||
|
||||
export async function updateCallSetRow(id: string, payload: CallSetPayload): Promise<CallSetRecord> {
|
||||
const requestedId = optionalText(payload.id);
|
||||
if (requestedId && requestedId !== id) {
|
||||
throw new Error("CallSet ID 不可修改,请新建记录");
|
||||
}
|
||||
const response = await request<BrapiSingleResponse<CallSetRecord>>(
|
||||
`/brapi/v2/callsets/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(callSetBody(payload)),
|
||||
},
|
||||
);
|
||||
invalidateCallSetPageCache();
|
||||
const [samples, variantSets] = await Promise.all([
|
||||
sampleLoader.load(true),
|
||||
variantSetLoader.load(true),
|
||||
]);
|
||||
return mapCallSet(response.result, samples, variantSets);
|
||||
}
|
||||
|
||||
export async function deleteCallSetRow(id: string): Promise<void> {
|
||||
await request(`/brapi/v2/callsets/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
invalidateCallSetPageCache();
|
||||
}
|
||||
|
||||
const mapAlleleCall = (item: AlleleCallRecord & { additionalInfo?: Record<string, unknown> }): AlleleCallRecord => ({
|
||||
...item,
|
||||
id: String(item.additionalInfo?.callDbId ?? item.callDbId ?? item.id ?? ""),
|
||||
call_set_id: item.callSetDbId || item.call_set_id || null,
|
||||
call_set_name: item.callSetName || item.call_set_name || null,
|
||||
variant_id: item.variantDbId || item.variant_id || null,
|
||||
variant_name: item.variantName || item.variant_name || null,
|
||||
variant_set_id: item.variantSetDbId || item.variant_set_id || null,
|
||||
genotype: item.genotypeValue || item.genotype || null,
|
||||
});
|
||||
|
||||
const filterAlleleCallRows = (rows: AlleleCallRecord[], query?: AlleleCallQuery) => {
|
||||
const callSetId = optionalText(query?.call_set_id);
|
||||
const variantId = optionalText(query?.variant_id);
|
||||
const variantSetId = optionalText(query?.variant_set_id);
|
||||
|
||||
return rows
|
||||
.map(mapAlleleCall)
|
||||
.filter((item) => {
|
||||
if (callSetId && item.call_set_id !== callSetId) return false;
|
||||
if (variantId && item.variant_id !== variantId) return false;
|
||||
if (variantSetId && item.variant_set_id !== variantSetId) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export async function loadAlleleCallRows(query?: AlleleCallQuery): Promise<AlleleCallRecord[]> {
|
||||
const searchParams = new URLSearchParams({ pageSize: "10" });
|
||||
const callSetId = optionalText(query?.call_set_id);
|
||||
const variantId = optionalText(query?.variant_id);
|
||||
const variantSetId = optionalText(query?.variant_set_id);
|
||||
if (callSetId) searchParams.set("callSetDbId", callSetId);
|
||||
if (variantId) searchParams.set("variantDbId", variantId);
|
||||
if (variantSetId) searchParams.set("variantSetDbId", variantSetId);
|
||||
|
||||
const response = await request<BrapiListResponse<AlleleCallRecord>>(
|
||||
`/brapi/v2/calls?${searchParams.toString()}`,
|
||||
);
|
||||
return filterAlleleCallRows(response.result.data, query);
|
||||
}
|
||||
|
||||
export async function importAlleleCallRows(payloads: AlleleCallPayload[]): Promise<AlleleCallRecord[]> {
|
||||
const body = payloads.map((payload) => ({
|
||||
callDbId: optionalText((payload as Record<string, unknown>).id),
|
||||
callSetDbId: requiredText(payload.call_set_id, "CallSet 不能为空"),
|
||||
variantDbId: requiredText(payload.variant_id, "Variant 不能为空"),
|
||||
genotype: requiredText(payload.genotype, "Genotype 不能为空"),
|
||||
readDepth: payload.read_depth === null || payload.read_depth === undefined || payload.read_depth === ""
|
||||
? null
|
||||
: Number(payload.read_depth),
|
||||
phaseSet: optionalText(payload.phase_set),
|
||||
}));
|
||||
|
||||
const response = await request<BrapiListResponse<AlleleCallRecord>>("/brapi/v2/calls/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return response.result.data.map(mapAlleleCall);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Dna, RotateCcw, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { loadAlleleCallRows, loadCallSetPageData } from "../api";
|
||||
import { NONE_SELECT_VALUE, type AlleleCallQuery, type AlleleCallRecord, type SelectOption } from "../types";
|
||||
|
||||
const emptyQuery = (): AlleleCallQuery => ({
|
||||
call_set_id: NONE_SELECT_VALUE,
|
||||
variant_id: NONE_SELECT_VALUE,
|
||||
variant_set_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
|
||||
export function AlleleCallTab() {
|
||||
const searchParams = useSearchParams();
|
||||
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
|
||||
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
|
||||
const [draftQuery, setDraftQuery] = useState<AlleleCallQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE,
|
||||
}));
|
||||
const [appliedQuery, setAppliedQuery] = useState<AlleleCallQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
variant_id: searchParams.get("variant_id") || NONE_SELECT_VALUE,
|
||||
}));
|
||||
const [rows, setRows] = useState<AlleleCallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
loadCallSetPageData()
|
||||
.then(({ options, rows: callSetRows }) => {
|
||||
if (!mounted) return;
|
||||
setCallSetOptions(callSetRows.map((item) => ({
|
||||
value: String(item.id ?? item.callSetDbId ?? ""),
|
||||
label: String(item.call_set_name ?? item.callSetName ?? item.id ?? ""),
|
||||
})).filter((item) => item.value));
|
||||
setVariantSetOptions(options.variantSets);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
return loadAlleleCallRows(appliedQuery);
|
||||
}, [appliedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
loadRows()
|
||||
.then((data) => {
|
||||
if (!mounted) return;
|
||||
setRows(data);
|
||||
})
|
||||
.catch((event) => {
|
||||
if (!mounted) return;
|
||||
setError(event instanceof Error ? event.message : "加载 allele_call 失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [loadRows]);
|
||||
|
||||
const hint = useMemo(() => {
|
||||
if (appliedQuery.variant_id && appliedQuery.variant_id !== NONE_SELECT_VALUE) {
|
||||
return "按 Variant 过滤查看该位点下的 genotype 调用结果。";
|
||||
}
|
||||
return "allele_call 通常通过 VCF/HapMap/矩阵导入;导入时会自动绑定 callset 与 variantset 关系。";
|
||||
}, [appliedQuery.variant_id]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Dna className="h-4 w-4 text-emerald-500" />
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">allele_call 调用结果</h2>
|
||||
<p className="text-xs text-slate-500">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-500">CallSet</Label>
|
||||
<Select
|
||||
value={draftQuery.call_set_id ?? NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, call_set_id: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部</SelectItem>
|
||||
{callSetOptions.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>
|
||||
<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>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
|
||||
{loading ? (
|
||||
<div className="p-4"><Skeleton className="h-64 w-full" /></div>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="p-6 text-sm text-slate-500">暂无 allele_call 记录。</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CallSet</TableHead>
|
||||
<TableHead>Variant</TableHead>
|
||||
<TableHead>VariantSet</TableHead>
|
||||
<TableHead>Genotype</TableHead>
|
||||
<TableHead>Read Depth</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={String(row.id || `${row.call_set_id}-${row.variant_id}`)}>
|
||||
<TableCell>{row.call_set_name || row.call_set_id || "—"}</TableCell>
|
||||
<TableCell>{row.variant_name || row.variant_id || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{row.variant_set_id || "—"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{row.genotype || "—"}</TableCell>
|
||||
<TableCell>{row.read_depth ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Binary, 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
createCallSetRow,
|
||||
deleteCallSetRow,
|
||||
fetchCallSetDetail,
|
||||
loadCallSetPageData,
|
||||
normalizeCallSetFormData,
|
||||
updateCallSetRow,
|
||||
} from "../api";
|
||||
import { NONE_SELECT_VALUE, type CallSetQuery, type SelectOption } from "../types";
|
||||
|
||||
const emptyQuery = (): CallSetQuery => ({
|
||||
call_set_name: "",
|
||||
sample_id: NONE_SELECT_VALUE,
|
||||
variant_set_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
|
||||
const optionOrNone = (label: string, options: SelectOption[]) => [
|
||||
{ value: NONE_SELECT_VALUE, label },
|
||||
...options,
|
||||
];
|
||||
|
||||
function normalizeSelectedVariantSetIds(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((item) => String(item ?? "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function CallSetTab() {
|
||||
const searchParams = useSearchParams();
|
||||
const [sampleOptions, setSampleOptions] = useState<SelectOption[]>([]);
|
||||
const [variantSetOptions, setVariantSetOptions] = useState<SelectOption[]>([]);
|
||||
const [draftQuery, setDraftQuery] = useState<CallSetQuery>(emptyQuery);
|
||||
const [appliedQuery, setAppliedQuery] = useState<CallSetQuery>(emptyQuery);
|
||||
|
||||
const urlDefaultFormValues = useMemo(() => {
|
||||
const sampleId = searchParams.get("sample_id");
|
||||
if (!sampleId) return undefined;
|
||||
return { sample_id: sampleId };
|
||||
}, [searchParams]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const { options, rows } = await loadCallSetPageData({ query: appliedQuery });
|
||||
setSampleOptions(options.samples);
|
||||
setVariantSetOptions(options.variantSets);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [appliedQuery]);
|
||||
|
||||
const fetchRecord = useCallback(async (id: string) => {
|
||||
const detail = await fetchCallSetDetail(id);
|
||||
return normalizeCallSetFormData(detail);
|
||||
}, []);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "CallSet ID", type: "text", placeholder: "留空则系统自动生成(导入时可指定)" },
|
||||
{
|
||||
key: "call_set_name",
|
||||
label: "CallSet 名称",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "如 sample01_variantset1",
|
||||
},
|
||||
{
|
||||
key: "sample_id",
|
||||
label: "Sample",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: optionOrNone("请选择 Sample", sampleOptions),
|
||||
},
|
||||
], [sampleOptions]);
|
||||
|
||||
const renderFormExtra = useCallback(({ formData, updateFormBatch }: {
|
||||
formData: Record<string, unknown>;
|
||||
updateForm: (key: string, value: string) => void;
|
||||
updateFormBatch: (patch: Record<string, unknown>) => void;
|
||||
editingRow: Record<string, unknown> | null;
|
||||
}) => {
|
||||
const selectedIds = normalizeSelectedVariantSetIds(formData.variant_set_ids);
|
||||
|
||||
const toggleVariantSet = (variantSetId: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...selectedIds, variantSetId]))
|
||||
: selectedIds.filter((id) => id !== variantSetId);
|
||||
updateFormBatch({ variant_set_ids: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label className="text-sm text-slate-700 dark:text-slate-200">VariantSet(可多选)</Label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
{variantSetOptions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">暂无可选 VariantSet,请先在 VariantSet 页面创建。</p>
|
||||
) : (
|
||||
variantSetOptions.map((option) => {
|
||||
const checked = selectedIds.includes(option.value);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-sm hover:bg-slate-50 dark:hover:bg-slate-900"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(value) => toggleVariantSet(option.value, value === true)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
创建或编辑 CallSet 时绑定 VariantSet;导入 allele_call 时也会自动写入 callset_variant_sets 关系。
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-span-2 rounded-lg border border-sky-100 bg-sky-50/60 p-3 text-xs text-sky-900 dark:border-sky-900/40 dark:bg-sky-950/30 dark:text-sky-100">
|
||||
<p className="font-medium">callset_variant_sets</p>
|
||||
<p className="mt-1 text-sky-800/80 dark:text-sky-200/80">
|
||||
此处勾选会写入 callset 与 variantset 的多对多关系,无需单独维护关系表。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [variantSetOptions]);
|
||||
|
||||
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">CallSet 名称</Label>
|
||||
<Input
|
||||
value={draftQuery.call_set_name ?? ""}
|
||||
onChange={(event) => setDraftQuery((current) => ({ ...current, call_set_name: event.target.value }))}
|
||||
placeholder="callSetName 模糊匹配"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-500">Sample</Label>
|
||||
<Select
|
||||
value={draftQuery.sample_id ?? NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, sample_id: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部</SelectItem>
|
||||
{sampleOptions.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>
|
||||
<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, sampleOptions, variantSetOptions]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={Binary}
|
||||
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
|
||||
title="CallSet 调用集合"
|
||||
description="维护 sample 的 genotype call 集合,并绑定其覆盖的 VariantSet。"
|
||||
addLabel="新增 CallSet"
|
||||
defaultFormValues={urlDefaultFormValues}
|
||||
columns={[
|
||||
{ key: "call_set_name", label: "名称" },
|
||||
{ key: "sample_name", label: "Sample" },
|
||||
{
|
||||
key: "variant_set_names",
|
||||
label: "VariantSet",
|
||||
render: (value) => (
|
||||
<Badge variant="outline" className="max-w-xs truncate">
|
||||
{String(value ?? "—")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[{
|
||||
label: "/brapi/v2/callsets",
|
||||
value: "BrAPI",
|
||||
className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200",
|
||||
}]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={fetchRecord}
|
||||
createRecord={(payload) => createCallSetRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCallSetRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
deleteRecord={deleteCallSetRow}
|
||||
renderQueryForm={renderQueryForm}
|
||||
renderFormExtra={renderFormExtra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
frontend/src/app/(app)/genotyping/call-set/page.tsx
Normal file
56
frontend/src/app/(app)/genotyping/call-set/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Binary, Dna } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AlleleCallTab } from "./components/AlleleCallTab";
|
||||
import { CallSetTab } from "./components/CallSetTab";
|
||||
|
||||
function TabFallback() {
|
||||
return <Skeleton className="h-96 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
export default function CallSetPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const [tab, setTab] = useState("callsets");
|
||||
|
||||
useEffect(() => {
|
||||
const requested = searchParams.get("tab");
|
||||
if (requested === "allele-calls") {
|
||||
setTab("allele-calls");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TabsTrigger value="callsets" className="gap-2">
|
||||
<Binary className="h-4 w-4" />
|
||||
CallSet
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="allele-calls" className="gap-2">
|
||||
<Dna className="h-4 w-4" />
|
||||
allele_call
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{tab === "callsets" ? (
|
||||
<TabsContent value="callsets" className="mt-0 min-h-0 flex-1">
|
||||
<Suspense fallback={<TabFallback />}>
|
||||
<CallSetTab />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "allele-calls" ? (
|
||||
<TabsContent value="allele-calls" className="mt-0 min-h-0 flex-1">
|
||||
<Suspense fallback={<TabFallback />}>
|
||||
<AlleleCallTab />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
57
frontend/src/app/(app)/genotyping/call-set/types.ts
Normal file
57
frontend/src/app/(app)/genotyping/call-set/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export const NONE_SELECT_VALUE = "__none__";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CallSetQuery {
|
||||
call_set_name?: string;
|
||||
sample_id?: string;
|
||||
variant_set_id?: string;
|
||||
}
|
||||
|
||||
export interface CallSetRecord {
|
||||
id?: string;
|
||||
callSetDbId?: string;
|
||||
call_set_name?: string | null;
|
||||
callSetName?: string | null;
|
||||
sample_id?: string | null;
|
||||
sampleDbId?: string | null;
|
||||
sample_name?: string | null;
|
||||
sampleName?: string | null;
|
||||
variant_set_ids?: string[];
|
||||
variantSetDbIds?: string[] | null;
|
||||
variant_set_names?: string;
|
||||
created?: string | null;
|
||||
updated?: string | null;
|
||||
}
|
||||
|
||||
export interface CallSetDetail extends CallSetRecord {
|
||||
variant_set_ids: string[];
|
||||
}
|
||||
|
||||
export interface AlleleCallQuery {
|
||||
call_set_id?: string;
|
||||
variant_id?: string;
|
||||
variant_set_id?: string;
|
||||
}
|
||||
|
||||
export interface AlleleCallRecord {
|
||||
id?: string;
|
||||
callDbId?: string;
|
||||
call_set_id?: string | null;
|
||||
callSetDbId?: string | null;
|
||||
call_set_name?: string | null;
|
||||
callSetName?: string | null;
|
||||
variant_id?: string | null;
|
||||
variantDbId?: string | null;
|
||||
variant_name?: string | null;
|
||||
variantName?: string | null;
|
||||
variant_set_id?: string | null;
|
||||
variantSetDbId?: string | null;
|
||||
genotype?: string | null;
|
||||
genotypeValue?: string | null;
|
||||
read_depth?: number | null;
|
||||
phase_set?: string | null;
|
||||
}
|
||||
568
frontend/src/app/(app)/genotyping/genome-map/api.ts
Normal file
568
frontend/src/app/(app)/genotyping/genome-map/api.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { DEFAULT_LIST_PAGE_SIZE, DEFAULT_PAGE_QUERY } from "@/constants/api";
|
||||
import { createCachedLoader, loadCommonCropNameOptions, type SelectOption as CachedSelectOption } from "@/services/dropdownCache";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import { readAdditionalInfoString } from "./genomeMapUtils";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type GenomeMapDetail,
|
||||
type GenomeMapQuery,
|
||||
type GenomeMapRecord,
|
||||
type LinkageGroupQuery,
|
||||
type LinkageGroupRecord,
|
||||
type MarkerPositionQuery,
|
||||
type MarkerPositionRecord,
|
||||
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;
|
||||
}
|
||||
|
||||
type GenomeMapPayload = Partial<Record<
|
||||
| "id"
|
||||
| "map_name"
|
||||
| "map_pui"
|
||||
| "common_crop_name"
|
||||
| "scientific_name"
|
||||
| "type"
|
||||
| "unit"
|
||||
| "comments"
|
||||
| "documentation_url"
|
||||
| "published_date",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type LinkageGroupPayload = Partial<Record<
|
||||
"id" | "linkage_group_name" | "max_position",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type MarkerPositionPayload = Partial<Record<
|
||||
"id" | "linkage_group_id" | "variant_id" | "position",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
interface VariantLookup {
|
||||
variantDbId?: string;
|
||||
variantId?: string;
|
||||
id?: string;
|
||||
variantNames?: string[] | null;
|
||||
variantName?: string | null;
|
||||
}
|
||||
|
||||
const readVariantLabel = (item: VariantLookup) =>
|
||||
item.variantNames?.[0] || item.variantName || item.variantDbId || item.variantId || item.id || "";
|
||||
|
||||
const toValidSelectOptions = (options: SelectOption[]) =>
|
||||
options.filter((option) => Boolean(String(option.value ?? "").trim()));
|
||||
|
||||
const mapVariantOptions = (rows: VariantLookup[]): SelectOption[] =>
|
||||
toValidSelectOptions(rows.map((item) => {
|
||||
const value = String(item.variantDbId ?? item.variantId ?? item.id ?? "").trim();
|
||||
return { value, label: readVariantLabel(item) || value };
|
||||
}).filter((item) => item.value));
|
||||
|
||||
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}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
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) => {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
if (Number.isNaN(parsed)) throw new Error("请输入有效数字");
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const requiredNumber = (value: unknown, message: string) => {
|
||||
const parsed = optionalNumber(value);
|
||||
if (parsed === null) throw new Error(message);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const mapGenomeMap = (item: GenomeMapRecord): GenomeMapRecord => ({
|
||||
...item,
|
||||
id: item.mapDbId || item.id,
|
||||
map_name: item.map_name ?? item.mapName ?? null,
|
||||
map_pui: item.map_pui ?? item.mapPUI ?? null,
|
||||
common_crop_name: item.common_crop_name ?? item.commonCropName ?? null,
|
||||
scientific_name: item.scientific_name ?? item.scientificName ?? null,
|
||||
documentation_url: item.documentation_url ?? item.documentationURL ?? null,
|
||||
published_date: item.published_date ?? (item.publishedDate ? String(item.publishedDate).slice(0, 10) : null),
|
||||
linkage_group_count: item.linkage_group_count ?? item.linkageGroupCount ?? null,
|
||||
marker_count: item.marker_count ?? item.markerCount ?? null,
|
||||
});
|
||||
|
||||
const mapLinkageGroup = (item: Record<string, unknown>): LinkageGroupRecord => {
|
||||
const id = (
|
||||
readAdditionalInfoString(item, "linkageGroupDbId")
|
||||
|| String(item.linkageGroupDbId ?? item.id ?? "")
|
||||
).trim();
|
||||
const mapDbId = readAdditionalInfoString(item, "mapDbId") || String(item.mapDbId ?? item.map_db_id ?? "").trim();
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
linkage_group_db_id: id,
|
||||
linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""),
|
||||
max_position: (item.maxPosition ?? item.max_position) as number | null,
|
||||
marker_count: (item.markerCount ?? item.marker_count) as number | null,
|
||||
map_db_id: mapDbId || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const mapMarkerPosition = (item: Record<string, unknown>): MarkerPositionRecord => {
|
||||
const id = readAdditionalInfoString(item, "markerPositionDbId")
|
||||
|| String(item.markerPositionDbId ?? item.id ?? "");
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
marker_position_db_id: id,
|
||||
linkage_group_db_id: readAdditionalInfoString(item, "linkageGroupDbId") ?? undefined,
|
||||
linkage_group_name: String(item.linkageGroupName ?? item.linkage_group_name ?? ""),
|
||||
variant_db_id: String(item.variantDbId ?? item.variant_db_id ?? ""),
|
||||
variant_name: String(item.variantName ?? item.variant_name ?? ""),
|
||||
position: (item.position ?? null) as number | null,
|
||||
map_db_id: String(item.mapDbId ?? item.map_db_id ?? ""),
|
||||
map_name: String(item.mapName ?? item.map_name ?? ""),
|
||||
};
|
||||
};
|
||||
|
||||
const genomeMapBody = (payload: GenomeMapPayload) => ({
|
||||
mapDbId: optionalText(payload.id),
|
||||
mapName: requiredText(payload.map_name, "图谱名称不能为空"),
|
||||
commonCropName: optionalText(payload.common_crop_name),
|
||||
scientificName: optionalText(payload.scientific_name),
|
||||
type: optionalText(payload.type),
|
||||
unit: optionalText(payload.unit),
|
||||
comments: optionalText(payload.comments),
|
||||
documentationURL: optionalText(payload.documentation_url),
|
||||
publishedDate: optionalText(payload.published_date),
|
||||
});
|
||||
|
||||
const linkageGroupBody = (payload: LinkageGroupPayload) => ({
|
||||
linkageGroupName: requiredText(payload.linkage_group_name, "连锁群名称不能为空"),
|
||||
maxPosition: optionalNumber(payload.max_position),
|
||||
});
|
||||
|
||||
const markerPositionBody = (payload: MarkerPositionPayload) => ({
|
||||
markerPositionDbId: optionalText(payload.id),
|
||||
linkageGroupDbId: requiredText(payload.linkage_group_id, "请选择连锁群"),
|
||||
variantDbId: requiredText(payload.variant_id, "请选择 Variant"),
|
||||
position: requiredNumber(payload.position, "位置不能为空"),
|
||||
});
|
||||
|
||||
export const normalizeGenomeMapFormData = (row: GenomeMapRecord) => ({
|
||||
id: row.id,
|
||||
map_name: row.map_name || "",
|
||||
map_pui: row.map_pui || "",
|
||||
common_crop_name: row.common_crop_name && row.common_crop_name !== NONE_SELECT_VALUE
|
||||
? row.common_crop_name
|
||||
: NONE_SELECT_VALUE,
|
||||
scientific_name: row.scientific_name || "",
|
||||
type: row.type || "",
|
||||
unit: row.unit || "",
|
||||
comments: row.comments || "",
|
||||
documentation_url: row.documentation_url || "",
|
||||
published_date: row.published_date || "",
|
||||
});
|
||||
|
||||
export const normalizeLinkageGroupFormData = (row: LinkageGroupRecord, includeMap = false) => ({
|
||||
id: row.id,
|
||||
linkage_group_name: row.linkage_group_name || "",
|
||||
max_position: row.max_position ?? "",
|
||||
...(includeMap ? {
|
||||
map_db_id: row.map_db_id && row.map_db_id !== NONE_SELECT_VALUE
|
||||
? row.map_db_id
|
||||
: NONE_SELECT_VALUE,
|
||||
} : {}),
|
||||
});
|
||||
|
||||
export const normalizeMarkerPositionFormData = (row: MarkerPositionRecord) => ({
|
||||
id: row.id,
|
||||
linkage_group_id: row.linkage_group_db_id && row.linkage_group_db_id !== NONE_SELECT_VALUE
|
||||
? row.linkage_group_db_id
|
||||
: NONE_SELECT_VALUE,
|
||||
variant_id: row.variant_db_id && row.variant_db_id !== NONE_SELECT_VALUE
|
||||
? row.variant_db_id
|
||||
: NONE_SELECT_VALUE,
|
||||
position: row.position ?? "",
|
||||
});
|
||||
|
||||
const mapListLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<GenomeMapRecord>>(`/brapi/v2/maps?${DEFAULT_PAGE_QUERY}`);
|
||||
return response.result.data.map(mapGenomeMap);
|
||||
});
|
||||
|
||||
const allLinkageGroupsLoader = createCachedLoader(async () => {
|
||||
const maps = await mapListLoader.load();
|
||||
if (maps.length === 0) return [];
|
||||
const batches = await Promise.all(
|
||||
maps.map(async (map) => {
|
||||
try {
|
||||
const rows = await fetchLinkageGroupRows(map.id);
|
||||
return rows
|
||||
.filter((row) => row.id)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
map_db_id: row.map_db_id || map.id,
|
||||
map_name: map.map_name || map.id,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
return batches.flat();
|
||||
});
|
||||
|
||||
const variantListLoader = createCachedLoader(async () => {
|
||||
const response = await request<BrapiListResponse<VariantLookup>>(
|
||||
`/brapi/v2/variants?pageToken=0&pageSize=${DEFAULT_LIST_PAGE_SIZE}`,
|
||||
);
|
||||
return mapVariantOptions(response.result?.data ?? []);
|
||||
});
|
||||
|
||||
export function invalidateGenomeMapPageCache() {
|
||||
mapListLoader.invalidate();
|
||||
allLinkageGroupsLoader.invalidate();
|
||||
variantListLoader.invalidate();
|
||||
}
|
||||
|
||||
const filterLinkageGroupRows = (rows: LinkageGroupRecord[], query?: LinkageGroupQuery) => {
|
||||
const nameFilter = String(query?.linkage_group_name ?? "").trim().toLowerCase();
|
||||
const mapFilter = optionalText(query?.map_db_id);
|
||||
return rows.filter((row) => {
|
||||
if (nameFilter && !String(row.linkage_group_name ?? "").toLowerCase().includes(nameFilter)) return false;
|
||||
if (mapFilter && row.map_db_id !== mapFilter) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export async function loadLinkageGroupPageData(params: {
|
||||
query?: LinkageGroupQuery;
|
||||
force?: boolean;
|
||||
} = {}) {
|
||||
const [maps, linkageGroups] = await Promise.all([
|
||||
mapListLoader.load(params.force),
|
||||
allLinkageGroupsLoader.load(params.force),
|
||||
]);
|
||||
const mapOptions: SelectOption[] = maps.map((map) => ({
|
||||
value: map.id,
|
||||
label: map.map_name || map.id,
|
||||
}));
|
||||
return {
|
||||
options: { maps: mapOptions },
|
||||
rows: filterLinkageGroupRows(linkageGroups, params.query),
|
||||
};
|
||||
}
|
||||
|
||||
const filterMapRows = (rows: GenomeMapRecord[], query?: GenomeMapQuery) => {
|
||||
const nameFilter = String(query?.map_name ?? "").trim().toLowerCase();
|
||||
const cropFilter = optionalText(query?.common_crop_name);
|
||||
const typeFilter = optionalText(query?.type);
|
||||
return rows.filter((row) => {
|
||||
if (nameFilter && !String(row.map_name ?? "").toLowerCase().includes(nameFilter)) return false;
|
||||
if (cropFilter && row.common_crop_name !== cropFilter) return false;
|
||||
if (typeFilter && row.type !== typeFilter) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export async function loadGenomeMapPageData(params: {
|
||||
query?: GenomeMapQuery;
|
||||
force?: boolean;
|
||||
} = {}) {
|
||||
const [maps, cropOptions] = await Promise.all([
|
||||
mapListLoader.load(params.force),
|
||||
loadCommonCropNameOptions(params.force),
|
||||
]);
|
||||
const cropSelectOptions: SelectOption[] = cropOptions.map((item: CachedSelectOption) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
return {
|
||||
options: { crops: cropSelectOptions },
|
||||
rows: filterMapRows(maps, params.query),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGenomeMapDetail(mapDbId: string): Promise<GenomeMapDetail> {
|
||||
const [mapResponse, linkageResponse, markerResponse] = await Promise.all([
|
||||
request<BrapiSingleResponse<GenomeMapRecord>>(`/brapi/v2/maps/${encodeURIComponent(mapDbId)}`),
|
||||
request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`,
|
||||
),
|
||||
request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/markerpositions?mapDbId=${encodeURIComponent(mapDbId)}&${DEFAULT_PAGE_QUERY}`,
|
||||
),
|
||||
]);
|
||||
const map = mapGenomeMap(mapResponse.result);
|
||||
return {
|
||||
...map,
|
||||
linkageGroups: linkageResponse.result.data.map(mapLinkageGroup),
|
||||
markerPositions: markerResponse.result.data.map(mapMarkerPosition),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLinkageGroupRows(mapDbId: string): Promise<LinkageGroupRecord[]> {
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups?${DEFAULT_PAGE_QUERY}`,
|
||||
);
|
||||
return response.result.data.map(mapLinkageGroup);
|
||||
}
|
||||
|
||||
const buildMarkerPositionQueryString = (query?: MarkerPositionQuery) => {
|
||||
const params = new URLSearchParams(DEFAULT_PAGE_QUERY);
|
||||
const mapDbId = optionalText(query?.map_db_id);
|
||||
const linkageGroupName = optionalText(query?.linkage_group_name);
|
||||
const variantDbId = optionalText(query?.variant_db_id);
|
||||
if (mapDbId) params.set("mapDbId", mapDbId);
|
||||
if (linkageGroupName) params.set("linkageGroupName", linkageGroupName);
|
||||
if (variantDbId) params.set("variantDbId", variantDbId);
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
const filterMarkerPositionRows = (
|
||||
rows: MarkerPositionRecord[],
|
||||
query?: MarkerPositionQuery,
|
||||
linkageGroupDbId?: string,
|
||||
) => {
|
||||
const linkageGroupFilter = optionalText(linkageGroupDbId);
|
||||
return rows.filter((row) => {
|
||||
if (linkageGroupFilter && row.linkage_group_db_id !== linkageGroupFilter) return false;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export async function fetchMarkerPositionRowsByQuery(
|
||||
query?: MarkerPositionQuery,
|
||||
linkageGroupDbId?: string,
|
||||
): Promise<MarkerPositionRecord[]> {
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/markerpositions?${buildMarkerPositionQueryString(query)}`,
|
||||
);
|
||||
return filterMarkerPositionRows(
|
||||
response.result.data.map(mapMarkerPosition),
|
||||
query,
|
||||
linkageGroupDbId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadMarkerPositionFilterOptions(params: {
|
||||
mapDbId?: string;
|
||||
} = {}) {
|
||||
const maps = await mapListLoader.load();
|
||||
const mapOptions: SelectOption[] = maps.map((map) => ({
|
||||
value: map.id,
|
||||
label: map.map_name || map.id,
|
||||
}));
|
||||
const mapFilter = optionalText(params.mapDbId);
|
||||
let linkageGroupOptions: SelectOption[] = [];
|
||||
if (mapFilter) {
|
||||
const rows = await fetchLinkageGroupRows(mapFilter);
|
||||
linkageGroupOptions = toValidSelectOptions(rows.map((row) => ({
|
||||
value: row.linkage_group_name || row.id,
|
||||
label: row.linkage_group_name || row.id,
|
||||
})));
|
||||
} else {
|
||||
const linkageGroups = await allLinkageGroupsLoader.load();
|
||||
linkageGroupOptions = toValidSelectOptions(linkageGroups.map((row) => ({
|
||||
value: row.linkage_group_name || row.id,
|
||||
label: row.map_name
|
||||
? `${row.linkage_group_name || row.id} (${row.map_name})`
|
||||
: (row.linkage_group_name || row.id),
|
||||
})));
|
||||
}
|
||||
const variants = await variantListLoader.load();
|
||||
return {
|
||||
maps: mapOptions,
|
||||
linkageGroups: linkageGroupOptions,
|
||||
variants,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadMarkerPositionPageData(params: {
|
||||
query?: MarkerPositionQuery;
|
||||
} = {}) {
|
||||
const mapDbId = optionalText(params.query?.map_db_id) ?? undefined;
|
||||
const [options, rows] = await Promise.all([
|
||||
loadMarkerPositionFilterOptions({ mapDbId }),
|
||||
fetchMarkerPositionRowsByQuery(params.query),
|
||||
]);
|
||||
return { options, rows };
|
||||
}
|
||||
|
||||
export async function fetchLinkageGroupDetail(
|
||||
mapDbId: string,
|
||||
linkageGroupDbId: string,
|
||||
): Promise<LinkageGroupRecord | null> {
|
||||
const rows = await fetchLinkageGroupRows(mapDbId);
|
||||
return rows.find((row) => row.id === linkageGroupDbId) ?? null;
|
||||
}
|
||||
|
||||
export async function fetchMarkerPositionRows(mapDbId: string): Promise<MarkerPositionRecord[]> {
|
||||
return fetchMarkerPositionRowsByQuery({ map_db_id: mapDbId });
|
||||
}
|
||||
|
||||
export async function fetchMarkerPositionsByVariantId(variantDbId: string): Promise<MarkerPositionRecord[]> {
|
||||
return fetchMarkerPositionRowsByQuery({ variant_db_id: variantDbId });
|
||||
}
|
||||
|
||||
export async function fetchVariantOptions(force = false): Promise<SelectOption[]> {
|
||||
return variantListLoader.load(force);
|
||||
}
|
||||
|
||||
export async function fetchAllLinkageGroupFormOptions(): Promise<SelectOption[]> {
|
||||
const linkageGroups = await allLinkageGroupsLoader.load();
|
||||
return toValidSelectOptions(linkageGroups.map((row) => ({
|
||||
value: row.id,
|
||||
label: row.map_name
|
||||
? `${row.linkage_group_name || row.id} (${row.map_name})`
|
||||
: (row.linkage_group_name || row.id),
|
||||
})));
|
||||
}
|
||||
|
||||
export async function fetchLinkageGroupOptions(mapDbId: string): Promise<SelectOption[]> {
|
||||
const rows = await fetchLinkageGroupRows(mapDbId);
|
||||
return toValidSelectOptions(rows.map((row) => ({
|
||||
value: row.id,
|
||||
label: row.linkage_group_name || row.id,
|
||||
})));
|
||||
}
|
||||
|
||||
export async function createGenomeMapRow(payload: GenomeMapPayload) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiListResponse<GenomeMapRecord>>("/brapi/v2/maps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(genomeMapBody(payload)),
|
||||
});
|
||||
return mapGenomeMap(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateGenomeMapRow(id: string, payload: GenomeMapPayload) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiSingleResponse<GenomeMapRecord>>(
|
||||
`/brapi/v2/maps/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body: JSON.stringify(genomeMapBody(payload)) },
|
||||
);
|
||||
return mapGenomeMap(response.result);
|
||||
}
|
||||
|
||||
export async function deleteGenomeMapRow(id: string) {
|
||||
invalidateGenomeMapPageCache();
|
||||
await request(`/brapi/v2/maps/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function createLinkageGroupRow(mapDbId: string, payload: LinkageGroupPayload) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups`,
|
||||
{ method: "POST", body: JSON.stringify(linkageGroupBody(payload)) },
|
||||
);
|
||||
return mapLinkageGroup(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function createLinkageGroupRowFromPayload(payload: LinkageGroupPayload & { map_db_id?: unknown }) {
|
||||
const mapDbId = requiredText(payload.map_db_id, "请选择所属图谱");
|
||||
return createLinkageGroupRow(mapDbId, payload);
|
||||
}
|
||||
|
||||
export async function updateLinkageGroupRow(
|
||||
mapDbId: string,
|
||||
linkageGroupDbId: string,
|
||||
payload: LinkageGroupPayload,
|
||||
) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`,
|
||||
{ method: "PUT", body: JSON.stringify(linkageGroupBody(payload)) },
|
||||
);
|
||||
return mapLinkageGroup(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function deleteLinkageGroupRow(mapDbId: string, linkageGroupDbId: string) {
|
||||
invalidateGenomeMapPageCache();
|
||||
await request(
|
||||
`/brapi/v2/maps/${encodeURIComponent(mapDbId)}/linkagegroups/${encodeURIComponent(linkageGroupDbId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function createMarkerPositionRow(payload: MarkerPositionPayload) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>("/brapi/v2/markerpositions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(markerPositionBody(payload)),
|
||||
});
|
||||
return mapMarkerPosition(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateMarkerPositionRow(id: string, payload: MarkerPositionPayload) {
|
||||
invalidateGenomeMapPageCache();
|
||||
const response = await request<BrapiListResponse<Record<string, unknown>>>(
|
||||
`/brapi/v2/markerpositions/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body: JSON.stringify(markerPositionBody(payload)) },
|
||||
);
|
||||
return mapMarkerPosition(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function deleteMarkerPositionRow(id: string) {
|
||||
invalidateGenomeMapPageCache();
|
||||
await request(`/brapi/v2/markerpositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Map, 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 {
|
||||
createGenomeMapRow,
|
||||
deleteGenomeMapRow,
|
||||
loadGenomeMapPageData,
|
||||
normalizeGenomeMapFormData,
|
||||
updateGenomeMapRow,
|
||||
} from "../api";
|
||||
import { NONE_SELECT_VALUE, type GenomeMapQuery, type SelectOption } from "../types";
|
||||
|
||||
const mapTypeOptions: SelectOption[] = [
|
||||
{ value: "physical", label: "physical(物理图)" },
|
||||
{ value: "genomic", label: "genomic(基因组图)" },
|
||||
];
|
||||
|
||||
const emptyQuery = (): GenomeMapQuery => ({
|
||||
map_name: "",
|
||||
common_crop_name: NONE_SELECT_VALUE,
|
||||
type: NONE_SELECT_VALUE,
|
||||
});
|
||||
|
||||
const optionOrNone = (label: string, options: SelectOption[]) => [
|
||||
{ value: NONE_SELECT_VALUE, label },
|
||||
...options,
|
||||
];
|
||||
|
||||
export function GenomeMapTab() {
|
||||
const [cropOptions, setCropOptions] = useState<SelectOption[]>([]);
|
||||
const [draftQuery, setDraftQuery] = useState<GenomeMapQuery>(emptyQuery);
|
||||
const [appliedQuery, setAppliedQuery] = useState<GenomeMapQuery>(emptyQuery);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const { options, rows } = await loadGenomeMapPageData({ query: appliedQuery });
|
||||
setCropOptions(options.crops);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [appliedQuery]);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "Map ID", type: "text", placeholder: "留空则系统自动生成" },
|
||||
{ key: "map_name", label: "图谱名称", type: "text", required: true, placeholder: "如 Maize IBM2" },
|
||||
{
|
||||
key: "map_pui",
|
||||
label: "Map PUI",
|
||||
type: "text",
|
||||
readOnly: true,
|
||||
placeholder: "保存后由系统自动生成",
|
||||
},
|
||||
{
|
||||
key: "common_crop_name",
|
||||
label: "作物",
|
||||
type: "select",
|
||||
options: optionOrNone("不关联作物", cropOptions),
|
||||
},
|
||||
{ key: "scientific_name", label: "学名", type: "text", placeholder: "Zea mays" },
|
||||
{
|
||||
key: "type",
|
||||
label: "图谱类型",
|
||||
type: "select",
|
||||
options: optionOrNone("不指定", mapTypeOptions),
|
||||
},
|
||||
{ key: "unit", label: "单位", type: "text", placeholder: "如 cM" },
|
||||
{ key: "published_date", label: "发表日期", type: "date" },
|
||||
{ key: "documentation_url", label: "文档 URL", type: "text", placeholder: "https://..." },
|
||||
{ key: "comments", label: "备注", type: "textarea", colSpan: 2 },
|
||||
], [cropOptions]);
|
||||
|
||||
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.map_name ?? ""}
|
||||
onChange={(event) => setDraftQuery((current) => ({ ...current, map_name: event.target.value }))}
|
||||
placeholder="mapName 模糊匹配"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-500">作物</Label>
|
||||
<Select
|
||||
value={draftQuery.common_crop_name ?? NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, common_crop_name: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部</SelectItem>
|
||||
{cropOptions.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">类型</Label>
|
||||
<Select
|
||||
value={draftQuery.type ?? NONE_SELECT_VALUE}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, type: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部</SelectItem>
|
||||
{mapTypeOptions.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, cropOptions]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={Map}
|
||||
iconBg="bg-gradient-to-br from-teal-500 to-cyan-600"
|
||||
title="GenomeMap 遗传图谱"
|
||||
description="维护遗传图谱主数据;可切换 Linkage Group Tab 跨图谱维护,或进入详情维护 Marker Position。"
|
||||
addLabel="新增图谱"
|
||||
columns={[
|
||||
{
|
||||
key: "map_name",
|
||||
label: "名称",
|
||||
render: (value, row) => {
|
||||
const id = String(row.id ?? row.mapDbId ?? "");
|
||||
const name = String(value ?? "—");
|
||||
if (!id) return name;
|
||||
return (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(id)}`}
|
||||
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "common_crop_name", label: "作物" },
|
||||
{ key: "type", label: "类型" },
|
||||
{ key: "unit", label: "单位" },
|
||||
{
|
||||
key: "linkage_group_count",
|
||||
label: "连锁群",
|
||||
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "marker_count",
|
||||
label: "Marker 数",
|
||||
render: (value) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[{
|
||||
label: "/brapi/v2/maps",
|
||||
value: "BrAPI",
|
||||
className: "bg-teal-50 text-teal-700 dark:bg-teal-400/10 dark:text-teal-200",
|
||||
}]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={async (id) => {
|
||||
const { rows } = await loadGenomeMapPageData({ force: true });
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new Error("图谱不存在");
|
||||
return normalizeGenomeMapFormData(row);
|
||||
}}
|
||||
createRecord={(payload) => createGenomeMapRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateGenomeMapRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
deleteRecord={deleteGenomeMapRow}
|
||||
renderQueryForm={renderQueryForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
createLinkageGroupRow,
|
||||
createLinkageGroupRowFromPayload,
|
||||
deleteLinkageGroupRow,
|
||||
fetchLinkageGroupRows,
|
||||
loadLinkageGroupPageData,
|
||||
normalizeLinkageGroupFormData,
|
||||
updateLinkageGroupRow,
|
||||
} from "../api";
|
||||
import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types";
|
||||
|
||||
const optionOrNone = (label: string, options: SelectOption[]) => [
|
||||
{ value: NONE_SELECT_VALUE, label },
|
||||
...options,
|
||||
];
|
||||
|
||||
interface LinkageGroupEntityPageProps {
|
||||
/** 详情页固定所属图谱;入口 Tab 不传则跨图谱列表维护 */
|
||||
mapDbId?: string;
|
||||
mapOptions?: SelectOption[];
|
||||
linkageGroupQuery?: LinkageGroupQuery;
|
||||
onChanged?: () => void;
|
||||
defaultFormValues?: Record<string, unknown>;
|
||||
renderQueryForm?: () => ReactNode;
|
||||
}
|
||||
|
||||
export function LinkageGroupEntityPage({
|
||||
mapDbId,
|
||||
mapOptions = [],
|
||||
linkageGroupQuery,
|
||||
onChanged,
|
||||
defaultFormValues,
|
||||
renderQueryForm,
|
||||
}: LinkageGroupEntityPageProps) {
|
||||
const scopedToMap = Boolean(mapDbId);
|
||||
const includeMapInForm = !scopedToMap;
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
if (scopedToMap && mapDbId) {
|
||||
const rows = await fetchLinkageGroupRows(mapDbId);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}
|
||||
const { rows } = await loadLinkageGroupPageData({ query: linkageGroupQuery });
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [scopedToMap, mapDbId, linkageGroupQuery]);
|
||||
|
||||
const resolveMapDbId = useCallback((payload: Record<string, unknown>, row?: Record<string, unknown>) => {
|
||||
if (mapDbId) return mapDbId;
|
||||
const fromPayload = String(payload.map_db_id ?? "").trim();
|
||||
if (fromPayload && fromPayload !== NONE_SELECT_VALUE) return fromPayload;
|
||||
return String(row?.map_db_id ?? row?.mapDbId ?? "").trim();
|
||||
}, [mapDbId]);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => {
|
||||
const base: BrapiFormField[] = [];
|
||||
if (includeMapInForm) {
|
||||
base.push({
|
||||
key: "map_db_id",
|
||||
label: "所属图谱",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: optionOrNone("请选择图谱", mapOptions),
|
||||
});
|
||||
}
|
||||
base.push(
|
||||
{ key: "linkage_group_name", label: "连锁群名称", type: "text", required: true, placeholder: "如 Chr01" },
|
||||
{ key: "max_position", label: "最大位置", type: "number", placeholder: "可选,非负整数" },
|
||||
);
|
||||
return base;
|
||||
}, [includeMapInForm, mapOptions]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols = [
|
||||
{
|
||||
key: "linkage_group_name",
|
||||
label: "名称",
|
||||
render: (value: unknown, row: Record<string, unknown>) => {
|
||||
const name = String(value ?? "—");
|
||||
const targetMapId = resolveMapDbId({}, row);
|
||||
if (!targetMapId) return name;
|
||||
return (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}/linkage-groups/${encodeURIComponent(String(row.id ?? ""))}`}
|
||||
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
if (!scopedToMap) {
|
||||
cols.push({
|
||||
key: "map_name",
|
||||
label: "所属图谱",
|
||||
render: (value: unknown, row: Record<string, unknown>) => {
|
||||
const targetMapId = resolveMapDbId({}, row);
|
||||
const label = String(value ?? row.map_name ?? "—");
|
||||
if (!targetMapId) return label;
|
||||
return (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}`}
|
||||
className="text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
cols.push(
|
||||
{ key: "max_position", label: "最大位置", render: (value: unknown) => String(value ?? "—") },
|
||||
{
|
||||
key: "marker_count",
|
||||
label: "Marker 数",
|
||||
render: (value: unknown) => <Badge variant="outline">{Number(value ?? 0)}</Badge>,
|
||||
},
|
||||
);
|
||||
return cols;
|
||||
}, [scopedToMap, resolveMapDbId]);
|
||||
|
||||
const wrapMutation = useCallback(<T, >(action: () => Promise<T>) => async () => {
|
||||
const result = await action();
|
||||
onChanged?.();
|
||||
return result;
|
||||
}, [onChanged]);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={GitBranch}
|
||||
iconBg="bg-gradient-to-br from-teal-500 to-emerald-600"
|
||||
title={scopedToMap ? "连锁群 Linkage Group" : "Linkage Group 连锁群"}
|
||||
description={
|
||||
scopedToMap
|
||||
? "该图谱下的连锁群;删除前请确认无 Marker Position 引用。"
|
||||
: "跨图谱维护连锁群;新增时需选择所属 GenomeMap,也可在图谱详情中维护。"
|
||||
}
|
||||
addLabel="新增连锁群"
|
||||
defaultFormValues={defaultFormValues}
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
stats={[{
|
||||
label: scopedToMap ? `/maps/${mapDbId}/linkagegroups` : "聚合 /maps/*/linkagegroups",
|
||||
value: "BrAPI",
|
||||
className: "bg-emerald-50 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-200",
|
||||
}]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={async (id) => {
|
||||
if (scopedToMap && mapDbId) {
|
||||
const rows = await fetchLinkageGroupRows(mapDbId);
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new Error("连锁群不存在");
|
||||
return normalizeLinkageGroupFormData(row);
|
||||
}
|
||||
const { rows } = await loadLinkageGroupPageData({ force: true });
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new Error("连锁群不存在");
|
||||
return normalizeLinkageGroupFormData(row, true);
|
||||
}}
|
||||
createRecord={(payload) => {
|
||||
if (scopedToMap && mapDbId) {
|
||||
return wrapMutation(() => createLinkageGroupRow(mapDbId, payload))() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
return wrapMutation(() => createLinkageGroupRowFromPayload(payload))() as Promise<Record<string, unknown>>;
|
||||
}}
|
||||
updateRecord={async (id, payload) => {
|
||||
const targetMapId = resolveMapDbId(payload);
|
||||
if (!targetMapId) throw new Error("无法确定所属图谱");
|
||||
return wrapMutation(() => updateLinkageGroupRow(targetMapId, id, payload))() as Promise<Record<string, unknown>>;
|
||||
}}
|
||||
deleteRecord={async (id) => {
|
||||
let targetMapId = mapDbId;
|
||||
if (!targetMapId) {
|
||||
const { rows } = await loadLinkageGroupPageData({ force: true });
|
||||
const row = rows.find((item) => item.id === id);
|
||||
targetMapId = row?.map_db_id || undefined;
|
||||
}
|
||||
if (!targetMapId) throw new Error("无法确定所属图谱");
|
||||
await wrapMutation(() => deleteLinkageGroupRow(targetMapId, id))();
|
||||
}}
|
||||
renderQueryForm={renderQueryForm}
|
||||
renderFormExtra={({ editingRow }) => (
|
||||
<>
|
||||
<div className="md:col-span-2 space-y-1.5">
|
||||
<Label className="text-sm text-slate-700 dark:text-slate-200">LinkageGroup ID</Label>
|
||||
{editingRow ? (
|
||||
<Input
|
||||
value={String(editingRow.id ?? "")}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-slate-50 dark:bg-slate-900"
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
保存后由系统自动生成
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">数据库主键,新增与修改时均不可编辑。</p>
|
||||
</div>
|
||||
<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 className="mt-1 text-emerald-800/80 dark:text-emerald-200/80">
|
||||
同一图谱下连锁群名称不可重复;删除前需无 Marker Position 引用。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage";
|
||||
|
||||
interface LinkageGroupPanelProps {
|
||||
mapDbId: string;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export function LinkageGroupPanel({ mapDbId, onChanged }: LinkageGroupPanelProps) {
|
||||
return <LinkageGroupEntityPage mapDbId={mapDbId} onChanged={onChanged} />;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { RotateCcw, Search } from "lucide-react";
|
||||
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 { loadLinkageGroupPageData } from "../api";
|
||||
import { LinkageGroupEntityPage } from "./LinkageGroupEntityPage";
|
||||
import { NONE_SELECT_VALUE, type LinkageGroupQuery, type SelectOption } from "../types";
|
||||
|
||||
const emptyQuery = (): LinkageGroupQuery => ({
|
||||
linkage_group_name: "",
|
||||
map_db_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
|
||||
function toSelectValue(value: string | null | undefined) {
|
||||
return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE;
|
||||
}
|
||||
|
||||
export function LinkageGroupTab() {
|
||||
const searchParams = useSearchParams();
|
||||
const [mapOptions, setMapOptions] = useState<SelectOption[]>([]);
|
||||
const [draftQuery, setDraftQuery] = useState<LinkageGroupQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
|
||||
}));
|
||||
const [appliedQuery, setAppliedQuery] = useState<LinkageGroupQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
|
||||
}));
|
||||
|
||||
const urlDefaultFormValues = useMemo(() => {
|
||||
const mapDbId = searchParams.get("map_db_id");
|
||||
return mapDbId ? { map_db_id: mapDbId } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
loadLinkageGroupPageData().then(({ options }) => {
|
||||
if (!mounted) return;
|
||||
setMapOptions(options.maps);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
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">连锁群名称</Label>
|
||||
<Input
|
||||
value={draftQuery.linkage_group_name ?? ""}
|
||||
onChange={(event) => setDraftQuery((current) => ({
|
||||
...current,
|
||||
linkage_group_name: event.target.value,
|
||||
}))}
|
||||
placeholder="名称模糊匹配"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-500">所属图谱</Label>
|
||||
<Select
|
||||
value={toSelectValue(draftQuery.map_db_id)}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, map_db_id: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部图谱" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部图谱</SelectItem>
|
||||
{mapOptions.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, mapOptions]);
|
||||
|
||||
return (
|
||||
<LinkageGroupEntityPage
|
||||
mapOptions={mapOptions}
|
||||
linkageGroupQuery={appliedQuery}
|
||||
defaultFormValues={urlDefaultFormValues}
|
||||
renderQueryForm={renderQueryForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import {
|
||||
createMarkerPositionRow,
|
||||
deleteMarkerPositionRow,
|
||||
fetchAllLinkageGroupFormOptions,
|
||||
fetchLinkageGroupOptions,
|
||||
fetchMarkerPositionRowsByQuery,
|
||||
fetchVariantOptions,
|
||||
normalizeMarkerPositionFormData,
|
||||
updateMarkerPositionRow,
|
||||
} from "../api";
|
||||
import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types";
|
||||
|
||||
interface MarkerPositionEntityPageProps {
|
||||
/** 详情页固定所属图谱 */
|
||||
mapDbId?: string;
|
||||
/** 连锁群详情页固定所属连锁群 */
|
||||
linkageGroupDbId?: string;
|
||||
linkageGroupName?: string | null;
|
||||
markerPositionQuery?: MarkerPositionQuery;
|
||||
onChanged?: () => void;
|
||||
defaultFormValues?: Record<string, unknown>;
|
||||
renderQueryForm?: () => ReactNode;
|
||||
}
|
||||
|
||||
export function MarkerPositionEntityPage({
|
||||
mapDbId,
|
||||
linkageGroupDbId,
|
||||
linkageGroupName,
|
||||
markerPositionQuery,
|
||||
onChanged,
|
||||
defaultFormValues,
|
||||
renderQueryForm,
|
||||
}: MarkerPositionEntityPageProps) {
|
||||
const scopedToMap = Boolean(mapDbId);
|
||||
const scopedToLinkageGroup = Boolean(linkageGroupDbId);
|
||||
const [linkageGroupOptions, setLinkageGroupOptions] = useState<SelectOption[]>([]);
|
||||
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const linkagePromise = mapDbId
|
||||
? fetchLinkageGroupOptions(mapDbId)
|
||||
: fetchAllLinkageGroupFormOptions();
|
||||
Promise.all([linkagePromise, fetchVariantOptions()])
|
||||
.then(([linkageGroups, variants]) => {
|
||||
if (!mounted) return;
|
||||
setLinkageGroupOptions(linkageGroups);
|
||||
setVariantOptions(variants);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setLinkageGroupOptions([]);
|
||||
setVariantOptions([]);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [mapDbId]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
const query = scopedToMap && mapDbId
|
||||
? { map_db_id: mapDbId }
|
||||
: markerPositionQuery;
|
||||
const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "MarkerPosition ID", type: "text", placeholder: "留空则系统自动生成" },
|
||||
{
|
||||
key: "linkage_group_id",
|
||||
label: "连锁群",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: NONE_SELECT_VALUE, label: "请选择连锁群" },
|
||||
...linkageGroupOptions,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "variant_id",
|
||||
label: "Variant",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: NONE_SELECT_VALUE, label: "请选择 Variant" },
|
||||
...variantOptions,
|
||||
],
|
||||
},
|
||||
{ key: "position", label: "图谱位置", type: "number", required: true, placeholder: "非负整数" },
|
||||
], [linkageGroupOptions, variantOptions]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols = [];
|
||||
if (!scopedToMap) {
|
||||
cols.push({
|
||||
key: "map_name",
|
||||
label: "所属图谱",
|
||||
render: (value: unknown, row: Record<string, unknown>) => {
|
||||
const id = String(row.map_db_id ?? row.mapDbId ?? "");
|
||||
const label = String(value ?? row.map_name ?? "—");
|
||||
if (!id) return label;
|
||||
return (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(id)}`}
|
||||
className="text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
cols.push(
|
||||
{
|
||||
key: "linkage_group_name",
|
||||
label: "连锁群",
|
||||
render: (value: unknown, row: Record<string, unknown>) => {
|
||||
const name = String(value ?? "—");
|
||||
const lgId = String(row.linkage_group_db_id ?? row.linkageGroupDbId ?? "");
|
||||
const targetMapId = mapDbId || String(row.map_db_id ?? row.mapDbId ?? "");
|
||||
if (!lgId || !targetMapId) return name;
|
||||
return (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(targetMapId)}/linkage-groups/${encodeURIComponent(lgId)}`}
|
||||
className="font-medium text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "variant_name",
|
||||
label: "Variant",
|
||||
render: (value: unknown, row: Record<string, unknown>) => {
|
||||
const id = String(row.variant_db_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: "position", label: "位置" },
|
||||
);
|
||||
return cols;
|
||||
}, [scopedToMap, mapDbId]);
|
||||
|
||||
const resolvedDefaultFormValues = useMemo(() => ({
|
||||
...defaultFormValues,
|
||||
...(linkageGroupDbId ? { linkage_group_id: linkageGroupDbId } : {}),
|
||||
}), [defaultFormValues, linkageGroupDbId]);
|
||||
|
||||
const wrapMutation = useCallback(<T, >(action: () => Promise<T>) => async () => {
|
||||
const result = await action();
|
||||
onChanged?.();
|
||||
return result;
|
||||
}, [onChanged]);
|
||||
|
||||
const findRowById = useCallback(async (id: string) => {
|
||||
const query = scopedToMap && mapDbId
|
||||
? { map_db_id: mapDbId }
|
||||
: markerPositionQuery;
|
||||
const rows = await fetchMarkerPositionRowsByQuery(query, linkageGroupDbId);
|
||||
return rows.find((item) => item.id === id);
|
||||
}, [scopedToMap, mapDbId, linkageGroupDbId, markerPositionQuery]);
|
||||
|
||||
const title = scopedToLinkageGroup && linkageGroupName
|
||||
? `Marker Position · ${linkageGroupName}`
|
||||
: scopedToMap
|
||||
? "Marker Position"
|
||||
: "Marker Position 列表";
|
||||
|
||||
const description = scopedToLinkageGroup
|
||||
? "在该连锁群下维护 Variant 图谱位置;同一 Variant 不可重复。"
|
||||
: scopedToMap
|
||||
? "将 Variant 定位到连锁群坐标;同一连锁群下同一 Variant 不可重复。"
|
||||
: "按图谱、连锁群、Variant 查询并维护 marker_position。";
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={MapPin}
|
||||
iconBg="bg-gradient-to-br from-cyan-500 to-blue-600"
|
||||
title={title}
|
||||
description={description}
|
||||
addLabel="新增 Marker Position"
|
||||
defaultFormValues={resolvedDefaultFormValues}
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={async (id) => {
|
||||
const row = await findRowById(id);
|
||||
if (!row) throw new Error("Marker Position 不存在");
|
||||
return normalizeMarkerPositionFormData(row);
|
||||
}}
|
||||
createRecord={(payload) => wrapMutation(() => createMarkerPositionRow(payload))() as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => wrapMutation(() => updateMarkerPositionRow(id, payload))() as Promise<Record<string, unknown>>}
|
||||
deleteRecord={(id) => wrapMutation(() => deleteMarkerPositionRow(id))().then(() => undefined)}
|
||||
renderQueryForm={renderQueryForm}
|
||||
renderFormExtra={() => (
|
||||
<div className="col-span-2 rounded-lg border border-cyan-100 bg-cyan-50/60 p-3 text-xs text-cyan-900 dark:border-cyan-900/40 dark:bg-cyan-950/30 dark:text-cyan-100">
|
||||
<p className="font-medium">校验说明</p>
|
||||
<p className="mt-1 text-cyan-800/80 dark:text-cyan-200/80">
|
||||
position 不应超过所属连锁群的 max_position(若已设置);单位应与图谱 unit 一致。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage";
|
||||
|
||||
interface MarkerPositionPanelProps {
|
||||
mapDbId: string;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export function MarkerPositionPanel({ mapDbId, onChanged }: MarkerPositionPanelProps) {
|
||||
return <MarkerPositionEntityPage mapDbId={mapDbId} onChanged={onChanged} />;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { RotateCcw, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { loadMarkerPositionFilterOptions } from "../api";
|
||||
import { MarkerPositionEntityPage } from "./MarkerPositionEntityPage";
|
||||
import { NONE_SELECT_VALUE, type MarkerPositionQuery, type SelectOption } from "../types";
|
||||
|
||||
const emptyQuery = (): MarkerPositionQuery => ({
|
||||
map_db_id: NONE_SELECT_VALUE,
|
||||
linkage_group_name: NONE_SELECT_VALUE,
|
||||
variant_db_id: NONE_SELECT_VALUE,
|
||||
});
|
||||
|
||||
function toSelectValue(value: string | null | undefined) {
|
||||
return value && value !== NONE_SELECT_VALUE ? value : NONE_SELECT_VALUE;
|
||||
}
|
||||
|
||||
export function MarkerPositionTab() {
|
||||
const searchParams = useSearchParams();
|
||||
const [mapOptions, setMapOptions] = useState<SelectOption[]>([]);
|
||||
const [linkageGroupOptions, setLinkageGroupOptions] = useState<SelectOption[]>([]);
|
||||
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
|
||||
const [draftQuery, setDraftQuery] = useState<MarkerPositionQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
|
||||
linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE,
|
||||
variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE,
|
||||
}));
|
||||
const [appliedQuery, setAppliedQuery] = useState<MarkerPositionQuery>(() => ({
|
||||
...emptyQuery(),
|
||||
map_db_id: searchParams.get("map_db_id") ?? NONE_SELECT_VALUE,
|
||||
linkage_group_name: searchParams.get("linkage_group_name") ?? NONE_SELECT_VALUE,
|
||||
variant_db_id: searchParams.get("variant_db_id") ?? NONE_SELECT_VALUE,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
loadMarkerPositionFilterOptions()
|
||||
.then((options) => {
|
||||
if (!mounted) return;
|
||||
setMapOptions(options.maps);
|
||||
setLinkageGroupOptions(options.linkageGroups);
|
||||
setVariantOptions(options.variants);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
setMapOptions([]);
|
||||
setLinkageGroupOptions([]);
|
||||
setVariantOptions([]);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const mapDbId = toSelectValue(draftQuery.map_db_id);
|
||||
loadMarkerPositionFilterOptions({
|
||||
mapDbId: mapDbId !== NONE_SELECT_VALUE ? mapDbId : undefined,
|
||||
}).then((options) => {
|
||||
if (!mounted) return;
|
||||
setLinkageGroupOptions(options.linkageGroups);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [draftQuery.map_db_id]);
|
||||
|
||||
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-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-slate-500">所属图谱</Label>
|
||||
<Select
|
||||
value={toSelectValue(draftQuery.map_db_id)}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({
|
||||
...current,
|
||||
map_db_id: value,
|
||||
linkage_group_name: NONE_SELECT_VALUE,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部图谱" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部图谱</SelectItem>
|
||||
{mapOptions.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">连锁群</Label>
|
||||
<Select
|
||||
value={toSelectValue(draftQuery.linkage_group_name)}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, linkage_group_name: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部连锁群" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部连锁群</SelectItem>
|
||||
{linkageGroupOptions.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">Variant</Label>
|
||||
<Select
|
||||
value={toSelectValue(draftQuery.variant_db_id)}
|
||||
onValueChange={(value) => setDraftQuery((current) => ({ ...current, variant_db_id: value }))}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="全部 Variant" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_SELECT_VALUE}>全部 Variant</SelectItem>
|
||||
{variantOptions.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, mapOptions, linkageGroupOptions, variantOptions]);
|
||||
|
||||
const urlDefaultFormValues = useMemo(() => {
|
||||
const variantId = searchParams.get("variant_db_id");
|
||||
return variantId ? { variant_id: variantId } : undefined;
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<MarkerPositionEntityPage
|
||||
markerPositionQuery={appliedQuery}
|
||||
defaultFormValues={urlDefaultFormValues}
|
||||
renderQueryForm={renderQueryForm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function readAdditionalInfoString(
|
||||
item: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | null {
|
||||
const info = item.additionalInfo;
|
||||
if (!info || typeof info !== "object") return null;
|
||||
const value = (info as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function formatPublishedDate(value: unknown): string {
|
||||
if (!value) return "—";
|
||||
const text = String(value);
|
||||
return text.length >= 10 ? text.slice(0, 10) : text;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft, GitBranch, Map } 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 { MarkerPositionEntityPage } from "../../../../components/MarkerPositionEntityPage";
|
||||
import { fetchGenomeMapDetail, fetchLinkageGroupDetail } from "../../../../api";
|
||||
import type { GenomeMapRecord, LinkageGroupRecord } from "../../../../types";
|
||||
|
||||
export default function LinkageGroupDetailPage() {
|
||||
const params = useParams<{ mapDbId: string; linkageGroupDbId: string }>();
|
||||
const mapDbId = decodeURIComponent(params.mapDbId);
|
||||
const linkageGroupDbId = decodeURIComponent(params.linkageGroupDbId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mapDetail, setMapDetail] = useState<GenomeMapRecord | null>(null);
|
||||
const [linkageGroup, setLinkageGroup] = useState<LinkageGroupRecord | null>(null);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
const [map, group] = await Promise.all([
|
||||
fetchGenomeMapDetail(mapDbId),
|
||||
fetchLinkageGroupDetail(mapDbId, linkageGroupDbId),
|
||||
]);
|
||||
if (!group) throw new Error("连锁群不存在");
|
||||
setMapDetail(map);
|
||||
setLinkageGroup(group);
|
||||
}, [mapDbId, linkageGroupDbId]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
loadDetail()
|
||||
.catch((event) => {
|
||||
if (!mounted) return;
|
||||
setError(event instanceof Error ? event.message : "加载连锁群详情失败");
|
||||
})
|
||||
.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 || !linkageGroup || !mapDetail) {
|
||||
return (
|
||||
<div className="rounded-xl border border-destructive/20 bg-destructive/10 px-4 py-6 text-sm text-destructive">
|
||||
{error || "连锁群不存在"}
|
||||
<div className="mt-4">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/genotyping/genome-map?tab=linkage-groups">
|
||||
<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/genome-map/maps/${encodeURIComponent(mapDbId)}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回图谱详情
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<GitBranch className="h-5 w-5 text-emerald-500" />
|
||||
{linkageGroup.linkage_group_name || linkageGroup.id}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div><span className="text-slate-500">LinkageGroup ID:</span>{linkageGroup.id}</div>
|
||||
<div>
|
||||
<span className="text-slate-500">所属图谱:</span>
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(mapDbId)}`}
|
||||
className="text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{mapDetail.map_name || mapDbId}
|
||||
</Link>
|
||||
</div>
|
||||
<div><span className="text-slate-500">最大位置:</span>{linkageGroup.max_position ?? "—"}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">Marker 数:</span>
|
||||
<Badge variant="outline">{linkageGroup.marker_count ?? 0}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/genotyping/genome-map?tab=marker-positions&map_db_id=${encodeURIComponent(mapDbId)}&linkage_group_name=${encodeURIComponent(linkageGroup.linkage_group_name || "")}`}>
|
||||
<Map className="mr-2 h-4 w-4" />
|
||||
在 Marker Position 列表中查看
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<MarkerPositionEntityPage
|
||||
mapDbId={mapDbId}
|
||||
linkageGroupDbId={linkageGroupDbId}
|
||||
linkageGroupName={linkageGroup.linkage_group_name}
|
||||
onChanged={loadDetail}
|
||||
defaultFormValues={{ linkage_group_id: linkageGroupDbId }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft, GitBranch, Map, Sigma } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LinkageGroupPanel } from "../../components/LinkageGroupPanel";
|
||||
import { MarkerPositionPanel } from "../../components/MarkerPositionPanel";
|
||||
import { fetchGenomeMapDetail } from "../../api";
|
||||
import { formatPublishedDate } from "../../genomeMapUtils";
|
||||
import type { GenomeMapDetail } from "../../types";
|
||||
|
||||
export default function GenomeMapDetailPage() {
|
||||
const params = useParams<{ mapDbId: string }>();
|
||||
const mapDbId = decodeURIComponent(params.mapDbId);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<GenomeMapDetail | null>(null);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
const record = await fetchGenomeMapDetail(mapDbId);
|
||||
setDetail(record);
|
||||
}, [mapDbId]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
loadDetail()
|
||||
.catch((event) => {
|
||||
if (!mounted) return;
|
||||
setError(event instanceof Error ? event.message : "加载图谱详情失败");
|
||||
})
|
||||
.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 || "图谱不存在"}
|
||||
<div className="mt-4">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/genotyping/genome-map"><ArrowLeft className="mr-2 h-4 w-4" />返回列表</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDependencies = (detail.linkage_group_count ?? detail.linkageGroups.length) > 0
|
||||
|| (detail.marker_count ?? detail.markerPositions.length) > 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col gap-4">
|
||||
<Button asChild variant="outline" size="sm" className="w-fit">
|
||||
<Link href="/genotyping/genome-map"><ArrowLeft className="mr-2 h-4 w-4" />返回 GenomeMap 列表</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Map className="h-5 w-5 text-teal-500" />
|
||||
{detail.map_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">Map ID:</span>{detail.id}</div>
|
||||
<div><span className="text-slate-500">作物:</span>{detail.common_crop_name || "—"}</div>
|
||||
<div><span className="text-slate-500">类型:</span>{detail.type || "—"}</div>
|
||||
<div><span className="text-slate-500">单位:</span>{detail.unit || "—"}</div>
|
||||
<div><span className="text-slate-500">学名:</span>{detail.scientific_name || "—"}</div>
|
||||
<div><span className="text-slate-500">Map PUI:</span>{detail.map_pui || "—"}</div>
|
||||
<div><span className="text-slate-500">发表日期:</span>{formatPublishedDate(detail.published_date)}</div>
|
||||
<div><span className="text-slate-500">连锁群数:</span>{detail.linkage_group_count ?? detail.linkageGroups.length}</div>
|
||||
<div><span className="text-slate-500">Marker 数:</span>{detail.marker_count ?? detail.markerPositions.length}</div>
|
||||
<div className="sm:col-span-2"><span className="text-slate-500">文档:</span>{detail.documentation_url || "—"}</div>
|
||||
<div className="sm:col-span-2"><span className="text-slate-500">备注:</span>{detail.comments || "—"}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasDependencies ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||||
删除图谱前请先移除下属连锁群与 Marker Position。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/genotyping/genome-map?tab=linkage-groups&map_db_id=${encodeURIComponent(detail.id)}`}>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
在 Linkage Group 列表中查看
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href="/genotyping/variant">
|
||||
<Sigma className="mr-2 h-4 w-4" />
|
||||
前往 Variant 管理
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<LinkageGroupPanel mapDbId={mapDbId} onChanged={loadDetail} />
|
||||
<MarkerPositionPanel mapDbId={mapDbId} onChanged={loadDetail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/app/(app)/genotyping/genome-map/page.tsx
Normal file
71
frontend/src/app/(app)/genotyping/genome-map/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { GitBranch, Map, MapPin } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { GenomeMapTab } from "./components/GenomeMapTab";
|
||||
import { LinkageGroupTab } from "./components/LinkageGroupTab";
|
||||
import { MarkerPositionTab } from "./components/MarkerPositionTab";
|
||||
|
||||
function GenomeMapPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const [tab, setTab] = useState("genome-maps");
|
||||
|
||||
useEffect(() => {
|
||||
const nextTab = searchParams.get("tab");
|
||||
if (nextTab === "linkage-groups" || nextTab === "genome-maps" || nextTab === "marker-positions") {
|
||||
setTab(nextTab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<TabsTrigger value="genome-maps" className="gap-2">
|
||||
<Map className="h-4 w-4" />
|
||||
GenomeMap
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="linkage-groups" className="gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Linkage Group
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="marker-positions" className="gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Marker Position
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{tab === "genome-maps" ? (
|
||||
<TabsContent value="genome-maps" className="mt-0 min-h-0 flex-1">
|
||||
<GenomeMapTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "linkage-groups" ? (
|
||||
<TabsContent value="linkage-groups" className="mt-0 min-h-0 flex-1">
|
||||
<LinkageGroupTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "marker-positions" ? (
|
||||
<TabsContent value="marker-positions" className="mt-0 min-h-0 flex-1">
|
||||
<MarkerPositionTab />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function PageFallback() {
|
||||
return <Skeleton className="h-96 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
export default function GenomeMapPage() {
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<GenomeMapPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
84
frontend/src/app/(app)/genotyping/genome-map/types.ts
Normal file
84
frontend/src/app/(app)/genotyping/genome-map/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
export const NONE_SELECT_VALUE = "__none__";
|
||||
|
||||
export type SelectOption = { value: string; label: string };
|
||||
|
||||
export type GenomeMapQuery = {
|
||||
map_name?: string;
|
||||
common_crop_name?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type LinkageGroupQuery = {
|
||||
linkage_group_name?: string;
|
||||
map_db_id?: string;
|
||||
};
|
||||
|
||||
export type MarkerPositionQuery = {
|
||||
map_db_id?: string;
|
||||
linkage_group_name?: string;
|
||||
variant_db_id?: string;
|
||||
};
|
||||
|
||||
export type GenomeMapRecord = {
|
||||
id: string;
|
||||
mapDbId?: string;
|
||||
map_name?: string | null;
|
||||
mapName?: string | null;
|
||||
map_pui?: string | null;
|
||||
mapPUI?: string | null;
|
||||
common_crop_name?: string | null;
|
||||
commonCropName?: string | null;
|
||||
scientific_name?: string | null;
|
||||
scientificName?: string | null;
|
||||
type?: string | null;
|
||||
unit?: string | null;
|
||||
comments?: string | null;
|
||||
documentation_url?: string | null;
|
||||
documentationURL?: string | null;
|
||||
published_date?: string | null;
|
||||
publishedDate?: string | null;
|
||||
linkage_group_count?: number | null;
|
||||
linkageGroupCount?: number | null;
|
||||
marker_count?: number | null;
|
||||
markerCount?: number | null;
|
||||
};
|
||||
|
||||
export type LinkageGroupRecord = {
|
||||
id: string;
|
||||
linkage_group_db_id?: string;
|
||||
linkageGroupDbId?: string;
|
||||
linkage_group_name?: string | null;
|
||||
linkageGroupName?: string | null;
|
||||
max_position?: number | null;
|
||||
maxPosition?: number | null;
|
||||
marker_count?: number | null;
|
||||
markerCount?: number | null;
|
||||
map_db_id?: string | null;
|
||||
mapDbId?: string | null;
|
||||
map_name?: string | null;
|
||||
mapName?: string | null;
|
||||
};
|
||||
|
||||
export type MarkerPositionRecord = {
|
||||
id: string;
|
||||
marker_position_db_id?: string;
|
||||
markerPositionDbId?: string;
|
||||
linkage_group_db_id?: string;
|
||||
linkageGroupDbId?: string;
|
||||
linkage_group_name?: string | null;
|
||||
linkageGroupName?: string | null;
|
||||
variant_db_id?: string | null;
|
||||
variantDbId?: string | null;
|
||||
variant_name?: string | null;
|
||||
variantName?: string | null;
|
||||
position?: number | null;
|
||||
map_db_id?: string | null;
|
||||
mapDbId?: string | null;
|
||||
map_name?: string | null;
|
||||
mapName?: string | null;
|
||||
};
|
||||
|
||||
export type GenomeMapDetail = GenomeMapRecord & {
|
||||
linkageGroups: LinkageGroupRecord[];
|
||||
markerPositions: MarkerPositionRecord[];
|
||||
};
|
||||
@@ -3,7 +3,9 @@ import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type SelectOption,
|
||||
type VariantSetAnalysisItem,
|
||||
type VariantSetDetail,
|
||||
type VariantSetFormatItem,
|
||||
type VariantSetQuery,
|
||||
type VariantSetRecord,
|
||||
} from "./types";
|
||||
@@ -273,6 +275,184 @@ export async function deleteVariantSetRow(id: string): Promise<void> {
|
||||
invalidateVariantSetPageCache();
|
||||
}
|
||||
|
||||
const mapAnalysisRow = (item: VariantSetAnalysisItem): VariantSetAnalysisItem => ({
|
||||
...item,
|
||||
id: item.analysisDbId || item.id,
|
||||
analysisName: item.analysisName ?? null,
|
||||
type: item.type ?? null,
|
||||
description: item.description ?? null,
|
||||
software: Array.isArray(item.software)
|
||||
? item.software
|
||||
: (item.software ? [String(item.software)] : []),
|
||||
created: item.created ?? null,
|
||||
updated: item.updated ?? null,
|
||||
});
|
||||
|
||||
const mapFormatRow = (item: VariantSetFormatItem): VariantSetFormatItem => ({
|
||||
...item,
|
||||
id: item.formatDbId || item.id,
|
||||
dataFormat: item.dataFormat ?? item.data_format ?? null,
|
||||
fileFormat: item.fileFormat ?? item.file_format ?? null,
|
||||
fileURL: item.fileURL ?? item.fileurl ?? null,
|
||||
expandHomozygotes: item.expandHomozygotes ?? item.expand_homozygotes ?? null,
|
||||
sepPhased: item.sepPhased ?? item.sep_phased ?? null,
|
||||
sepUnphased: item.sepUnphased ?? item.sep_unphased ?? null,
|
||||
unknownString: item.unknownString ?? item.unknown_string ?? null,
|
||||
});
|
||||
|
||||
const parseSoftwareLines = (value: unknown) => String(value ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isValidUrl = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const analysisBody = (payload: Partial<Record<string, unknown>>) => {
|
||||
const analysisName = requiredText(payload.analysis_name, "分析名称不能为空");
|
||||
const software = parseSoftwareLines(payload.software_text);
|
||||
for (const item of software) {
|
||||
if (item.startsWith("http") && !isValidUrl(item)) {
|
||||
throw new Error(`Software URL 格式无效:${item}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
analysisDbId: optionalText(payload.id),
|
||||
analysisName,
|
||||
description: optionalText(payload.description),
|
||||
type: optionalText(payload.type === NONE_SELECT_VALUE ? null : payload.type),
|
||||
created: optionalText(payload.created),
|
||||
updated: optionalText(payload.updated),
|
||||
software,
|
||||
};
|
||||
};
|
||||
|
||||
const formatBody = (payload: Partial<Record<string, unknown>>) => {
|
||||
const dataFormat = requiredText(payload.data_format, "请选择 data_format");
|
||||
const fileFormat = requiredText(payload.file_format, "请选择 file_format");
|
||||
const fileURL = optionalText(payload.fileurl);
|
||||
if (fileURL && !isValidUrl(fileURL)) {
|
||||
throw new Error("fileurl 格式无效,请输入 http(s) URL");
|
||||
}
|
||||
return {
|
||||
formatDbId: optionalText(payload.id),
|
||||
dataFormat,
|
||||
fileFormat,
|
||||
fileURL,
|
||||
expandHomozygotes: payload.expand_homozygotes === true || payload.expand_homozygotes === "true",
|
||||
sepPhased: optionalText(payload.sep_phased),
|
||||
sepUnphased: optionalText(payload.sep_unphased),
|
||||
unknownString: optionalText(payload.unknown_string),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeVariantSetAnalysisFormData = (row: VariantSetAnalysisItem) => ({
|
||||
id: row.id || row.analysisDbId || "",
|
||||
analysis_name: row.analysisName || "",
|
||||
type: row.type ? row.type : NONE_SELECT_VALUE,
|
||||
description: row.description || "",
|
||||
software_text: (Array.isArray(row.software) ? row.software : []).join("\n"),
|
||||
created: row.created ? String(row.created).slice(0, 19) : "",
|
||||
updated: row.updated ? String(row.updated).slice(0, 19) : "",
|
||||
});
|
||||
|
||||
export const normalizeVariantSetFormatFormData = (row: VariantSetFormatItem) => ({
|
||||
id: row.id || row.formatDbId || "",
|
||||
data_format: row.dataFormat || NONE_SELECT_VALUE,
|
||||
file_format: row.fileFormat || NONE_SELECT_VALUE,
|
||||
fileurl: row.fileURL || "",
|
||||
expand_homozygotes: row.expandHomozygotes === true,
|
||||
sep_phased: row.sepPhased || "",
|
||||
sep_unphased: row.sepUnphased || "",
|
||||
unknown_string: row.unknownString || "",
|
||||
});
|
||||
|
||||
export async function fetchVariantSetAnalysisRows(variantSetDbId: string): Promise<VariantSetAnalysisItem[]> {
|
||||
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`,
|
||||
);
|
||||
return response.result.data.map(mapAnalysisRow);
|
||||
}
|
||||
|
||||
export async function fetchVariantSetFormatRows(variantSetDbId: string): Promise<VariantSetFormatItem[]> {
|
||||
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`,
|
||||
);
|
||||
return response.result.data.map(mapFormatRow);
|
||||
}
|
||||
|
||||
export async function createVariantSetAnalysisRow(
|
||||
variantSetDbId: string,
|
||||
payload: Partial<Record<string, unknown>>,
|
||||
): Promise<VariantSetAnalysisItem> {
|
||||
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis`,
|
||||
{ method: "POST", body: JSON.stringify(analysisBody(payload)) },
|
||||
);
|
||||
invalidateVariantSetPageCache();
|
||||
return mapAnalysisRow(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateVariantSetAnalysisRow(
|
||||
variantSetDbId: string,
|
||||
id: string,
|
||||
payload: Partial<Record<string, unknown>>,
|
||||
): Promise<VariantSetAnalysisItem> {
|
||||
const response = await request<BrapiListResponse<VariantSetAnalysisItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body: JSON.stringify(analysisBody(payload)) },
|
||||
);
|
||||
invalidateVariantSetPageCache();
|
||||
return mapAnalysisRow(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function deleteVariantSetAnalysisRow(variantSetDbId: string, id: string): Promise<void> {
|
||||
await request(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/analysis/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
invalidateVariantSetPageCache();
|
||||
}
|
||||
|
||||
export async function createVariantSetFormatRow(
|
||||
variantSetDbId: string,
|
||||
payload: Partial<Record<string, unknown>>,
|
||||
): Promise<VariantSetFormatItem> {
|
||||
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats`,
|
||||
{ method: "POST", body: JSON.stringify(formatBody(payload)) },
|
||||
);
|
||||
invalidateVariantSetPageCache();
|
||||
return mapFormatRow(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateVariantSetFormatRow(
|
||||
variantSetDbId: string,
|
||||
id: string,
|
||||
payload: Partial<Record<string, unknown>>,
|
||||
): Promise<VariantSetFormatItem> {
|
||||
const response = await request<BrapiListResponse<VariantSetFormatItem>>(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${encodeURIComponent(id)}`,
|
||||
{ method: "PUT", body: JSON.stringify(formatBody(payload)) },
|
||||
);
|
||||
invalidateVariantSetPageCache();
|
||||
return mapFormatRow(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function deleteVariantSetFormatRow(variantSetDbId: string, id: string): Promise<void> {
|
||||
await request(
|
||||
`/brapi/v2/variantsets/${encodeURIComponent(variantSetDbId)}/availableformats/${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([
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
createVariantSetAnalysisRow,
|
||||
deleteVariantSetAnalysisRow,
|
||||
fetchVariantSetAnalysisRows,
|
||||
normalizeVariantSetAnalysisFormData,
|
||||
updateVariantSetAnalysisRow,
|
||||
} from "../api";
|
||||
import { ANALYSIS_TYPE_OPTIONS, NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
interface VariantSetAnalysisPanelProps {
|
||||
variantSetDbId: string;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const optionOrNone = (label: string, options: readonly { value: string; label: string }[]) => [
|
||||
{ value: NONE_SELECT_VALUE, label },
|
||||
...options,
|
||||
];
|
||||
|
||||
export function VariantSetAnalysisPanel({ variantSetDbId, onChanged }: VariantSetAnalysisPanelProps) {
|
||||
const loadRows = useCallback(async () => {
|
||||
const rows = await fetchVariantSetAnalysisRows(variantSetDbId);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [variantSetDbId]);
|
||||
|
||||
const findRowById = useCallback(async (id: string) => {
|
||||
const rows = await fetchVariantSetAnalysisRows(variantSetDbId);
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new Error("Analysis 不存在");
|
||||
return normalizeVariantSetAnalysisFormData(row);
|
||||
}, [variantSetDbId]);
|
||||
|
||||
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
|
||||
const result = await action();
|
||||
onChanged?.();
|
||||
return result;
|
||||
}, [onChanged]);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "Analysis ID", type: "text", placeholder: "留空则系统自动生成" },
|
||||
{ key: "analysis_name", label: "分析名称", type: "text", required: true, placeholder: "如 Standard QC" },
|
||||
{
|
||||
key: "type",
|
||||
label: "分析类型",
|
||||
type: "select",
|
||||
options: optionOrNone("不指定类型", ANALYSIS_TYPE_OPTIONS),
|
||||
},
|
||||
{ key: "description", label: "分析说明", type: "textarea", placeholder: "描述分析流程或 QC 标准" },
|
||||
{ key: "created", label: "创建时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" },
|
||||
{ key: "updated", label: "更新时间", type: "text", placeholder: "2024-01-01T12:00:00+08:00" },
|
||||
], []);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "analysisName", label: "分析名称" },
|
||||
{ key: "type", label: "类型" },
|
||||
{
|
||||
key: "software",
|
||||
label: "Software",
|
||||
render: (value: unknown) => {
|
||||
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
|
||||
return String(value ?? "—");
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
label: "说明",
|
||||
render: (value: unknown) => {
|
||||
const text = String(value ?? "");
|
||||
if (!text) return "—";
|
||||
return text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
||||
},
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={FlaskConical}
|
||||
iconBg="bg-gradient-to-br from-amber-500 to-orange-600"
|
||||
title="Analysis"
|
||||
description="维护 VariantSet 的分析或 QC 信息,每条记录可关联多个 software(名称、版本或 URL)。"
|
||||
addLabel="新增 Analysis"
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={findRowById}
|
||||
createRecord={(payload) => wrapMutation(() => createVariantSetAnalysisRow(variantSetDbId, payload))() as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => wrapMutation(() => updateVariantSetAnalysisRow(variantSetDbId, id, payload))() as Promise<Record<string, unknown>>}
|
||||
deleteRecord={(id) => wrapMutation(() => deleteVariantSetAnalysisRow(variantSetDbId, id))().then(() => undefined)}
|
||||
renderFormExtra={({ formData, updateForm }) => (
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="software_text" className="text-xs text-slate-500">
|
||||
Software 列表(每行一项,可为名称、版本或 URL)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="software_text"
|
||||
value={String(formData.software_text ?? "")}
|
||||
onChange={(event) => updateForm("software_text", event.target.value)}
|
||||
placeholder={"GATK 4.5\nhttps://github.com/genotyping/QC"}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">若填写 URL,请使用 http(s) 格式。</p>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { FileDown } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
createVariantSetFormatRow,
|
||||
deleteVariantSetFormatRow,
|
||||
fetchVariantSetFormatRows,
|
||||
normalizeVariantSetFormatFormData,
|
||||
updateVariantSetFormatRow,
|
||||
} from "../api";
|
||||
import { DATA_FORMAT_OPTIONS, FILE_FORMAT_OPTIONS, NONE_SELECT_VALUE } from "../types";
|
||||
|
||||
interface VariantSetFormatPanelProps {
|
||||
variantSetDbId: string;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const optionOrNone = (label: string, options: readonly { value: string; label: string }[]) => [
|
||||
{ value: NONE_SELECT_VALUE, label },
|
||||
...options,
|
||||
];
|
||||
|
||||
const boolLabel = (value: unknown) => (value === true ? "是" : value === false ? "否" : "—");
|
||||
|
||||
export function VariantSetFormatPanel({ variantSetDbId, onChanged }: VariantSetFormatPanelProps) {
|
||||
const loadRows = useCallback(async () => {
|
||||
const rows = await fetchVariantSetFormatRows(variantSetDbId);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [variantSetDbId]);
|
||||
|
||||
const findRowById = useCallback(async (id: string) => {
|
||||
const rows = await fetchVariantSetFormatRows(variantSetDbId);
|
||||
const row = rows.find((item) => item.id === id);
|
||||
if (!row) throw new Error("Available Format 不存在");
|
||||
return normalizeVariantSetFormatFormData(row);
|
||||
}, [variantSetDbId]);
|
||||
|
||||
const wrapMutation = useCallback(<T,>(action: () => Promise<T>) => async () => {
|
||||
const result = await action();
|
||||
onChanged?.();
|
||||
return result;
|
||||
}, [onChanged]);
|
||||
|
||||
const fields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "Format ID", type: "text", placeholder: "留空则系统自动生成" },
|
||||
{
|
||||
key: "data_format",
|
||||
label: "数据格式 (data_format)",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: optionOrNone("请选择数据格式", DATA_FORMAT_OPTIONS),
|
||||
},
|
||||
{
|
||||
key: "file_format",
|
||||
label: "文件格式 (file_format)",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: optionOrNone("请选择 MIME 类型", FILE_FORMAT_OPTIONS),
|
||||
},
|
||||
{ key: "fileurl", label: "文件 URL (fileurl)", type: "text", placeholder: "https://example.com/data.vcf" },
|
||||
{ key: "sep_phased", label: "Phased 分隔符", type: "text", placeholder: "如 |" },
|
||||
{ key: "sep_unphased", label: "Unphased 分隔符", type: "text", placeholder: "如 /" },
|
||||
{ key: "unknown_string", label: "缺失值字符串", type: "text", placeholder: "如 NA 或 ." },
|
||||
], []);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ key: "dataFormat", label: "数据格式" },
|
||||
{ key: "fileFormat", label: "MIME" },
|
||||
{
|
||||
key: "fileURL",
|
||||
label: "文件 URL",
|
||||
render: (value: unknown) => {
|
||||
const url = String(value ?? "").trim();
|
||||
if (!url) return "—";
|
||||
return (
|
||||
<Link href={url} target="_blank" rel="noopener noreferrer" className="break-all text-violet-600 hover:underline dark:text-violet-400">
|
||||
{url}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "expandHomozygotes",
|
||||
label: "展开纯合",
|
||||
render: (value: unknown) => boolLabel(value),
|
||||
},
|
||||
{ key: "sepPhased", label: "Phased 分隔符" },
|
||||
{ key: "sepUnphased", label: "Unphased 分隔符" },
|
||||
{ key: "unknownString", label: "缺失值" },
|
||||
], []);
|
||||
|
||||
return (
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={FileDown}
|
||||
iconBg="bg-gradient-to-br from-sky-500 to-blue-600"
|
||||
title="Available Formats"
|
||||
description="配置 VariantSet 可下载的数据格式与文件地址;矩阵格式可设置分隔符与缺失值字符串。"
|
||||
addLabel="新增 Format"
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
data={[]}
|
||||
loadData={loadRows}
|
||||
fetchRecord={findRowById}
|
||||
createRecord={(payload) => wrapMutation(() => createVariantSetFormatRow(variantSetDbId, payload))() as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => wrapMutation(() => updateVariantSetFormatRow(variantSetDbId, id, payload))() as Promise<Record<string, unknown>>}
|
||||
deleteRecord={(id) => wrapMutation(() => deleteVariantSetFormatRow(variantSetDbId, id))().then(() => undefined)}
|
||||
renderFormExtra={({ formData, updateFormBatch }) => (
|
||||
<div className="col-span-2 flex items-center justify-between rounded-lg border border-sky-100 bg-sky-50/60 p-3 dark:border-sky-900/40 dark:bg-sky-950/30">
|
||||
<div>
|
||||
<Label htmlFor="expand_homozygotes" className="text-sm font-medium text-sky-900 dark:text-sky-100">
|
||||
展开纯合位点 (expand_homozygotes)
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-sky-800/80 dark:text-sky-200/80">
|
||||
矩阵格式导入时会按此设置解析 phased / unphased 与缺失值。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="expand_homozygotes"
|
||||
checked={formData.expand_homozygotes === true}
|
||||
onCheckedChange={(checked) => updateFormBatch({ expand_homozygotes: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,17 +11,60 @@ export interface VariantSetQuery {
|
||||
study_id?: string;
|
||||
}
|
||||
|
||||
export const DATA_FORMAT_OPTIONS = [
|
||||
{ value: "DartSeq", label: "DartSeq" },
|
||||
{ value: "VCF", label: "VCF" },
|
||||
{ value: "Hapmap", label: "Hapmap" },
|
||||
{ value: "tabular", label: "tabular" },
|
||||
{ value: "JSON", label: "JSON" },
|
||||
] as const;
|
||||
|
||||
export const FILE_FORMAT_OPTIONS = [
|
||||
{ value: "application/json", label: "application/json" },
|
||||
{ value: "text/csv", label: "text/csv" },
|
||||
{ value: "text/tsv", label: "text/tsv" },
|
||||
{ value: "application/flapjack", label: "application/flapjack" },
|
||||
{ value: "application/excel", label: "application/excel" },
|
||||
{ value: "application/zip", label: "application/zip" },
|
||||
] as const;
|
||||
|
||||
export const ANALYSIS_TYPE_OPTIONS = [
|
||||
{ value: "QC", label: "QC" },
|
||||
{ value: "Annotation", label: "Annotation" },
|
||||
{ value: "Filtering", label: "Filtering" },
|
||||
{ value: "Imputation", label: "Imputation" },
|
||||
] as const;
|
||||
|
||||
export interface VariantSetAnalysisItem {
|
||||
id?: string;
|
||||
analysisDbId?: string;
|
||||
analysisName?: string | null;
|
||||
type?: string | null;
|
||||
software?: string | null;
|
||||
description?: string | null;
|
||||
software?: string[] | string | null;
|
||||
created?: string | null;
|
||||
updated?: string | null;
|
||||
}
|
||||
|
||||
export interface VariantSetFormatItem {
|
||||
id?: string;
|
||||
formatDbId?: string;
|
||||
variant_set_id?: string;
|
||||
variantSetDbId?: string;
|
||||
dataFormat?: string | null;
|
||||
data_format?: string | null;
|
||||
fileFormat?: string | null;
|
||||
file_format?: string | null;
|
||||
fileURL?: string | null;
|
||||
fileurl?: string | null;
|
||||
expandHomozygotes?: boolean | null;
|
||||
expand_homozygotes?: boolean | null;
|
||||
sepPhased?: string | null;
|
||||
sep_phased?: string | null;
|
||||
sepUnphased?: string | null;
|
||||
sep_unphased?: string | null;
|
||||
unknownString?: string | null;
|
||||
unknown_string?: string | null;
|
||||
}
|
||||
|
||||
export interface VariantSetRecord {
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
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 { ArrowLeft, Binary, FileDown, FlaskConical, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VariantSetAnalysisPanel } from "../../components/VariantSetAnalysisPanel";
|
||||
import { VariantSetFormatPanel } from "../../components/VariantSetFormatPanel";
|
||||
import {
|
||||
fetchVariantSetCallsets,
|
||||
fetchVariantSetDetail,
|
||||
@@ -105,129 +108,120 @@ export default function VariantSetDetailPage() {
|
||||
<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>
|
||||
<div><span className="text-slate-500">Analysis 数:</span>{detail.analysis_count ?? 0}</div>
|
||||
<div><span className="text-slate-500">Formats 数:</span>{detail.format_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>
|
||||
<Tabs defaultValue="overview" className="space-y-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">
|
||||
<TabsTrigger value="overview" className="gap-2">
|
||||
<Sigma className="h-4 w-4" />
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analysis" className="gap-2">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
Analysis
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="formats" className="gap-2">
|
||||
<FileDown className="h-4 w-4" />
|
||||
Formats
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{variants.length > 20 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">仅展示前 20 条,完整列表请前往 Variant 管理页筛选。</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TabsContent value="analysis">
|
||||
<VariantSetAnalysisPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
<TabsContent value="formats">
|
||||
<VariantSetFormatPanel variantSetDbId={variantSetDbId} onChanged={loadDetail} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createCachedLoader } from "@/services/dropdownCache";
|
||||
import { DEFAULT_LIST_PAGE, DEFAULT_LIST_PAGE_SIZE } from "@/constants/api";
|
||||
import { getAuthToken } from "@/utils/token";
|
||||
import {
|
||||
NONE_SELECT_VALUE,
|
||||
type CallRecord,
|
||||
type SelectOption,
|
||||
type VariantQuery,
|
||||
type VariantRecord,
|
||||
@@ -46,13 +46,6 @@ interface VariantSetResponse {
|
||||
referenceSetDbId: string | null;
|
||||
}
|
||||
|
||||
interface CallSetResponse {
|
||||
callSetDbId: string;
|
||||
callSetName: string | null;
|
||||
sampleDbId: string | null;
|
||||
sampleName: string | null;
|
||||
}
|
||||
|
||||
type VariantPayload = Partial<Record<
|
||||
| "id"
|
||||
| "variant_name"
|
||||
@@ -68,17 +61,6 @@ type VariantPayload = Partial<Record<
|
||||
unknown
|
||||
>>;
|
||||
|
||||
type CallPayload = Partial<Record<
|
||||
| "id"
|
||||
| "call_set_id"
|
||||
| "variant_id"
|
||||
| "genotype_text"
|
||||
| "genotype_likelihood"
|
||||
| "read_depth"
|
||||
| "phase_set",
|
||||
unknown
|
||||
>>;
|
||||
|
||||
const apiBase = () => {
|
||||
if (typeof window !== "undefined") return "";
|
||||
return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
|
||||
@@ -127,16 +109,6 @@ const optionalBoolean = (value: unknown) => {
|
||||
return ["true", "1", "pass", "passed"].includes(normalized.toLowerCase());
|
||||
};
|
||||
|
||||
const genotypeToText = (value: unknown) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object" && "values" in value) {
|
||||
const values = (value as { values?: unknown[] }).values;
|
||||
return Array.isArray(values) ? values.map((item) => String(item)).join("/") : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const mapVariant = (variant: VariantRecord): VariantRecord => ({
|
||||
...variant,
|
||||
id: variant.variantDbId || variant.variantId || variant.id,
|
||||
@@ -161,19 +133,6 @@ export const mapVariant = (variant: VariantRecord): VariantRecord => ({
|
||||
filters_passed: variant.filters_passed ?? variant.filtersPassed ?? null,
|
||||
});
|
||||
|
||||
const mapCall = (call: CallRecord): CallRecord => ({
|
||||
...call,
|
||||
id: call.callDbId || call.id,
|
||||
call_set_id: call.call_set_id || call.callSetDbId || null,
|
||||
call_set_name: call.call_set_name || call.callSetName || null,
|
||||
variant_id: call.variant_id || call.variantDbId || null,
|
||||
variant_name: call.variant_name || call.variantName || null,
|
||||
genotype_text: call.genotype_text || call.genotypeText || genotypeToText(call.genotype),
|
||||
genotype_likelihood: call.genotype_likelihood ?? call.genotypeLikelihood ?? null,
|
||||
read_depth: call.read_depth ?? call.readDepth ?? null,
|
||||
phase_set: call.phase_set || call.phaseSet || null,
|
||||
});
|
||||
|
||||
const variantBody = (payload: VariantPayload) => ({
|
||||
variantName: requiredText(payload.variant_name, "Variant name is required"),
|
||||
variantType: optionalText(payload.variant_type),
|
||||
@@ -187,15 +146,6 @@ const variantBody = (payload: VariantPayload) => ({
|
||||
filtersPassed: optionalBoolean(payload.filters_passed),
|
||||
});
|
||||
|
||||
const callBody = (payload: CallPayload) => ({
|
||||
callSetDbId: requiredText(payload.call_set_id, "CallSet is required"),
|
||||
variantDbId: requiredText(payload.variant_id, "Variant is required"),
|
||||
genotype: requiredText(payload.genotype_text, "Genotype is required"),
|
||||
genotypeLikelihood: optionalNumber(payload.genotype_likelihood),
|
||||
readDepth: optionalNumber(payload.read_depth),
|
||||
phaseSet: optionalText(payload.phase_set),
|
||||
});
|
||||
|
||||
export const normalizeVariantFormData = (row: VariantRecord) => ({
|
||||
id: row.id,
|
||||
variant_name: row.variant_name || "",
|
||||
@@ -220,13 +170,11 @@ const variantSetLoader = createCachedLoader(async () => {
|
||||
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");
|
||||
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/search/variants", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ page: DEFAULT_LIST_PAGE, pageSize: DEFAULT_LIST_PAGE_SIZE }),
|
||||
});
|
||||
return response.result.data.map(mapVariant);
|
||||
});
|
||||
|
||||
@@ -264,8 +212,6 @@ function filterVariantRows(rows: VariantRecord[], query?: VariantQuery): Variant
|
||||
function buildVariantOptions(
|
||||
referenceSets: ReferenceSetResponse[],
|
||||
variantSets: VariantSetResponse[],
|
||||
callSets: CallSetResponse[],
|
||||
variants: VariantRecord[],
|
||||
) {
|
||||
return {
|
||||
referenceSets: referenceSets.map((item) => ({
|
||||
@@ -276,38 +222,25 @@ function buildVariantOptions(
|
||||
value: item.variantSetDbId,
|
||||
label: `${item.variantSetName || item.variantSetDbId}${item.referenceSetDbId ? ` / ${item.referenceSetDbId}` : ""}`,
|
||||
})),
|
||||
callSets: callSets.map((item) => ({
|
||||
value: item.callSetDbId,
|
||||
label: `${item.callSetName || item.callSetDbId}${item.sampleName || item.sampleDbId ? ` / ${item.sampleName || item.sampleDbId}` : ""}`,
|
||||
})),
|
||||
variants: variants.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.variant_name || item.id}${item.variant_type ? ` / ${item.variant_type}` : ""}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateVariantPageCache() {
|
||||
referenceSetLoader.invalidate();
|
||||
variantSetLoader.invalidate();
|
||||
callSetLoader.invalidate();
|
||||
variantRowsLoader.invalidate();
|
||||
}
|
||||
|
||||
export async function fetchVariantOptions(force = false): Promise<{
|
||||
referenceSets: SelectOption[];
|
||||
variantSets: SelectOption[];
|
||||
callSets: SelectOption[];
|
||||
variants: SelectOption[];
|
||||
}> {
|
||||
const [referenceSets, variantSets, callSets, variants] = await Promise.all([
|
||||
const [referenceSets, variantSets] = await Promise.all([
|
||||
referenceSetLoader.load(force),
|
||||
variantSetLoader.load(force),
|
||||
callSetLoader.load(force),
|
||||
variantRowsLoader.load(force),
|
||||
]);
|
||||
|
||||
return buildVariantOptions(referenceSets, variantSets, callSets, variants);
|
||||
return buildVariantOptions(referenceSets, variantSets);
|
||||
}
|
||||
|
||||
export async function fetchVariantRows(query?: VariantQuery): Promise<VariantRecord[]> {
|
||||
@@ -324,7 +257,7 @@ export async function fetchVariantDetail(variantDbId: string): Promise<VariantRe
|
||||
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`),
|
||||
request<BrapiListResponse<Record<string, never>>>(`/brapi/v2/variants/${encodeURIComponent(variantDbId)}/calls?pageSize=10`),
|
||||
]);
|
||||
|
||||
const mapped = mapVariant(detail.result);
|
||||
@@ -339,11 +272,6 @@ export async function fetchVariantDetail(variantDbId: string): Promise<VariantRe
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await request<BrapiListResponse<VariantRecord>>("/brapi/v2/variants", {
|
||||
method: "POST",
|
||||
@@ -374,45 +302,17 @@ export async function deleteVariantRow(id: string): Promise<void> {
|
||||
invalidateVariantPageCache();
|
||||
}
|
||||
|
||||
export async function createCallRow(payload: CallPayload): Promise<CallRecord> {
|
||||
const response = await request<BrapiListResponse<CallRecord>>("/brapi/v2/calls", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
callDbId: requiredText(payload.id, "Call ID is required"),
|
||||
...callBody(payload),
|
||||
}),
|
||||
});
|
||||
return mapCall(response.result.data[0]);
|
||||
}
|
||||
|
||||
export async function updateCallRow(id: string, payload: CallPayload): Promise<CallRecord> {
|
||||
const requestedId = optionalText(payload.id);
|
||||
if (requestedId && requestedId !== id) throw new Error("Call ID is immutable. Create a new record instead.");
|
||||
const response = await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(callBody(payload)),
|
||||
});
|
||||
return mapCall(response.result);
|
||||
}
|
||||
|
||||
export async function deleteCallRow(id: string): Promise<void> {
|
||||
await request<BrapiSingleResponse<CallRecord>>(`/brapi/v2/calls/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadVariantPageData(options?: { query?: VariantQuery; force?: boolean }) {
|
||||
const force = options?.force ?? false;
|
||||
const [referenceSets, variantSets, callSets, variants] = await Promise.all([
|
||||
const [referenceSets, variantSets, 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),
|
||||
options: buildVariantOptions(referenceSets, variantSets),
|
||||
rows: filterVariantRows(enrichedRows, options?.query),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
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 { fetchMarkerPositionsByVariantId } from "../../genome-map/api";
|
||||
import type { MarkerPositionRecord } from "../../genome-map/types";
|
||||
|
||||
interface VariantMarkerPositionCardProps {
|
||||
variantDbId: string;
|
||||
}
|
||||
|
||||
export function VariantMarkerPositionCard({ variantDbId }: VariantMarkerPositionCardProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rows, setRows] = useState<MarkerPositionRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchMarkerPositionsByVariantId(variantDbId)
|
||||
.then((result) => {
|
||||
if (!mounted) return;
|
||||
setRows(result);
|
||||
})
|
||||
.catch((event) => {
|
||||
if (!mounted) return;
|
||||
setError(event instanceof Error ? event.message : "加载 Marker Position 失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [variantDbId]);
|
||||
|
||||
return (
|
||||
<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="space-y-3 text-sm">
|
||||
{loading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : error ? (
|
||||
<p className="text-destructive">{error}</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-slate-600 dark:text-slate-300">
|
||||
该 Variant 尚未关联遗传图谱位置。
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-800">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>图谱</TableHead>
|
||||
<TableHead>连锁群</TableHead>
|
||||
<TableHead>位置</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
{row.map_db_id ? (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(row.map_db_id)}`}
|
||||
className="text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{row.map_name || row.map_db_id}
|
||||
</Link>
|
||||
) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.linkage_group_db_id && row.map_db_id ? (
|
||||
<Link
|
||||
href={`/genotyping/genome-map/maps/${encodeURIComponent(row.map_db_id)}/linkage-groups/${encodeURIComponent(row.linkage_group_db_id)}`}
|
||||
className="text-teal-600 hover:underline dark:text-teal-400"
|
||||
>
|
||||
{row.linkage_group_name || row.linkage_group_db_id}
|
||||
</Link>
|
||||
) : (row.linkage_group_name || "—")}
|
||||
</TableCell>
|
||||
<TableCell>{row.position ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="link" className="h-auto px-0 text-cyan-600 dark:text-cyan-400">
|
||||
<Link href={`/genotyping/genome-map?tab=marker-positions&variant_db_id=${encodeURIComponent(variantDbId)}`}>
|
||||
在 GenomeMap 模块维护 Marker Position
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { Binary, Sigma } from "lucide-react";
|
||||
import { BrapiEntityPage, type BrapiFormField } from "@/components/brapi/BrapiEntityPage";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Suspense } from "react";
|
||||
import { Sigma } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { VariantTab } from "./components/VariantTab";
|
||||
import {
|
||||
createCallRow,
|
||||
deleteCallRow,
|
||||
fetchCallRows,
|
||||
fetchVariantOptions,
|
||||
updateCallRow,
|
||||
} from "./api";
|
||||
import { NONE_SELECT_VALUE, type SelectOption } from "./types";
|
||||
|
||||
function VariantTabFallback() {
|
||||
return <Skeleton className="h-96 w-full rounded-xl" />;
|
||||
}
|
||||
|
||||
export default function VariantPage() {
|
||||
const [tab, setTab] = useState("variants");
|
||||
const [callSetOptions, setCallSetOptions] = useState<SelectOption[]>([]);
|
||||
const [variantOptions, setVariantOptions] = useState<SelectOption[]>([]);
|
||||
|
||||
const loadOptions = useCallback(async () => {
|
||||
const options = await fetchVariantOptions();
|
||||
setCallSetOptions(options.callSets);
|
||||
setVariantOptions(options.variants);
|
||||
return options;
|
||||
}, []);
|
||||
|
||||
const loadCalls = useCallback(async () => {
|
||||
const [, rows] = await Promise.all([loadOptions(), fetchCallRows()]);
|
||||
return rows as unknown as Record<string, unknown>[];
|
||||
}, [loadOptions]);
|
||||
|
||||
const callFields = useMemo<BrapiFormField[]>(() => [
|
||||
{ key: "id", label: "Call ID", type: "text", required: true, placeholder: "call-001" },
|
||||
{
|
||||
key: "call_set_id",
|
||||
label: "CallSet",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: callSetOptions,
|
||||
},
|
||||
{
|
||||
key: "variant_id",
|
||||
label: "Variant",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: variantOptions,
|
||||
},
|
||||
{ key: "genotype_text", label: "Genotype", type: "text", required: true, placeholder: "A/G" },
|
||||
{ key: "genotype_likelihood", label: "似然值", type: "number", placeholder: "0.98" },
|
||||
{ key: "read_depth", label: "测序深度", type: "number", placeholder: "42" },
|
||||
{ key: "phase_set", label: "Phase Set", type: "text", placeholder: "PS001" },
|
||||
], [callSetOptions, variantOptions]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
{tab === "variants" ? (
|
||||
<TabsContent value="variants" className="mt-0 min-h-0 flex-1">
|
||||
<Suspense fallback={<VariantTabFallback />}>
|
||||
<VariantTab />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
{tab === "calls" ? (
|
||||
<TabsContent value="calls" className="mt-0 min-h-0 flex-1">
|
||||
<BrapiEntityPage
|
||||
useEnhancedDialog
|
||||
icon={Binary}
|
||||
iconBg="bg-gradient-to-br from-sky-500 to-cyan-600"
|
||||
title="Call 基因型判读"
|
||||
description="维护样品 CallSet 在指定 Variant 上的 genotype、深度和相位信息。"
|
||||
addLabel="新增 Call"
|
||||
columns={[
|
||||
{ key: "callDbId", label: "Call ID" },
|
||||
{ key: "call_set_name", label: "CallSet" },
|
||||
{ key: "variant_name", label: "Variant" },
|
||||
{ key: "genotype_text", label: "Genotype" },
|
||||
{ key: "genotype_likelihood", label: "似然值" },
|
||||
{ key: "read_depth", label: "深度" },
|
||||
{ key: "phase_set", label: "Phase Set" },
|
||||
]}
|
||||
fields={callFields}
|
||||
data={[]}
|
||||
stats={[{ label: "/brapi/v2/calls", value: "BrAPI", className: "bg-sky-50 text-sky-700 dark:bg-sky-400/10 dark:text-sky-200" }]}
|
||||
loadData={loadCalls}
|
||||
createRecord={(payload) => createCallRow(payload) as unknown as Promise<Record<string, unknown>>}
|
||||
updateRecord={(id, payload) => updateCallRow(id, payload) as unknown as Promise<Record<string, unknown>>}
|
||||
deleteRecord={deleteCallRow}
|
||||
/>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
<Suspense fallback={<VariantTabFallback />}>
|
||||
<VariantTab />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ArrowLeft, MapPin, Sigma } from "lucide-react";
|
||||
import { ArrowLeft, 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";
|
||||
import { VariantMarkerPositionCard } from "../../components/VariantMarkerPositionCard";
|
||||
|
||||
const boolLabel = (value: boolean | null | undefined) => {
|
||||
if (value === true) return "是";
|
||||
@@ -97,30 +98,19 @@ export default function VariantDetailPage() {
|
||||
<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>
|
||||
{(detail.allele_call_count ?? 0) > 0 ? (
|
||||
<div className="sm:col-span-2">
|
||||
<Button asChild variant="link" className="h-auto px-0 text-cyan-600 dark:text-cyan-400">
|
||||
<Link href={`/genotyping/call-set?tab=allele-calls&variant_id=${encodeURIComponent(detail.id)}`}>
|
||||
查看该位点下的 allele_call
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VariantMarkerPositionCard variantDbId={detail.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,8 @@ export interface BrapiFormField {
|
||||
label: string;
|
||||
type: "text" | "select" | "date" | "number" | "textarea" | "year";
|
||||
required?: boolean;
|
||||
/** 只读展示,用户不可编辑(如后端自动生成的字段) */
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
colSpan?: 1 | 2;
|
||||
@@ -256,8 +258,14 @@ export function BrapiEntityPage({
|
||||
?? row.referenceDbId
|
||||
?? row.referenceBasesDbId
|
||||
?? row.variantSetDbId
|
||||
?? row.callSetDbId
|
||||
?? row.variantDbId
|
||||
?? row.callDbId
|
||||
?? row.mapDbId
|
||||
?? row.linkageGroupDbId
|
||||
?? row.markerPositionDbId
|
||||
?? row.analysisDbId
|
||||
?? row.formatDbId
|
||||
?? "",
|
||||
), []);
|
||||
|
||||
@@ -464,7 +472,11 @@ export function BrapiEntityPage({
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select value={String(formData[field.key] ?? "")} onValueChange={(value) => updateForm(field.key, value)}>
|
||||
<Select
|
||||
value={String(formData[field.key] ?? "")}
|
||||
onValueChange={(value) => updateForm(field.key, value)}
|
||||
disabled={field.readOnly}
|
||||
>
|
||||
<SelectTrigger id={field.key}><SelectValue placeholder={field.placeholder ?? `请选择${field.label}`} /></SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
@@ -474,9 +486,27 @@ export function BrapiEntityPage({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea id={field.key} value={String(formData[field.key] ?? "")} onChange={(event) => updateForm(field.key, event.target.value)} placeholder={field.placeholder} rows={3} className="resize-none" />
|
||||
<Textarea
|
||||
id={field.key}
|
||||
value={String(formData[field.key] ?? "")}
|
||||
onChange={(event) => updateForm(field.key, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
readOnly={field.readOnly}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
) : (
|
||||
<Input id={field.key} type={field.type === "number" ? "number" : "text"} value={String(formData[field.key] ?? "")} onChange={(event) => updateForm(field.key, event.target.value)} placeholder={field.placeholder} />
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
value={String(formData[field.key] ?? "")}
|
||||
onChange={(event) => updateForm(field.key, event.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
readOnly={field.readOnly}
|
||||
disabled={field.readOnly}
|
||||
className={field.readOnly ? "bg-slate-50 text-slate-500 dark:bg-slate-900 dark:text-slate-400" : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Binary,
|
||||
BookCheck,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
List as ListIcon,
|
||||
PanelTop,
|
||||
CalendarClock,
|
||||
Map,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface BrapiNavItem {
|
||||
@@ -143,7 +145,14 @@ export const brapiNavSections: BrapiNavSection[] = [
|
||||
title: "变异数据",
|
||||
items: [
|
||||
{ title: "VariantSet", href: "/genotyping/variant-set", icon: Layers },
|
||||
{ title: "Variant / Call", href: "/genotyping/variant", icon: Sigma },
|
||||
{ title: "Variant", href: "/genotyping/variant", icon: Sigma },
|
||||
{ title: "CallSet / allele_call", href: "/genotyping/call-set", icon: Binary },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "遗传图谱",
|
||||
items: [
|
||||
{ title: "GenomeMap / LinkageGroup", href: "/genotyping/genome-map", icon: Map },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,6 +6,12 @@ export const DEFAULT_LIST_PAGE = 0;
|
||||
/** 标准分页查询串:page=0&pageSize=10 */
|
||||
export const DEFAULT_PAGE_QUERY = `page=${DEFAULT_LIST_PAGE}&pageSize=${DEFAULT_LIST_PAGE_SIZE}`;
|
||||
|
||||
/** BrAPI token 分页接口(如 GET /variants) */
|
||||
export const DEFAULT_TOKEN_PAGE_QUERY = "pageToken=0&pageSize=1000";
|
||||
|
||||
/** BrAPI search 分页请求体默认值 */
|
||||
export const DEFAULT_SEARCH_PAGE_BODY = { page: DEFAULT_LIST_PAGE, pageSize: 1000 } as const;
|
||||
|
||||
export function buildPageQuery(page = DEFAULT_LIST_PAGE, pageSize = DEFAULT_LIST_PAGE_SIZE): string {
|
||||
return `page=${page}&pageSize=${pageSize}`;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,11 @@ export const mockBackendMenus: BackendMenuResponse[] = [
|
||||
{ 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: "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: [] },
|
||||
{ title: "Variant", path: "/genotyping/variant", menu_type: "menu", icon: "sigma", order_index: 2, children: [] },
|
||||
{ title: "CallSet / allele_call", path: "/genotyping/call-set", menu_type: "menu", icon: "binary", order_index: 3, children: [] },
|
||||
] },
|
||||
{ title: "遗传图谱", path: "/genotyping/genome-map-group", menu_type: "folder", icon: "map", order_index: 4, children: [
|
||||
{ title: "GenomeMap / LinkageGroup", path: "/genotyping/genome-map", menu_type: "menu", icon: "map", order_index: 1, children: [] },
|
||||
] }
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user