import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent, type FormEvent, type ReactNode } from "react"; import { Settings, Image, Video, BookOpen, Search, Database, Save, Trash2, Plus, RotateCcw, Languages, Link2, LogOut, LockKeyhole, X, } from "lucide-react"; import { DEFAULT_GUIDE_LOCALES } from "../guideContent"; import { DEFAULT_HOME_BG_URLS, DEFAULT_KNOWLEDGE_ENTRIES, DEFAULT_VIDEO_SOURCE, loadGuideOverrides, loadHomeBackgrounds, loadKnowledgeEntries, loadVideoSourceConfig, loadWelcome, mergeGuideLocales, newKnowledgeEntryId, type GuideOverrides, type KnowledgeEntry, type VideoProtocol, type VideoSourceConfig, type WelcomeMessages, } from "../kioskStorage"; import { PAGE_CONTENT_INSET } from "../pageContentInset"; import { fetchAndApplyKioskBundle, pushGuideToServer, pushHomeBackgroundsToServer, pushKnowledgeToServer, pushVideoSourceToServer, pushWelcomeToServer, resolveKioskMediaUrl, uploadKnowledgeMediaFile, } from "../api/kioskApi"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { toast } from "sonner"; import { useI18n } from "../i18n"; type AdminTab = | "background" | "welcome" | "guide" | "query" | "knowledge" | "stream"; /** 系统维护「信息查询」列表:展示全部筛选结果 */ const QUERY_LIST_PREVIEW_MAX = Infinity; const WELCOME_DEFAULTS: WelcomeMessages = { "zh-CN": "欢迎来到稻城天文台信息查询", en: "Welcome to the Daocheng Observatory Information Query", bo: "འདབ་ཆུ་གནམ་གཟིགས་ལྟེ་གནས་ཀྱི་ཆ་འཕྲིན་འཚོལ་ཞིབ་ལ་ཕེབས་པར་དགའ་བསུ་ཞུ།", }; const ADMIN_AUTH_KEY = "kiosk_admin_auth_v1"; const ADMIN_USERNAME = "admin"; const ADMIN_PASSWORD = "admin@123"; function linesToItems(s: string): string[] { return s .split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean); } function itemsToLines(items: string[]): string { return items.join("\n"); } export function AdminPage() { const { t } = useI18n(); const [activeTab, setActiveTab] = useState("background"); const [isAuthed, setIsAuthed] = useState(() => { try { return sessionStorage.getItem(ADMIN_AUTH_KEY) === "1"; } catch { return false; } }); const [loginUser, setLoginUser] = useState(""); const [loginPass, setLoginPass] = useState(""); const [homeBgUrls, setHomeBgUrls] = useState(() => loadHomeBackgrounds()); const homeBgFileRef = useRef(null); const homeBgPickRef = useRef<{ kind: "append" } | { kind: "replace"; index: number }>({ kind: "append", }); const pairCoverFileRef = useRef(null); const pairVideoFileRef = useRef(null); const [welcome, setWelcome] = useState(() => loadWelcome(WELCOME_DEFAULTS) ); const [guideZhTouch, setGuideZhTouch] = useState(""); const [guideZhSoft, setGuideZhSoft] = useState(""); const [guideEnTouch, setGuideEnTouch] = useState(""); const [guideEnSoft, setGuideEnSoft] = useState(""); const [guideBoTouch, setGuideBoTouch] = useState(""); const [guideBoSoft, setGuideBoSoft] = useState(""); const [guideEditLang, setGuideEditLang] = useState<"zh-CN" | "en" | "bo">("zh-CN"); const hydrateGuideEditors = useCallback(() => { const merged = mergeGuideLocales(DEFAULT_GUIDE_LOCALES, loadGuideOverrides()); const zh = merged.find((l) => l.htmlLang === "zh-CN")!; const en = merged.find((l) => l.htmlLang === "en")!; const bo = merged.find((l) => l.htmlLang === "bo")!; setGuideZhTouch(itemsToLines(zh.touchItems)); setGuideZhSoft(itemsToLines(zh.softwareItems)); setGuideEnTouch(itemsToLines(en.touchItems)); setGuideEnSoft(itemsToLines(en.softwareItems)); setGuideBoTouch(itemsToLines(bo.touchItems)); setGuideBoSoft(itemsToLines(bo.softwareItems)); }, []); useEffect(() => { hydrateGuideEditors(); }, [hydrateGuideEditors]); const pullServerState = useCallback(async (): Promise => { const ok = await fetchAndApplyKioskBundle(); if (!ok) { toast.error("无法从服务器加载展台数据,请检查后端与 /api/kiosk/bundle"); return false; } setHomeBgUrls(loadHomeBackgrounds()); setWelcome(loadWelcome(WELCOME_DEFAULTS)); hydrateGuideEditors(); setKnowledgeList(loadKnowledgeEntries()); setVideoSource(loadVideoSourceConfig()); return true; }, [hydrateGuideEditors]); useEffect(() => { if (!isAuthed) return; void pullServerState(); }, [isAuthed, pullServerState]); const [queryKeyword, setQueryKeyword] = useState(""); const [queryType, setQueryType] = useState("全部"); const [queryTag, setQueryTag] = useState("全部"); const [knowledgeList, setKnowledgeList] = useState(() => loadKnowledgeEntries() ); const [pairTitle, setPairTitle] = useState(""); const [pairType, setPairType] = useState<"文字" | "图片" | "视频">("文字"); const [pairContent, setPairContent] = useState(""); const [pairTags, setPairTags] = useState(""); const [pairImage, setPairImage] = useState(""); const [pairVideo, setPairVideo] = useState(""); const [pairDate, setPairDate] = useState(() => new Date().toISOString().slice(0, 10) ); const [editingKnowledgeId, setEditingKnowledgeId] = useState(null); const [videoSource, setVideoSource] = useState(() => loadVideoSourceConfig() ); const queryFiltered = useMemo(() => { return knowledgeList.filter((item) => { const kw = queryKeyword.trim().toLowerCase(); const okKw = kw === "" || item.title.toLowerCase().includes(kw) || item.content.toLowerCase().includes(kw); const okType = queryType === "全部" || item.type === queryType; const okTag = queryTag === "全部" || item.tags.includes(queryTag); return okKw && okType && okTag; }); }, [knowledgeList, queryKeyword, queryType, queryTag]); const queryListPreview = useMemo(() => queryFiltered, [queryFiltered]); const queryTags = useMemo(() => { const s = new Set(); knowledgeList.forEach((k) => k.tags.forEach((t) => s.add(t))); return ["全部", ...Array.from(s).sort()]; }, [knowledgeList]); const saveBackgrounds = async () => { const home = homeBgUrls.filter((u) => u.trim().length > 0); const urls = home.length ? home : [...DEFAULT_HOME_BG_URLS]; const ok = await pushHomeBackgroundsToServer(urls); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); toast.success(t("admin.toast.saved")); }; const resetBackgrounds = async () => { const urls = [...DEFAULT_HOME_BG_URLS]; const ok = await pushHomeBackgroundsToServer(urls); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); toast.success(t("admin.toast.updated")); }; const openHomeBgPicker = (target: { kind: "append" } | { kind: "replace"; index: number }) => { homeBgPickRef.current = target; homeBgFileRef.current?.click(); }; const onHomeBgFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file || !file.type.startsWith("image/")) return; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; if (typeof dataUrl !== "string") return; const t = homeBgPickRef.current; if (t.kind === "append") { setHomeBgUrls((prev) => [...prev, dataUrl]); } else { setHomeBgUrls((prev) => { const next = [...prev]; if (t.index >= 0 && t.index < next.length) next[t.index] = dataUrl; return next; }); } }; reader.readAsDataURL(file); }; const onPairCoverFileChange = async (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file || !file.type.startsWith("image/")) return; const tid = toast.loading(t("admin.knowledge.uploading")); try { const url = await uploadKnowledgeMediaFile(file); toast.dismiss(tid); setPairImage(url); } catch (e) { toast.dismiss(tid); const desc = e instanceof Error ? e.message : String(e); toast.error(t("admin.knowledge.uploadFail"), { description: desc, duration: 12000, }); } }; const onPairVideoFileChange = async (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file || !file.type.startsWith("video/")) return; const tid = toast.loading(t("admin.knowledge.uploading")); try { const url = await uploadKnowledgeMediaFile(file); toast.dismiss(tid); setPairVideo(url); } catch (e) { toast.dismiss(tid); const desc = e instanceof Error ? e.message : String(e); toast.error(t("admin.knowledge.uploadFail"), { description: desc, duration: 12000, }); } }; const removeHomeBgAt = (index: number) => { setHomeBgUrls((prev) => prev.filter((_, i) => i !== index)); }; const saveWelcomeClick = async () => { const ok = await pushWelcomeToServer(welcome); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); toast.success(t("admin.toast.saved")); }; const resetWelcome = async () => { const ok = await pushWelcomeToServer({ ...WELCOME_DEFAULTS }); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); toast.success(t("admin.toast.updated")); }; const saveGuideClick = async () => { const overrides: GuideOverrides = { "zh-CN": { touchItems: linesToItems(guideZhTouch), softwareItems: linesToItems(guideZhSoft), }, en: { touchItems: linesToItems(guideEnTouch), softwareItems: linesToItems(guideEnSoft), }, bo: { touchItems: linesToItems(guideBoTouch), softwareItems: linesToItems(guideBoSoft), }, }; const ok = await pushGuideToServer(overrides); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); hydrateGuideEditors(); toast.success(t("admin.toast.saved")); }; const resetGuide = async () => { const ok = await pushGuideToServer({}); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); hydrateGuideEditors(); toast.success(t("admin.toast.updated")); }; const deleteKnowledge = async (id: string) => { const next = knowledgeList.filter((k) => k.id !== id); try { await pushKnowledgeToServer(next); } catch (e) { const desc = e instanceof Error ? e.message : String(e); toast.error(t("admin.knowledge.saveFailTitle"), { description: desc, duration: 14000, }); await pullServerState(); setKnowledgeList(loadKnowledgeEntries()); return; } await pullServerState(); setKnowledgeList(loadKnowledgeEntries()); }; const beginEditKnowledge = (item: KnowledgeEntry) => { setEditingKnowledgeId(item.id); setActiveTab("knowledge"); setPairTitle(item.title ?? ""); setPairType(item.type); setPairContent(item.content ?? ""); setPairTags((item.tags ?? []).join(",")); setPairImage(item.image ?? ""); setPairVideo(item.videoUrl ?? ""); setPairDate(item.date ?? new Date().toISOString().slice(0, 10)); toast.message(t("admin.toast.editMode")); }; const resetKnowledgeEditor = () => { setEditingKnowledgeId(null); setPairTitle(""); setPairContent(""); setPairTags(""); setPairImage(""); setPairVideo(""); setPairDate(new Date().toISOString().slice(0, 10)); setPairType("文字"); }; const addKnowledge = async () => { const title = pairTitle.trim(); if (!title) return; const tags = pairTags .split(/[,,;;]/) .map((t) => t.trim()) .filter(Boolean); const row: KnowledgeEntry = { id: editingKnowledgeId ?? newKnowledgeEntryId(), type: pairType, title, content: pairContent.trim() || "—", date: pairDate, tags: tags.length ? tags : ["未分类"], image: pairImage.trim() || undefined, videoUrl: pairVideo.trim() || undefined, }; const next = editingKnowledgeId ? knowledgeList.map((k) => (k.id === editingKnowledgeId ? row : k)) : [row, ...knowledgeList]; try { await pushKnowledgeToServer(next); } catch (e) { const desc = e instanceof Error ? e.message : String(e); toast.error(t("admin.knowledge.saveFailTitle"), { description: desc, duration: 14000, }); return; } await pullServerState(); setKnowledgeList(loadKnowledgeEntries()); toast.success(editingKnowledgeId ? t("admin.toast.updated") : t("admin.toast.saved")); resetKnowledgeEditor(); }; const resetKnowledgeDb = async () => { const fresh = DEFAULT_KNOWLEDGE_ENTRIES.map((e) => ({ ...e })); try { await pushKnowledgeToServer(fresh); } catch (e) { const desc = e instanceof Error ? e.message : String(e); toast.error(t("admin.knowledge.saveFailTitle"), { description: desc, duration: 14000, }); await pullServerState(); setKnowledgeList(loadKnowledgeEntries()); return; } await pullServerState(); setKnowledgeList(loadKnowledgeEntries()); toast.success(t("admin.toast.updated")); }; const saveVideoSource = async () => { const next: VideoSourceConfig = { protocol: videoSource.protocol, url: videoSource.url.trim(), note: videoSource.note.trim(), }; const ok = await pushVideoSourceToServer(next); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); setVideoSource(loadVideoSourceConfig()); toast.success(t("admin.toast.videoSaved")); }; const resetVideoSource = async () => { const next = { ...DEFAULT_VIDEO_SOURCE }; const ok = await pushVideoSourceToServer(next); if (!ok) { toast.error(t("admin.syncFail")); await pullServerState(); return; } await pullServerState(); setVideoSource(loadVideoSourceConfig()); toast.success(t("admin.toast.videoReset")); }; const handleLogin = (e: FormEvent) => { e.preventDefault(); if (loginUser.trim() === ADMIN_USERNAME && loginPass === ADMIN_PASSWORD) { try { sessionStorage.setItem(ADMIN_AUTH_KEY, "1"); } catch { // ignore storage failures } setIsAuthed(true); setLoginPass(""); toast.success(t("admin.toast.loginOk")); return; } toast.error(t("admin.toast.loginBad")); }; const logoutAdmin = () => { try { sessionStorage.removeItem(ADMIN_AUTH_KEY); } catch { // ignore storage failures } setIsAuthed(false); setLoginPass(""); toast.success(t("admin.toast.logoutOk")); }; const sidebarBtn = (id: AdminTab, icon: ReactNode, label: string) => ( ); const labelCls = "mb-1.5 block text-xs font-medium text-blue-200 sm:text-sm"; const inputCls = "w-full rounded-lg border border-white/20 bg-white/5 px-3 py-2 text-sm text-white placeholder-blue-500/80 focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-400/30 sm:px-4 sm:py-2.5 sm:text-base"; const textareaCls = "min-h-[120px] w-full rounded-lg border border-white/20 bg-white/5 px-3 py-2 font-mono text-xs leading-relaxed text-white placeholder-blue-500/80 focus:border-sky-400 focus:outline-none focus:ring-1 focus:ring-sky-400/30 sm:min-h-[140px] sm:px-4 sm:py-2.5 sm:text-sm"; if (!isAuthed) { return (

{t("nav.maintain")}

{t("admin.login.title")}

{t("admin.login.desc")}

setLoginUser(e.target.value)} autoComplete="username" placeholder={t("admin.login.userPh")} />
setLoginPass(e.target.value)} autoComplete="current-password" placeholder={t("admin.login.passPh")} />
); } return (

{t("nav.maintain")}

{activeTab === "background" && (

{t("admin.bg.help")}

{t("admin.bg.title")}
{homeBgUrls.map((url, i) => (
))}
)} {activeTab === "welcome" && (

{t("admin.welcome.help")}

{(["zh-CN", "en", "bo"] as const).map((key) => (