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, emitKioskUpdate, loadGuideOverrides, loadHomeBackgrounds, loadKnowledgeEntries, loadVideoSourceConfig, loadWelcome, mergeGuideLocales, resetVideoSourceConfig, saveGuideOverrides, saveHomeBackgrounds, saveKnowledgeEntries, saveVideoSourceConfig, saveWelcome, type GuideOverrides, type KnowledgeEntry, type VideoProtocol, type VideoSourceConfig, type WelcomeMessages, } from "../kioskStorage"; import { PAGE_CONTENT_INSET } from "../pageContentInset"; import { pushGuideToServer, pushHomeBackgroundsToServer, pushKnowledgeToServer, pushWelcomeToServer, shouldSyncAdminToServer, } from "../api/kioskApi"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; import { toast } from "sonner"; type AdminTab = | "background" | "welcome" | "guide" | "query" | "knowledge" | "stream"; /** 系统维护「信息查询」列表最多展示条数 */ const QUERY_LIST_PREVIEW_MAX = 6; 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 [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 [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 [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.slice(0, QUERY_LIST_PREVIEW_MAX), [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]; try { saveHomeBackgrounds(urls); toast.success("保存成功"); if (shouldSyncAdminToServer()) { const ok = await pushHomeBackgroundsToServer(urls); if (!ok) toast.error("已保存到本地,同步服务器失败"); } } catch { window.alert("保存失败:图片数据可能过大,请减少张数或压缩图片后再试。"); } }; const resetBackgrounds = () => { localStorage.removeItem("kiosk_bg_home_urls_v1"); setHomeBgUrls([...DEFAULT_HOME_BG_URLS]); emitKioskUpdate(); }; 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 = (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") setPairImage(dataUrl); }; reader.readAsDataURL(file); }; const onPairVideoFileChange = (e: ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file || !file.type.startsWith("video/")) return; const reader = new FileReader(); reader.onload = () => { const dataUrl = reader.result as string; if (typeof dataUrl === "string") setPairVideo(dataUrl); }; reader.readAsDataURL(file); }; const removeHomeBgAt = (index: number) => { setHomeBgUrls((prev) => prev.filter((_, i) => i !== index)); }; const saveWelcomeClick = async () => { saveWelcome(welcome); toast.success("保存成功"); if (shouldSyncAdminToServer()) { const ok = await pushWelcomeToServer(welcome); if (!ok) toast.error("已保存到本地,同步服务器失败"); } }; const resetWelcome = () => { localStorage.removeItem("kiosk_welcome_v1"); setWelcome({ ...WELCOME_DEFAULTS }); emitKioskUpdate(); }; 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), }, }; saveGuideOverrides(overrides); toast.success("保存成功"); if (shouldSyncAdminToServer()) { const ok = await pushGuideToServer(overrides); if (!ok) toast.error("已保存到本地,同步服务器失败"); } }; const resetGuide = () => { localStorage.removeItem("kiosk_guide_overrides_v1"); hydrateGuideEditors(); emitKioskUpdate(); }; const deleteKnowledge = async (id: string) => { const next = knowledgeList.filter((k) => k.id !== id); setKnowledgeList(next); saveKnowledgeEntries(next); if (shouldSyncAdminToServer()) { const ok = await pushKnowledgeToServer(next); if (!ok) toast.error("已保存到本地,同步服务器失败"); } }; const addKnowledge = async () => { const title = pairTitle.trim(); if (!title) return; const tags = pairTags .split(/[,,;;]/) .map((t) => t.trim()) .filter(Boolean); const row: KnowledgeEntry = { id: crypto.randomUUID(), type: pairType, title, content: pairContent.trim() || "—", date: pairDate, tags: tags.length ? tags : ["未分类"], image: pairImage.trim() || undefined, videoUrl: pairVideo.trim() || undefined, }; const next = [row, ...knowledgeList]; try { saveKnowledgeEntries(next); } catch { window.alert("保存失败:媒体数据可能过大,请压缩图片或缩短视频后再试。"); return; } setKnowledgeList(next); toast.success("保存成功"); if (shouldSyncAdminToServer()) { const ok = await pushKnowledgeToServer(next); if (!ok) toast.error("已保存到本地,同步服务器失败"); } setPairTitle(""); setPairContent(""); setPairTags(""); setPairImage(""); setPairVideo(""); }; const resetKnowledgeDb = async () => { localStorage.removeItem("kiosk_knowledge_db_v1"); const fresh = DEFAULT_KNOWLEDGE_ENTRIES.map((e) => ({ ...e })); saveKnowledgeEntries(fresh); setKnowledgeList(fresh); if (shouldSyncAdminToServer()) { const ok = await pushKnowledgeToServer(fresh); if (!ok) toast.error("已保存到本地,同步服务器失败"); } }; const saveVideoSource = () => { const next: VideoSourceConfig = { protocol: videoSource.protocol, url: videoSource.url.trim(), note: videoSource.note.trim(), }; saveVideoSourceConfig(next); setVideoSource(next); toast.success("视频源已保存"); }; const resetVideoSource = () => { resetVideoSourceConfig(); setVideoSource(loadVideoSourceConfig()); toast.success("已恢复默认(未配置)"); }; 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("登录成功"); return; } toast.error("账号或密码错误"); }; const logoutAdmin = () => { try { sessionStorage.removeItem(ADMIN_AUTH_KEY); } catch { // ignore storage failures } setIsAuthed(false); setLoginPass(""); toast.success("已退出登录"); }; 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 (

系统维护

管理员登录

请输入账号与密码后进入系统维护页面。

setLoginUser(e.target.value)} autoComplete="username" placeholder="请输入账号" />
setLoginPass(e.target.value)} autoComplete="current-password" placeholder="请输入密码" />
); } return (

系统维护

{activeTab === "background" && (

上传的图片按格子顺序轮播(先添加的先播放)。第一张同时用作全部内页底图。点击缩略图可更换该张;单张建议压缩后上传,避免超出浏览器存储上限。修改后请点「保存背景」。

首页轮播背景
{homeBgUrls.map((url, i) => (
))}
)} {activeTab === "welcome" && (

修改首页轮播欢迎语(中 / 英 / 藏)。

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