kioskStorage.ts 12.1 KB
import type { GuideLocale } from "./guideContent";

export const KIOSK_EVENT = "kiosk-settings-updated";

export function emitKioskUpdate() {
  window.dispatchEvent(new CustomEvent(KIOSK_EVENT));
}

/** 旧版本曾写入 localStorage 的 key,启动时清理,避免误以为仍在本地持久化 */
const LEGACY_LS_KEYS = [
  "kiosk_welcome_v1",
  "kiosk_bg_home_urls_v1",
  "kiosk_bg_carousel_urls_v1",
  "kiosk_guide_overrides_v1",
  "kiosk_knowledge_db_v1",
  "kiosk_video_source_v1",
] as const;

function clearLegacyKioskLocalStorage(): void {
  try {
    for (const k of LEGACY_LS_KEYS) {
      localStorage.removeItem(k);
    }
  } catch {
    // ignore
  }
}

clearLegacyKioskLocalStorage();

/** 展台数据仅驻内存,由 GET /api/kiosk/bundle 填充;保存一律走后端 API */
type MemoryCache = {
  homeBackgrounds: string[] | null;
  carouselBackgrounds: string[] | null;
  welcome: WelcomeMessages | null;
  guide: GuideOverrides | null;
  knowledge: KnowledgeEntry[] | null;
  videoSource: VideoSourceConfig | null;
};

const mem: MemoryCache = {
  homeBackgrounds: null,
  carouselBackgrounds: null,
  welcome: null,
  guide: null,
  knowledge: null,
  videoSource: null,
};

export const DEFAULT_HOME_BG_URLS: readonly string[] = [
  "/backgrounds/daocheng-radio-array.png",
  "/backgrounds/daocheng-campus-aerial.png",
];

export const DEFAULT_CAROUSEL_BG_URLS: readonly string[] = [
  "https://images.unsplash.com/photo-1419242902214-272b3f66ee7a?auto=format&fit=crop&w=1920&q=80",
  "https://images.unsplash.com/photo-1462331940025-496dfbfc7564?auto=format&fit=crop&w=1920&q=80",
  "https://images.unsplash.com/photo-1543722530-d2c3201371e7?auto=format&fit=crop&w=1920&q=80",
  "https://images.unsplash.com/photo-1444703686971-f7864ae02629?auto=format&fit=crop&w=1920&q=80",
  "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1920&q=80",
  "https://images.unsplash.com/photo-1464802686167-b939a6910659?auto=format&fit=crop&w=1920&q=80",
];

export type WelcomeMessages = Record<"zh-CN" | "en" | "bo", string>;

export function loadHomeBackgrounds(): string[] {
  if (!mem.homeBackgrounds?.length) return [...DEFAULT_HOME_BG_URLS];
  return mem.homeBackgrounds.filter((u) => typeof u === "string" && u.length > 0);
}

export function loadCarouselBackgrounds(): string[] {
  if (!mem.carouselBackgrounds?.length) return [...DEFAULT_CAROUSEL_BG_URLS];
  return mem.carouselBackgrounds.filter((u) => typeof u === "string" && u.length > 0);
}

export function loadWelcome(defaults: WelcomeMessages): WelcomeMessages {
  if (!mem.welcome) return { ...defaults };
  const w = mem.welcome;
  return {
    "zh-CN": typeof w["zh-CN"] === "string" ? w["zh-CN"] : defaults["zh-CN"],
    en: typeof w.en === "string" ? w.en : defaults.en,
    bo: typeof w.bo === "string" ? w.bo : defaults.bo,
  };
}

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

export type GuideLocalePatch = {
  intro?: string;
  touchItems?: string[];
  softwareItems?: string[];
};

export type GuideOverrides = Partial<Record<GuideLangKey, GuideLocalePatch>>;

export function loadGuideOverrides(): GuideOverrides {
  if (!mem.guide) return {};
  return mem.guide;
}

export function mergeGuideLocales(
  base: readonly GuideLocale[],
  overrides: GuideOverrides
): GuideLocale[] {
  return base.map((loc) => {
    const key = loc.htmlLang as GuideLangKey;
    const p = overrides[key];
    if (!p) return { ...loc };
    return {
      ...loc,
      intro: p.intro ?? loc.intro,
      touchItems: p.touchItems ?? loc.touchItems,
      softwareItems: p.softwareItems ?? loc.softwareItems,
    };
  });
}

export type KnowledgeEntry = {
  id: string;
  type: "文字" | "图片" | "视频";
  title: string;
  content: string;
  date: string;
  tags: string[];
  image?: string;
  videoUrl?: string;
};

export function newKnowledgeEntryId(): string {
  try {
    if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
      return crypto.randomUUID();
    }
  } catch {
    // ignore
  }
  const t = Date.now().toString(36);
  const r = () => Math.random().toString(36).slice(2, 10);
  return `k_${t}_${r()}${r()}`;
}

export type VideoProtocol = "HLS" | "WebRTC" | "RTSP";

export type VideoSourceConfig = {
  protocol: VideoProtocol;
  url: string;
  note: string;
};

export const DEFAULT_VIDEO_SOURCE: VideoSourceConfig = {
  protocol: "HLS",
  url: "",
  note: "",
};

export const DEFAULT_KNOWLEDGE_ENTRIES: KnowledgeEntry[] = [
  {
    id: "k1",
    type: "文字",
    title: "黑洞的形成与演化",
    content:
      "黑洞是宇宙中最神秘的天体之一,当大质量恒星耗尽核燃料后,会发生引力坍缩并形成事件视界。事件视界内的光与物质无法逃逸,外部可通过吸积盘辐射与引力波信号间接探测。",
    date: "2026-03-15",
    tags: ["黑洞", "天体物理"],
    image: "/knowledge/black-hole.png",
  },
  {
    id: "k2",
    type: "图片",
    title: "猎户座大星云观测图",
    content:
      "使用稻城天文台2.5米望远镜拍摄的猎户座大星云高清图像,展示了恒星诞生的壮观景象。图像涵盖电离氢区与年轻星团,可用于公众科普与教学对比不同时段观测条件。",
    date: "2026-03-20",
    tags: ["星云", "观测数据"],
    image:
      "https://images.unsplash.com/photo-1628632642012-290ae1f4b1ac?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&w=400",
  },
  {
    id: "k3",
    type: "视频",
    title: "日全食观测全程记录",
    content:
      "2026年3月稻城地区日全食的完整观测记录,包含贝利珠、日冕等精彩现象。片中含安全观测提示与关键时间节点标注,适合展厅循环播放与导览讲解引用。",
    date: "2026-03-21",
    tags: ["日食", "观测记录"],
    image: "/knowledge/total-solar-eclipse.png",
  },
  {
    id: "k4",
    type: "文字",
    title: "暗物质探测最新进展",
    content:
      "稻城天文台参与的暗物质探测项目取得重大突破,可能为理解宇宙组成提供新线索。团队结合多波段数据与统计方法,正在验证新的信号模型并推进国际合作数据共享。",
    date: "2026-03-18",
    tags: ["暗物质", "科研进展"],
    image: "/knowledge/dark-matter.png",
  },
  {
    id: "k5",
    type: "图片",
    title: "银河中心区域深空摄影",
    content:
      "长曝光拍摄的银河中心区域,清晰展现了恒星密集区和暗物质带。后期校准了平场与偏置帧,可在高对比度显示器上呈现银心附近尘埃带的层次细节。",
    date: "2026-03-10",
    tags: ["银河系", "深空摄影"],
    image:
      "https://images.unsplash.com/photo-1631594274394-279ab758c1ed?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&w=400",
  },
  {
    id: "k6",
    type: "视频",
    title: "天文望远镜操作教程",
    content:
      "详细介绍如何使用稻城天文台的大型望远镜进行天体观测和数据采集。内容涵盖指向校准、导星设置、曝光序列与安全收镜流程,并附常见故障排查要点。",
    date: "2026-03-05",
    tags: ["教程", "望远镜"],
    image: "/knowledge/telescope.png",
  },
  {
    id: "k7",
    type: "文字",
    title: "火星冲日与行星观测要点",
    content:
      "冲日期间火星视直径最大、亮度最高,是行星观测与摄影的窗口期,需注意大气视宁度与设备校准。建议选择透明度高、气流稳定的夜晚,并预留色散与滤镜的对比实验时间。",
    date: "2026-03-12",
    tags: ["行星", "观测记录"],
    image: "/knowledge/planet-observation.png",
  },
  {
    id: "k8",
    type: "图片",
    title: "月球环形山特写",
    content:
      "高分辨率月面成像,展示哥白尼环形山及周边辐射纹细节,可用于科普对比不同时相月貌。拍摄采用短曝光叠加,突出环形山壁与中央峰的明暗过渡。",
    date: "2026-03-08",
    tags: ["月球", "观测数据"],
    image: "/knowledge/moon-crater.png",
  },
  {
    id: "k9",
    type: "视频",
    title: "流星雨观测指南短片",
    content:
      "英仙座与双子座流星雨观测技巧:光害规避、曝光参数与记录表格示例,适合公众开放夜播放。片中归纳了辐射点高度与流量峰值时段,便于安排户外讲解动线。",
    date: "2026-03-01",
    tags: ["流星雨", "教程"],
    image: "/knowledge/meteor-shower.png",
  },
  {
    id: "k10",
    type: "文字",
    title: "系外行星凌星测光简介",
    content:
      "当行星从恒星盘面前方经过会造成微小亮度下降,稻城台站可参与后续跟进与科普数据展示。简介测光曲线形态与噪声来源,帮助观众理解“凌星”信号的物理含义。",
    date: "2026-02-28",
    tags: ["系外行星", "科研进展"],
    image: "/knowledge/exoplanet.png",
  },
];

/** 未从服务器拉取前,知识库列表为空(不读本地演示数据) */
export function loadKnowledgeEntries(): KnowledgeEntry[] {
  if (!mem.knowledge) return [];
  return mem.knowledge.filter(
    (x): x is KnowledgeEntry => x && typeof x.id === "string" && typeof x.title === "string"
  );
}

export function loadVideoSourceConfig(): VideoSourceConfig {
  if (!mem.videoSource) return { ...DEFAULT_VIDEO_SOURCE };
  return { ...mem.videoSource };
}

export type KioskServerBundle = {
  homeBackgrounds: string[];
  carouselBackgrounds?: string[];
  welcome: WelcomeMessages;
  guide: GuideOverrides | Record<string, never> | unknown[];
  knowledge: Array<Record<string, unknown>>;
  videoSource?: VideoSourceConfig | Record<string, unknown>;
};

function normalizeKnowledgeRow(row: Record<string, unknown>): KnowledgeEntry | null {
  if (typeof row.id !== "string" || typeof row.title !== "string") return null;
  const t = row.type;
  const type: KnowledgeEntry["type"] =
    t === "图片" || t === "视频" || t === "文字" ? t : "文字";
  const tagsRaw = row.tags;
  const tags = Array.isArray(tagsRaw)
    ? tagsRaw.filter((x): x is string => typeof x === "string")
    : [];
  const videoRaw = row.videoUrl ?? row.video_url;
  const imageRaw = row.image;
  return {
    id: row.id,
    type,
    title: row.title,
    content: typeof row.content === "string" ? row.content : "",
    date: typeof row.date === "string" ? row.date : "",
    tags,
    image: typeof imageRaw === "string" && imageRaw ? imageRaw : undefined,
    videoUrl: typeof videoRaw === "string" && videoRaw ? videoRaw : undefined,
  };
}

function normalizeVideoSource(raw: unknown): VideoSourceConfig | null {
  if (!raw || typeof raw !== "object") return null;
  const o = raw as Record<string, unknown>;
  const p = o.protocol;
  const protocol: VideoSourceConfig["protocol"] =
    p === "WebRTC" || p === "RTSP" || p === "HLS" ? p : "HLS";
  return {
    protocol,
    url: typeof o.url === "string" ? o.url : "",
    note: typeof o.note === "string" ? o.note : "",
  };
}

/** 用 bundle 覆盖内存中的展台数据(唯一数据源入口,不写 localStorage) */
export function applyServerBundle(data: KioskServerBundle): void {
  const bg = data.homeBackgrounds;
  if (Array.isArray(bg)) {
    mem.homeBackgrounds = bg.filter((u) => typeof u === "string" && u !== "");
  }
  const car = data.carouselBackgrounds;
  if (Array.isArray(car) && car.length > 0) {
    mem.carouselBackgrounds = car.filter((u): u is string => typeof u === "string" && u !== "");
  }
  const w = data.welcome;
  if (w && typeof w["zh-CN"] === "string" && typeof w.en === "string" && typeof w.bo === "string") {
    mem.welcome = {
      "zh-CN": w["zh-CN"],
      en: w.en,
      bo: w.bo,
    };
  }
  const g = data.guide;
  if (Array.isArray(g) && g.length === 0) {
    mem.guide = {};
  } else if (g && typeof g === "object" && !Array.isArray(g)) {
    mem.guide = g as GuideOverrides;
  }
  const kn = data.knowledge;
  if (Array.isArray(kn)) {
    const list: KnowledgeEntry[] = [];
    for (const row of kn) {
      if (!row || typeof row !== "object") continue;
      const e = normalizeKnowledgeRow(row as Record<string, unknown>);
      if (e) list.push(e);
    }
    mem.knowledge = list;
  }
  const vs = normalizeVideoSource(data.videoSource);
  if (vs) {
    mem.videoSource = vs;
  }
  emitKioskUpdate();
}