i18n.tsx 14.8 KB
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { KIOSK_EVENT } from "./kioskStorage";

export type AppLang = "zh-CN" | "en" | "bo";

const LS_LANG = "kiosk_lang_v1";

type Dict = Record<string, Record<AppLang, string>>;

const DICT: Dict = {
  // Brand / header
  "brand.title": {
    "zh-CN": "稻城天文台信息查询",
    en: "Daocheng Observatory Information Query",
    bo: "འདབ་ཆུ་གནམ་གཟིགས་ལྟེ་གནས་ཀྱི་ཆ་འཕྲིན་འཚོལ་ཞིབ",
  },

  // Nav
  "nav.home": { "zh-CN": "首页", en: "Home", bo: "མདུན་ངོས" },
  "nav.tour": { "zh-CN": "共享天文台", en: "Shared Observatory", bo: "མཉམ་སྤྱོད་གནམ་གཟིགས་ལྟེ་གནས" },
  "nav.planetarium": { "zh-CN": "天文馆导览", en: "Planetarium Guide", bo: "གནམ་གཟིགས་ཁང་སྟོན་ལམ" },
  "nav.wiki": { "zh-CN": "天文知识库", en: "Astronomy Library", bo: "གནམ་གཟིགས་ཤེས་བྱའི་མཛོད" },
  "nav.web": { "zh-CN": "天文馆官网", en: "Official Website", bo: "གཞུང་དྲ་ཚིགས" },
  "nav.guide": { "zh-CN": "操作指南", en: "User Guide", bo: "སྤྱོད་སྟོན" },
  "nav.maintain": { "zh-CN": "系统维护", en: "Maintenance", bo: "རྒྱུད་ཁོངས་སྲུང་སྐྱོང" },

  // Search (knowledge library)
  "search.title": { "zh-CN": "天文知识库", en: "Astronomy Library", bo: "གནམ་གཟིགས་ཤེས་བྱའི་མཛོད" },
  "search.placeholder": { "zh-CN": "搜索天文知识...", en: "Search...", bo: "འཚོལ་ཞིབ..." },
  "search.button": { "zh-CN": "搜索", en: "Search", bo: "འཚོལ" },
  "search.foundPrefix": { "zh-CN": "找到", en: "Found", bo: "ཐོབ་པ" },
  "search.foundSuffix": { "zh-CN": "条结果", en: "results", bo: "གྲངས" },
  "search.noneTitle": { "zh-CN": "未找到匹配的结果", en: "No matching results", bo: "མ་ཐོབ" },
  "search.noneHint": { "zh-CN": "请尝试其他搜索词或点击上方关键词", en: "Try other keywords.", bo: "ཚིག་གཞན་ཞིག་འཚོལ" },
  "pager.label": { "zh-CN": "分页", en: "Pagination", bo: "ཤོག་གྲངས" },
  "pager.prev": { "zh-CN": "上一页", en: "Prev", bo: "གོང་ཤོག" },
  "pager.next": { "zh-CN": "下一页", en: "Next", bo: "འོག་ཤོག" },
  "pager.page": { "zh-CN": "第", en: "Page", bo: "ཤོག" },

  // Dialog
  "dialog.loading": { "zh-CN": "正在加载官网…", en: "Loading…", bo: "འཇུག་བཞིན…" },

  // Admin login / maintenance
  "admin.login.title": { "zh-CN": "管理员登录", en: "Admin Login", bo: "དོ་དམ་པ་ནང་འཛུལ" },
  "admin.login.desc": { "zh-CN": "请输入账号与密码后进入系统维护页面。", en: "Enter credentials to continue.", bo: "ཐོ་འགོད་ཆ་འཕྲིན་འཇུག" },
  "admin.login.user": { "zh-CN": "账号", en: "Username", bo: "མིང" },
  "admin.login.pass": { "zh-CN": "密码", en: "Password", bo: "གསང་ཨང" },
  "admin.login.userPh": { "zh-CN": "请输入账号", en: "Enter username", bo: "མིང་འཇུག" },
  "admin.login.passPh": { "zh-CN": "请输入密码", en: "Enter password", bo: "གསང་ཨང་འཇུག" },
  "admin.login.btn": { "zh-CN": "登录", en: "Sign in", bo: "ནང་འཛུལ" },
  "admin.logout": { "zh-CN": "退出登录", en: "Sign out", bo: "ཕྱིར་འབུད" },
  "admin.langSwitch": { "zh-CN": "语言切换", en: "Language", bo: "སྐད་ཡིག" },
  "admin.tab.background": { "zh-CN": "背景更换", en: "Background", bo: "རྒྱབ་ལྗོངས" },
  "admin.tab.welcome": { "zh-CN": "欢迎词", en: "Welcome", bo: "དགའ་བསུ" },
  "admin.tab.guide": { "zh-CN": "操作指南", en: "Guide", bo: "སྤྱོད་སྟོན" },
  "admin.tab.query": { "zh-CN": "信息查询", en: "Query", bo: "འཚོལ་ཞིབ" },
  "admin.tab.knowledge": { "zh-CN": "天文信息配对", en: "Knowledge Editor", bo: "ཤེས་བྱ་སྒྲིག" },
  "admin.tab.stream": { "zh-CN": "视频源配置", en: "Video Source", bo: "བརྙན་མཐུད" },
  "admin.knowledge.add": { "zh-CN": "添加到知识库", en: "Add to library", bo: "ཤེས་བྱའི་མཛོད་ལ་སྣོན" },
  "admin.knowledge.saveEdit": { "zh-CN": "保存修改", en: "Save changes", bo: "བཅོས་པ་ཉར" },
  "admin.knowledge.uploading": { "zh-CN": "正在上传到服务器…", en: "Uploading…", bo: "སྐྱེལ་བཞིན" },
  "admin.knowledge.uploadFail": { "zh-CN": "上传到服务器失败", en: "Upload failed", bo: "སྐྱེལ་མ་ཐུབ" },
  "admin.knowledge.saveFailTitle": { "zh-CN": "知识库保存失败", en: "Knowledge save failed", bo: "ཤེས་བྱ་ཉར་མ་ཐུབ" },
  "admin.toast.saved": { "zh-CN": "保存成功", en: "Saved", bo: "ཉར་ཚར" },
  "admin.toast.updated": { "zh-CN": "更新成功", en: "Updated", bo: "བཅོས་ཚར" },
  "admin.toast.loginOk": { "zh-CN": "登录成功", en: "Signed in", bo: "ནང་འཛུལ་ཚར" },
  "admin.toast.loginBad": { "zh-CN": "账号或密码错误", en: "Invalid username or password", bo: "མིང/གསང་ཨང་ནོར" },
  "admin.toast.logoutOk": { "zh-CN": "已退出登录", en: "Signed out", bo: "ཕྱིར་འབུད་ཚར" },
  "admin.toast.editMode": { "zh-CN": "已进入编辑模式", en: "Edit mode", bo: "བཟོ་བཅོས་རྣམ་པ" },
  "admin.toast.videoSaved": { "zh-CN": "视频源已保存", en: "Video source saved", bo: "བརྙན་མཐུད་ཉར་ཚར" },
  "admin.toast.videoReset": { "zh-CN": "已恢复默认(未配置)", en: "Reset to default", bo: "སྔོན་སྒྲིག་ལ་ལོག" },
  "admin.syncFail": { "zh-CN": "同步服务器失败,请检查网络、后端日志与 ADMIN_API_TOKEN", en: "Server sync failed", bo: "མཉམ་སྒྲིག་མ་ཐུབ" },
  "admin.alert.saveBgFail": { "zh-CN": "保存失败:图片数据可能过大,请减少张数或压缩图片后再试。", en: "Save failed: images too large.", bo: "ཉར་མ་ཐུབ། དཔེ་རིས་ཆེ་དྲགས" },
  "admin.alert.saveMediaFail": { "zh-CN": "媒体处理失败(详见下方提示)", en: "Media operation failed", bo: "ཉར་མ་ཐུབ།" },
  "admin.bg.help": { "zh-CN": "上传的图片按格子顺序轮播(先添加的先播放)。第一张同时用作全部内页底图。点击缩略图可更换该张;单张建议压缩后上传,避免超出浏览器存储上限。修改后请点「保存背景」。", en: "Uploaded images rotate in order. The first image is used as inner-page background. Click a thumbnail to replace it. Compress images to avoid storage limits, then click “Save background”.", bo: "འདྲ་པར་སྣོན་པའི་གོ་རིམ་ལྟར་འཁོར་སྐྱོད། མཐའ་མའི་དཔེ་རིས་དང་པོས་ནང་ངོས་རྒྱབ་ལྗོངས་བྱེད།" },
  "admin.bg.title": { "zh-CN": "首页轮播背景", en: "Home backgrounds", bo: "མདུན་ངོས་རྒྱབ་ལྗོངས" },
  "admin.bg.upload": { "zh-CN": "上传图片", en: "Upload image", bo: "དཔེ་རིས་སྣོན" },
  "admin.bg.save": { "zh-CN": "保存背景", en: "Save background", bo: "རྒྱབ་ལྗོངས་ཉར" },
  "admin.bg.noPrefix": { "zh-CN": "第", en: "No.", bo: "ཨང་" },
  "admin.bg.noSuffix": { "zh-CN": "张", en: "", bo: "" },
  "admin.bg.deletePrefix": { "zh-CN": "删除第", en: "Delete No.", bo: "ཨང་" },
  "admin.bg.deleteSuffix": { "zh-CN": "张背景", en: "background", bo: "རྒྱབ་ལྗོངས་སུབ" },
  "admin.bg.srPick": { "zh-CN": "选择首页背景图片文件", en: "Choose background image file", bo: "རྒྱབ་ལྗོངས་དཔེ་རིས་འདེམས" },
  "admin.resetDefault": { "zh-CN": "恢复默认", en: "Reset", bo: "སྔོན་སྒྲིག་ལ་ལོག" },
  "admin.common.clickReplace": { "zh-CN": "点击更换", en: "Click to replace", bo: "བསྣུན་ནས་བརྗེ" },
  "admin.welcome.help": { "zh-CN": "修改首页轮播欢迎语(中 / 英 / 藏)。", en: "Edit rotating welcome messages (ZH/EN/BO).", bo: "དགའ་བསུ་ཚིག་བཟོ་བཅོས" },
  "admin.welcome.save": { "zh-CN": "保存欢迎词", en: "Save welcome", bo: "དགའ་བསུ་ཉར" },
  "admin.guide.help": { "zh-CN": "触屏说明 / 软件说明每条一行。保存后访客端「操作指南」页立即生效。", en: "One instruction per line. Changes apply immediately to the Guide page.", bo: "ཚན་པ་རེ་རེ་ཐིག་རེ། ཉར་རྗེས་འཕྲལ་དུ་ལས་སྐྱོང" },
  "admin.guide.save": { "zh-CN": "保存操作指南", en: "Save guide", bo: "སྤྱོད་སྟོན་ཉར" },
  "admin.query.placeholder": { "zh-CN": "标题或正文", en: "Title or content", bo: "མཚན/ནང་དོན" },
  "admin.query.all": { "zh-CN": "全部", en: "All", bo: "ཚང་མ" },
  "admin.query.keyword": { "zh-CN": "关键词", en: "Keyword", bo: "ཚིག་གནད" },
  "admin.query.type": { "zh-CN": "类型", en: "Type", bo: "རིགས" },
  "admin.query.tag": { "zh-CN": "标签", en: "Tag", bo: "རྟགས" },
  "admin.query.help": { "zh-CN": "对当前知识库条目做关键词、类型、标签筛选(与前台「天文知识库」同一数据源)。", en: "Filter library entries by keyword, type, and tag (same data source as the Library page).", bo: "ཤེས་བྱ་མཛོད་ཀྱི་ཚན་པ་ལ་ཚིག་གནད/རིགས/རྟགས་བཤེར" },
  "admin.query.totalPrefix": { "zh-CN": "共", en: "Total", bo: "བསྡོམས" },
  "admin.query.itemsSuffix": { "zh-CN": "条", en: "items", bo: "རྣམ་པ" },
  "admin.query.filteredPrefix": { "zh-CN": "筛选结果", en: "Filtered", bo: "བཤེར་འབྲས" },
  "admin.type.text": { "zh-CN": "文字", en: "Text", bo: "ཡིག" },
  "admin.type.image": { "zh-CN": "图片", en: "Image", bo: "པར" },
  "admin.type.video": { "zh-CN": "视频", en: "Video", bo: "བརྙན" },
  "admin.knowledge.help": { "zh-CN": "向知识库添加文字、图片或视频类条目;保存后可在「天文知识库」前台检索。封面与视频会先以 multipart 上传到服务器磁盘(单文件最大约 2GB,适合多数十分钟以内视频),数据库仅存路径;服务器需相应调大 PHP 与 Nginx 上传限制。", en: "Cover and video upload to the server first (up to ~2GB each); only paths are stored in the database. Raise PHP/Nginx limits on the server.", bo: "ཤེས་བྱ་མཛོད་ལ་ཡིག/པར/བརྙན་སྣོན།" },
  "admin.knowledge.resetSample": { "zh-CN": "重置为示例数据", en: "Reset to sample data", bo: "དཔེ་མཚོན་ལ་ལོག" },
  "admin.knowledge.srCover": { "zh-CN": "选择知识库条目封面图", en: "Choose cover image", bo: "ཁ་བྱང་དཔེ་རིས་འདེམས" },
  "admin.knowledge.srVideo": { "zh-CN": "选择知识库条目视频文件", en: "Choose video file", bo: "བརྙན་ཡིག་ཆ་འདེམས" },
  "admin.knowledge.title": { "zh-CN": "标题", en: "Title", bo: "མཚན" },
  "admin.knowledge.type": { "zh-CN": "类型", en: "Type", bo: "རིགས" },
  "admin.knowledge.date": { "zh-CN": "日期", en: "Date", bo: "ཟླ་ཚེས" },
  "admin.knowledge.content": { "zh-CN": "正文 / 摘要", en: "Content / Summary", bo: "ནང་དོན" },
  "admin.knowledge.tags": { "zh-CN": "标签(逗号分隔)", en: "Tags (comma separated)", bo: "རྟགས" },
  "admin.knowledge.tagsPh": { "zh-CN": "例如:星云, 观测", en: "e.g. Nebula, Observation", bo: "དཔེར་ན..." },
  "admin.knowledge.coverOpt": { "zh-CN": "封面图(可选)", en: "Cover (optional)", bo: "ཁ་བྱང (གདམ་ཁ)" },
  "admin.knowledge.videoOpt": { "zh-CN": "视频文件(可选)", en: "Video (optional)", bo: "བརྙན (གདམ་ཁ)" },
  "admin.knowledge.clearCover": { "zh-CN": "清除封面图", en: "Clear cover", bo: "ཁ་བྱང་སུབ" },
  "admin.knowledge.clearVideo": { "zh-CN": "清除视频", en: "Clear video", bo: "བརྙན་སུབ" },
  "admin.stream.save": { "zh-CN": "保存视频源", en: "Save video source", bo: "བརྙན་མཐུད་ཉར" },
  "common.edit": { "zh-CN": "编辑", en: "Edit", bo: "བཟོ་བཅོས" },
  "common.delete": { "zh-CN": "删除", en: "Delete", bo: "སུབ" },
  "common.save": { "zh-CN": "保存", en: "Save", bo: "ཉར" },
  "common.cancel": { "zh-CN": "取消", en: "Cancel", bo: "ཕྱིར་འཐེན" },
};

function loadLang(): AppLang {
  try {
    const raw = localStorage.getItem(LS_LANG);
    if (raw === "zh-CN" || raw === "en" || raw === "bo") return raw;
  } catch {
    // ignore
  }
  return "zh-CN";
}

function saveLang(lang: AppLang) {
  try {
    localStorage.setItem(LS_LANG, lang);
  } catch {
    // ignore
  }
  try {
    window.dispatchEvent(new Event(KIOSK_EVENT));
  } catch {
    // ignore
  }
}

type Ctx = {
  lang: AppLang;
  setLang: (lang: AppLang) => void;
  t: (key: string) => string;
};

const I18nContext = createContext<Ctx | null>(null);

export function I18nProvider({ children }: { children: React.ReactNode }) {
  const [rev, setRev] = useState(0);
  const [lang, setLangState] = useState<AppLang>(() => loadLang());

  useEffect(() => {
    const h = () => setRev((r) => r + 1);
    window.addEventListener(KIOSK_EVENT, h);
    window.addEventListener("storage", h);
    return () => {
      window.removeEventListener(KIOSK_EVENT, h);
      window.removeEventListener("storage", h);
    };
  }, []);

  useEffect(() => {
    const next = loadLang();
    setLangState(next);
  }, [rev]);

  useEffect(() => {
    document.documentElement.lang = lang;
  }, [lang]);

  const setLang = useCallback((next: AppLang) => {
    saveLang(next);
    setLangState(next);
  }, []);

  const t = useCallback(
    (key: string) => {
      const row = DICT[key];
      if (!row) return key;
      return row[lang] ?? row["zh-CN"] ?? key;
    },
    [lang],
  );

  const value = useMemo(() => ({ lang, setLang, t }), [lang, setLang, t]);

  return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}

export function useI18n() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useI18n must be used within I18nProvider");
  return ctx;
}