/* global React */
const { useState, useMemo, useEffect, useRef } = React;
/* ===== Sample geometries (mock data; replace with /api/geometries later) ===== */
const SAMPLE_GEOMETRIES = [
{ id: "20260512-203200-a8b4f1", name: "盘式制动器装配体", fmt: "cdb", size: 12582912,
desc: "4 部件装配:刹车盘 + 内/外刹车片 + 背板。共 1 个 contact pair,结构钢材料。PyAnsys 官方 td-1 示例。",
original: "disc_pad_model.cdb", source_url: "https://github.com/ansys/example-data/.../td-1/disc_pad_model.cdb",
at: "2026-05-12 20:32", by: "admin",
refs: [
{ case_id: "c-20260510-a1", name: "盘式制动器 · 线性模态", atype: "modal" },
{ case_id: "c-20260510-a2", name: "盘式制动器 · 部分预应力", atype: "static_structural" },
{ case_id: "c-20260510-a3", name: "盘式制动器 · 全非线性", atype: "nonlinear" },
{ case_id: "c-20260510-a4", name: "盘式制动器 · 参数化扫频", atype: "harmonic" },
] },
{ id: "20260511-091245-c2e891", name: "涡轮叶片 NACA-65", fmt: "step", size: 3354624,
desc: "压气机第一级转子叶片单通道模型,含榫头与平台。来自客户 KCT-A-2024 项目。",
original: "turbine_blade_naca65.step", at: "2026-05-11 09:12", by: "admin",
refs: [{ case_id: "c-20260511-b1", name: "叶片 · 离心载荷静力", atype: "static_structural" }] },
{ id: "20260510-160340-9d33aa", name: "压力容器壳体", fmt: "iges", size: 4718592,
desc: "薄壁圆筒 + 半球封头,含两个法兰开孔。ASME 第八章设计校核基础几何。",
original: "pressure_vessel.iges", at: "2026-05-10 16:03", by: "admin",
refs: [
{ case_id: "c-20260510-c1", name: "容器 · 内压稳态", atype: "static_structural" },
{ case_id: "c-20260510-c2", name: "容器 · 屈曲分析", atype: "buckling" },
{ case_id: "c-20260510-c3", name: "容器 · 热应力耦合", atype: "thermal_structural" },
] },
{ id: "20260509-114220-7f6e1c", name: "齿轮箱壳体", fmt: "cdb", size: 19294617,
desc: "二级减速箱箱体,含 4 个轴承座孔位、8 颗螺栓位。模态 + 谐响应分析复用。",
original: "gearbox_housing.cdb", at: "2026-05-09 11:42", by: "admin",
refs: [{ case_id: "c-20260509-d1", name: "齿轮箱 · 整机模态", atype: "modal" }] },
{ id: "20260508-082015-31b7d2", name: "散热翅片阵列", fmt: "anf", size: 1677721,
desc: "20 片纵向直翅片,铝合金,用于稳态 / 瞬态热分析对比。",
original: "heatsink_fins.anf", at: "2026-05-08 08:20", by: "admin", refs: [] },
{ id: "20260507-153842-5a90b8", name: "螺栓 M16 装配", fmt: "step", size: 629145,
desc: "GB/T 5783 标准件,含螺母与垫片。预紧 + 接触分析基础几何。",
original: "bolt_m16_assembly.step", at: "2026-05-07 15:38", by: "admin",
refs: [
{ case_id: "c-20260507-e1", name: "螺栓 · 预紧力静力", atype: "static_structural" },
{ case_id: "c-20260507-e2", name: "螺栓 · 滑移屈服", atype: "nonlinear" },
] },
{ id: "20260505-094711-2bf041", name: "太阳能板支架", fmt: "iges", size: 6082662,
desc: "屋顶分布式光伏组件支架,含立柱、横梁、压块。风载与地震载荷分析。",
original: "solar_rack.iges", at: "2026-05-05 09:47", by: "admin",
refs: [{ case_id: "c-20260505-f1", name: "支架 · 风载静力", atype: "static_structural" }] },
{ id: "20260503-181520-e7c3d9", name: "连接器外壳", fmt: "xt", size: 419430,
desc: "塑料注塑件,含 8 个端子孔。落跌冲击分析备用几何。",
original: "connector_shell.x_t", at: "2026-05-03 18:15", by: "admin", refs: [] },
];
const FMT_META = {
cdb: { tag: "CDB", name: "ANSYS CDB", cls: "cdb" },
step: { tag: "STEP", name: "STEP", cls: "step" },
stp: { tag: "STP", name: "STEP", cls: "step" },
iges: { tag: "IGES", name: "IGES", cls: "iges" },
igs: { tag: "IGS", name: "IGES", cls: "iges" },
anf: { tag: "ANF", name: "ANSYS Native", cls: "anf" },
xt: { tag: "X_T", name: "Parasolid", cls: "xt" },
xb: { tag: "X_B", name: "Parasolid Bin.", cls: "xt" },
};
const FILTER_BUCKETS = [
{ key: "cdb", label: "CDB", match: (g) => g.fmt === "cdb" },
{ key: "step", label: "STEP", match: (g) => g.fmt === "step" || g.fmt === "stp" },
{ key: "iges", label: "IGES", match: (g) => g.fmt === "iges" || g.fmt === "igs" },
{ key: "anf", label: "ANF", match: (g) => g.fmt === "anf" },
{ key: "other", label: "其他", match: (g) => ["xt", "xb"].includes(g.fmt) },
{ key: "unref", label: "未引用", match: (g) => g.refs.length === 0 },
];
const fmtSize = (n) => {
if (n >= 1048576) return (n/1048576).toFixed(1) + " MB";
if (n >= 1024) return (n/1024).toFixed(1) + " KB";
return n + " B";
};
/* ===== Format art (colored block thumb, the only "preview" we get) ===== */
const FmtArt = ({ fmt, className = "", showLabel = true }) => {
const meta = FMT_META[fmt] || FMT_META.xt;
return (
{showLabel && {meta.tag}}
);
};
const FmtBadge = ({ fmt }) => {
const meta = FMT_META[fmt] || FMT_META.xt;
return {meta.tag};
};
/* ===== Grid card — name only, footer with fmt + size + ref count ===== */
const GeoCard = ({ g, onPick }) => (
onPick(g)} role="button" tabIndex={0}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onPick(g)}>
{g.refs.length > 0 && (
↗ {g.refs.length}
)}
{g.name}
{fmtSize(g.size)}
{g.at.slice(5, 10)}
);
/* ===== List row ===== */
const GeoRow = ({ g, onPick }) => (
onPick(g)} role="button" tabIndex={0}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onPick(g)}>
{fmtSize(g.size)}
{g.refs.length === 0
? — 未引用
: <>{g.refs.length} 个 case>}
{g.at.slice(0, 10)}
);
/* ===== Right-side drawer (detail) ===== */
const GeoDrawer = ({ g, onClose, onUse, onDelete }) => {
// Lock scroll + ESC to close while open
useEffect(() => {
if (!g) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [g, onClose]);
return (
<>
{g && (
e.stopPropagation()}>
{g.has_preview ? (
) : (
<>
{FMT_META[g.fmt]?.tag}
>
)}
{g.name}
{g.original} · {fmtSize(g.size)}
)}
>
);
};
/* ===== Upload modal ===== */
const GeoUploadModal = ({ open, onClose, onSubmit }) => {
const [file, setFile] = useState(null);
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [dragging, setDragging] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (open) { setFile(null); setName(""); setDesc(""); setBusy(false); }
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const ALLOWED = ["cdb","iges","igs","step","stp","anf","x_t","x_b"];
const onFile = (f) => {
if (!f) return;
const ext = (f.name.split(".").pop() || "").toLowerCase();
if (!ALLOWED.includes(ext)) {
alert("不支持的格式:." + ext + "\n支持:" + ALLOWED.map(e => "." + e).join(" · "));
return;
}
if (f.size > 100 * 1024 * 1024) {
alert("文件超过 100 MB 上限");
return;
}
setFile(f);
if (!name) setName(f.name.replace(/\.[^.]+$/, ""));
};
const fmt = file ? (file.name.split(".").pop() || "").toLowerCase().replace("_", "") : null;
const fmtKey = fmt === "xt" ? "xt" : fmt === "xb" ? "xb" : fmt;
const submit = async () => {
if (!file || !name.trim()) return;
setBusy(true);
try {
const g = await uploadGeometry({ file, name: name.trim(), description: desc.trim() });
onSubmit(g);
onClose();
} catch (e) {
alert("上传失败:" + e.message);
} finally {
setBusy(false);
}
};
return (
{ if (e.target === e.currentTarget) onClose(); }}>
{!file ? (
inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); onFile(e.dataTransfer.files?.[0]); }}>
拖入 CAD 文件,或点击选择
支持 .cdb · .step · .iges · .anf · .x_t · .x_b
单文件 ≤ 100 MB
onFile(e.target.files?.[0])}/>
) : (
{file.name}
{fmtSize(file.size)} · 自动识别为 {FMT_META[fmtKey]?.tag || fmt.toUpperCase()}
)}
);
};
/* Transform the API shape → the UI shape this page was designed against. */
const _normalizeGeo = (g) => {
if (!g) return g;
const at = g.created_at
? new Date(g.created_at * 1000).toISOString().slice(0, 16).replace("T", " ")
: "";
return {
id: g.id,
name: g.name,
fmt: g.fmt,
size: g.size,
desc: g.description || "",
original: g.original_filename || "",
source_url: g.source_url || null,
at,
by: g.user || "",
refs: g.refs || g.refs_preview || [],
ref_count: g.ref_count ?? (g.refs || g.refs_preview || []).length,
has_preview: !!g.has_preview,
};
};
const fetchGeometries = async () => {
const r = await fetch("/api/geometry", { credentials: "include" });
if (!r.ok) throw new Error("加载失败:" + r.status);
const d = await r.json();
return (d.geometry || []).map(_normalizeGeo);
};
const fetchGeometryDetail = async (id) => {
const r = await fetch(`/api/geometry/${id}`, { credentials: "include" });
if (!r.ok) throw new Error("详情加载失败");
return _normalizeGeo((await r.json()).geometry);
};
const uploadGeometry = async ({ file, name, description, source_url }) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", name);
if (description) fd.append("description", description);
if (source_url) fd.append("source_url", source_url);
const r = await fetch("/api/geometry", { method: "POST", body: fd, credentials: "include" });
if (!r.ok) throw new Error((await r.json()).detail || "上传失败");
return _normalizeGeo((await r.json()).geometry);
};
const deleteGeometryApi = async (id) => {
const r = await fetch(`/api/geometry/${id}`, { method: "DELETE", credentials: "include" });
if (!r.ok) throw new Error((await r.json()).detail || "删除失败");
};
/* ===== Main page ===== */
const GeometryPage = () => {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState("grid"); // grid | list
const [filter, setFilter] = useState("all"); // all | cdb | step | iges | anf | other | unref
const [search, setSearch] = useState("");
const [active, setActive] = useState(null);
const [uploadOpen, setUploadOpen] = useState(false);
const refresh = async () => {
setLoading(true);
try {
setItems(await fetchGeometries());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => { refresh(); }, []);
const counts = useMemo(() => {
const c = { all: items.length };
for (const b of FILTER_BUCKETS) c[b.key] = items.filter(b.match).length;
return c;
}, [items]);
const filtered = useMemo(() => {
let list = items;
if (filter !== "all") {
const b = FILTER_BUCKETS.find(x => x.key === filter);
if (b) list = list.filter(b.match);
}
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter(g =>
g.name.toLowerCase().includes(q) ||
(g.desc || "").toLowerCase().includes(q) ||
g.id.toLowerCase().includes(q) ||
(g.original || "").toLowerCase().includes(q));
}
return list;
}, [items, filter, search]);
const onUploaded = (g) => {
setItems(prev => [g, ...prev]);
setTimeout(() => setActive(g), 80);
};
// When opening a card, fetch full detail (refs list) so the drawer shows
// every case that references this geometry, not just the preview.
const openDetail = async (g) => {
setActive(g);
try {
const full = await fetchGeometryDetail(g.id);
setActive(full);
} catch (_) {}
};
const handleDelete = async (g) => {
if (g.ref_count > 0) {
alert(`被 ${g.ref_count} 个案例引用,无法删除`);
return;
}
if (!confirm(`确认删除「${g.name}」?此操作不可撤销。`)) return;
try {
await deleteGeometryApi(g.id);
setItems(prev => prev.filter(x => x.id !== g.id));
setActive(null);
} catch (e) {
alert("删除失败:" + e.message);
}
};
return (
{filtered.length === 0 ? (
items.length === 0 ? (
还没有几何资产
上传你的第一个 CAD 文件,开始把几何沉淀为可复用资产
) : (
没有匹配的几何
试试清除筛选或换个关键词
)
) : view === "grid" ? (
{filtered.map(g => )}
) : (
名称 / ID
格式
大小
引用
上传时间
{filtered.map(g =>
)}
)}
setActive(null)} onDelete={handleDelete}/>
setUploadOpen(false)} onSubmit={onUploaded}/>
);
};
window.GeometryPage = GeometryPage;