const { useCallback, useEffect, useMemo, useRef, useState } = React; const { App, Alert, Button, Card, Checkbox, Col, ConfigProvider, Drawer, Input, Pagination, Popover, Row, Select, Space, Spin, Tabs, Table, Typography, message, } = antd; const { Title, Text, Paragraph } = Typography; const apiBase = (window.APP_CONFIG && window.APP_CONFIG.apiBase) || "/drugs"; const authBase = (window.APP_CONFIG && window.APP_CONFIG.authBase) || "/auth"; const AUTH_KEY = "drug_admin_token"; const PAGE_SIZE_OPTIONS = [30, 50, 100, 200]; const ALL_COLUMN_KEYS = ["id", "generic_name_en"]; const DEFAULT_VISIBLE_COLUMNS = { id: true, generic_name_en: true, }; const SEARCH_HISTORY_KEY = "drug_site_recent_searches"; const SEARCH_HISTORY_LIMIT = 8; function readInitialStateFromUrl() { const params = new URLSearchParams(window.location.search || ""); const q = (params.get("q") || "").trim(); const pageRaw = Number(params.get("page") || "1"); const pageSizeRaw = Number(params.get("page_size") || "100"); const colsRaw = (params.get("cols") || "").trim(); const detailIdRaw = Number(params.get("detail_id") || ""); let visibleColumns = { ...DEFAULT_VISIBLE_COLUMNS }; if (colsRaw) { const selected = new Set(colsRaw.split(",").map((x) => x.trim()).filter((x) => ALL_COLUMN_KEYS.includes(x))); visibleColumns = ALL_COLUMN_KEYS.reduce((acc, key) => { acc[key] = selected.has(key); return acc; }, {}); if (!Object.values(visibleColumns).some(Boolean)) { visibleColumns = { ...DEFAULT_VISIBLE_COLUMNS }; } } return { q, page: Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1, pageSize: PAGE_SIZE_OPTIONS.includes(pageSizeRaw) ? pageSizeRaw : 100, visibleColumns, detailId: Number.isFinite(detailIdRaw) && detailIdRaw > 0 ? Math.floor(detailIdRaw) : null, }; } function buildLoginRedirectUrl(detailId) { const params = new URLSearchParams(window.location.search || ""); params.set("detail_id", String(detailId)); const nextPath = `${window.location.pathname}?${params.toString()}`; return `/admin?next=${encodeURIComponent(nextPath)}`; } function clearDetailIdInUrl() { const params = new URLSearchParams(window.location.search || ""); const hasDetailId = params.has("detail_id"); const hasNext = params.has("next"); if (!hasDetailId && !hasNext) { return; } params.delete("detail_id"); params.delete("next"); const query = params.toString(); const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}`; window.history.replaceState(null, "", nextUrl); } function readSearchHistory() { try { const raw = window.localStorage.getItem(SEARCH_HISTORY_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) { return []; } return parsed .filter((x) => x && typeof x.q === "string") .map((x) => ({ q: x.q.trim(), reviewStatus: "", })) .filter((x) => x.q.length > 0) .slice(0, SEARCH_HISTORY_LIMIT); } catch { return []; } } function saveSearchHistory(items) { try { window.localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(items.slice(0, SEARCH_HISTORY_LIMIT))); } catch { // Ignore localStorage failures in restricted environments. } } function removeSearchHistoryItem(currentItems, target) { return currentItems.filter( (item) => !(item.q === target.q && (item.reviewStatus || "") === (target.reviewStatus || "")) ); } function pushSearchHistory(currentItems, q, reviewStatus) { const normalized = (q || "").trim(); if (!normalized) { return currentItems; } const status = reviewStatus || ""; const next = [{ q: normalized, reviewStatus: status }]; for (const item of currentItems) { if (item.q === normalized && (item.reviewStatus || "") === status) { continue; } next.push(item); if (next.length >= SEARCH_HISTORY_LIMIT) { break; } } return next; } function toText(value, fallback = "-") { if (value === null || value === undefined || value === "") { return fallback; } return String(value); } function normalizeError(err) { if (!err) { return "Unknown error"; } return err.message || String(err); } async function fetchJSON(url, options = {}) { const resp = await fetch(url, options); if (!resp.ok) { const text = await resp.text(); const err = new Error(text || `HTTP ${resp.status}`); err.status = resp.status; throw err; } return await resp.json(); } function ReactDrugSite() { const init = useMemo(() => readInitialStateFromUrl(), []); const [queryInput, setQueryInput] = useState(init.q); const [queryValue, setQueryValue] = useState(init.q); const [page, setPage] = useState(init.page); const [pageSize, setPageSize] = useState(init.pageSize); const [loading, setLoading] = useState(false); const [errorMsg, setErrorMsg] = useState(""); const [total, setTotal] = useState(0); const [items, setItems] = useState([]); const [detailOpen, setDetailOpen] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); const [detailDrug, setDetailDrug] = useState(null); const [detailEvidence, setDetailEvidence] = useState([]); const [tableFilters, setTableFilters] = useState({}); const [tableSorter, setTableSorter] = useState({}); const [visibleColumns, setVisibleColumns] = useState(init.visibleColumns || DEFAULT_VISIBLE_COLUMNS); const [recentSearches, setRecentSearches] = useState(() => readSearchHistory()); const [shareUrl, setShareUrl] = useState(window.location.href); const autoOpenedDetailRef = useRef(false); const authHeaders = useCallback(() => { const token = window.localStorage.getItem(AUTH_KEY) || ""; if (!token) return {}; return { Authorization: `Bearer ${token}` }; }, []); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const fetchPage = useCallback(async () => { const offset = (page - 1) * pageSize; const params = new URLSearchParams({ q: queryValue, limit: String(pageSize), offset: String(offset), }); setLoading(true); setErrorMsg(""); try { const data = await fetchJSON(`${apiBase}/search?${params.toString()}`); setTotal(Number(data.total || 0)); setItems(Array.isArray(data.items) ? data.items : []); } catch (err) { setErrorMsg(normalizeError(err)); setItems([]); setTotal(0); } finally { setLoading(false); } }, [page, pageSize, queryValue]); useEffect(() => { fetchPage().catch(() => {}); }, [fetchPage]); useEffect(() => { const params = new URLSearchParams(); if (queryValue) { params.set("q", queryValue); } if (page > 1) { params.set("page", String(page)); } if (pageSize !== 100) { params.set("page_size", String(pageSize)); } const cols = ALL_COLUMN_KEYS.filter((k) => visibleColumns[k]); if (cols.length !== ALL_COLUMN_KEYS.length) { params.set("cols", cols.join(",")); } const query = params.toString(); const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}`; window.history.replaceState(null, "", nextUrl); setShareUrl(window.location.href); }, [queryValue, page, pageSize, visibleColumns]); const applySearch = useCallback( (nextQuery) => { const normalized = (nextQuery || "").trim(); setQueryInput(normalized); setQueryValue(normalized); setPage(1); const updated = pushSearchHistory(recentSearches, normalized, ""); setRecentSearches(updated); saveSearchHistory(updated); }, [recentSearches] ); const openDetail = useCallback(async (id) => { setDetailOpen(true); setDetailLoading(true); setDetailError(""); setDetailDrug(null); setDetailEvidence([]); try { const headers = authHeaders(); const drug = await fetchJSON(`${apiBase}/${id}`, { headers }); const evidences = await fetchJSON(`${apiBase}/${id}/evidences?limit=50`, { headers }); setDetailDrug(drug); setDetailEvidence(Array.isArray(evidences.items) ? evidences.items : []); } catch (err) { if (err && err.status === 401) { setDetailOpen(false); window.localStorage.removeItem(AUTH_KEY); message.warning("未登录,正在跳转到登录页"); window.location.href = buildLoginRedirectUrl(id); return; } setDetailError(normalizeError(err)); } finally { setDetailLoading(false); } }, [authHeaders]); useEffect(() => { if (autoOpenedDetailRef.current) { return; } if (!init.detailId) { return; } const token = window.localStorage.getItem(AUTH_KEY) || ""; if (!token) { return; } autoOpenedDetailRef.current = true; clearDetailIdInUrl(); openDetail(init.detailId).catch(() => {}); }, [init.detailId, openDetail]); const columns = useMemo( () => [ { title: "ID", dataIndex: "id", key: "id", width: 96, sorter: (a, b) => Number(a.id || 0) - Number(b.id || 0), sortOrder: tableSorter.columnKey === "id" ? tableSorter.order : null, }, { title: "英文通用名", dataIndex: "generic_name_en", key: "generic_name_en", sorter: (a, b) => toText(a.generic_name_en).localeCompare(toText(b.generic_name_en)), sortOrder: tableSorter.columnKey === "generic_name_en" ? tableSorter.order : null, render: (value) => {toText(value)}, }, { title: "操作", key: "actions", width: 120, render: (_, row) => ( ), }, ], [openDetail, tableFilters, tableSorter] ); const tableColumns = useMemo(() => { return columns.filter((col) => { if (col.key === "actions") { return true; } return visibleColumns[col.key] !== false; }); }, [columns, visibleColumns]); const columnChooserContent = ( setVisibleColumns((prev) => ({ ...prev, id: e.target.checked }))} > ID setVisibleColumns((prev) => ({ ...prev, generic_name_en: e.target.checked }))} > 英文通用名 ); const detailTabItems = useMemo(() => { if (!detailDrug) { return []; } const evidenceRows = (detailEvidence || []).slice(0, 60).map((ev, idx) => ({ key: `${idx}-${ev.field_name}-${ev.source_url || "-"}`, field_name: ev.field_name, evidence_text: ev.evidence_text, source_title: ev.source_title, source_url: ev.source_url, confidence: Number(ev.confidence || 0), })); const oneLineField = (label, value) => (
{label} {toText(value)}
); return [ { key: "base", label: "基础信息", children: (
{oneLineField("英文名", detailDrug.generic_name_en)} {oneLineField("药理", detailDrug.pharmacology)} {oneLineField("机制", detailDrug.mechanism)} {oneLineField("不良反应", detailDrug.adverse_reactions)} {oneLineField("临床用途", detailDrug.clinical_uses)}
{JSON.stringify(detailDrug.physicochemical || {}, null, 2)}
), }, { key: "evidence", label: `证据 (${detailEvidence.length})`, children: ( toText(value), }, { title: "证据", dataIndex: "evidence_text", key: "evidence_text", render: (value) =>
{toText(value)}
, }, { title: "来源", dataIndex: "source_title", key: "source_title", width: 180, render: (_, row) => ( row.source_url ? {toText(row.source_title)} : toText(row.source_title) ), }, { title: "置信度", dataIndex: "confidence", key: "confidence", width: 90, render: (value) => Number(value || 0).toFixed(2), }, ]} /> ), }, { key: "refs", label: `文献 (${(detailDrug.references || []).length})`, children: ( {(detailDrug.references || []).slice(0, 20).map((ref, idx) => ( {toText(ref.title)} ))} {(detailDrug.references || []).length === 0 ? 暂无 : null} ), }, ]; }, [detailDrug, detailEvidence]); const copyCurrentViewLink = useCallback(async () => { try { await navigator.clipboard.writeText(shareUrl); message.success("已复制当前视图链接"); } catch { message.error("复制失败,请手动复制地址栏"); } }, [shareUrl]); return ( 药物信息检索中心 支持药物检索、详情查看与证据追踪。 setQueryInput(e.target.value)} onPressEnter={() => applySearch(queryInput)} />
{ setTableFilters(filters || {}); if (Array.isArray(sorter)) { setTableSorter(sorter[0] || {}); } else { setTableSorter(sorter || {}); } }} />
setPage(nextPage)} /> 第 {page} / {totalPages} 页
setDetailOpen(false)} > {detailLoading ? (
) : null} {!detailLoading && detailError ? : null} {!detailLoading && detailDrug ? : null}
); } const root = ReactDOM.createRoot(document.getElementById("reactRoot")); root.render();