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) =>
{JSON.stringify(detailDrug.physicochemical || {}, null, 2)}