SearchPage.tsx 14.9 KB
import { useState, useEffect, useMemo, useRef } from "react";
import {
  Search,
  FileText,
  Image,
  Video,
  Play,
  ChevronLeft,
  ChevronRight,
  Library,
} from "lucide-react";
import { KIOSK_EVENT, loadKnowledgeEntries, type KnowledgeEntry } from "../kioskStorage";
import { PAGE_CONTENT_INSET } from "../pageContentInset";

/** 搜索框下方固定展示的关键词(顺序与文案一致) */
const QUICK_KEYWORDS = [
  "天体物理",
  "教程",
  "日食",
  "星云",
  "暗物质",
  "望远镜",
  "深空摄影",
] as const;

/** 每页条数(大屏约 5 列×2 行,共 10 条) */
const PAGE_SIZE = 10;

function buildPageList(totalPages: number, currentPage: number): (number | "ellipsis")[] {
  if (totalPages <= 12) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }
  const set = new Set<number>([1, totalPages, currentPage]);
  for (let p = currentPage - 2; p <= currentPage + 2; p++) {
    if (p >= 1 && p <= totalPages) set.add(p);
  }
  const sorted = [...set].sort((a, b) => a - b);
  const out: (number | "ellipsis")[] = [];
  for (let i = 0; i < sorted.length; i++) {
    const p = sorted[i]!;
    if (i > 0 && p - sorted[i - 1]! > 1) {
      out.push("ellipsis");
    }
    out.push(p);
  }
  return out;
}

function matchesQuery(item: KnowledgeEntry, raw: string): boolean {
  const q = raw.trim();
  if (q === "") return true;
  const lower = q.toLowerCase();
  if (item.title.toLowerCase().includes(lower) || item.content.toLowerCase().includes(lower)) {
    return true;
  }
  return item.tags.some((t) => t.includes(q) || t.toLowerCase().includes(lower));
}

export function SearchPage() {
  const [dbRev, setDbRev] = useState(0);
  const [searchQuery, setSearchQuery] = useState("");
  const [page, setPage] = useState(1);
  const searchInputRef = useRef<HTMLInputElement>(null);

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

  const searchResults = useMemo(() => loadKnowledgeEntries(), [dbRev]);

  const filteredResults = useMemo(
    () => searchResults.filter((item) => matchesQuery(item, searchQuery)),
    [searchResults, searchQuery]
  );

  const totalPages = Math.max(1, Math.ceil(filteredResults.length / PAGE_SIZE));

  useEffect(() => {
    setPage(1);
  }, [searchQuery, dbRev]);

  useEffect(() => {
    setPage((p) => Math.min(p, totalPages));
  }, [totalPages]);

  const currentPage = Math.min(page, totalPages);
  const pageItems = filteredResults.slice(
    (currentPage - 1) * PAGE_SIZE,
    currentPage * PAGE_SIZE
  );

  const toggleKeyword = (word: string) => {
    setSearchQuery((prev) => (prev.trim() === word ? "" : word));
  };

  const getTypeIcon = (type: string) => {
    switch (type) {
      case "文字":
        return FileText;
      case "图片":
        return Image;
      case "视频":
        return Video;
      default:
        return FileText;
    }
  };

  const goPage = (p: number) => {
    setPage(Math.max(1, Math.min(p, totalPages)));
  };

  return (
    <div
      className={`flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden ${PAGE_CONTENT_INSET}`}
    >
      <div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden">
        {/* 左:标题 · 右:搜索框,关键词仅在搜索框下方 */}
        <div className="shrink-0 pb-2">
          <div className="flex w-full min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
            <div className="flex min-w-0 shrink-0 items-center gap-2 text-xl font-bold text-white sm:text-2xl">
              <Library className="h-6 w-6 shrink-0 text-sky-400" aria-hidden />
              <span className="truncate">天文知识库</span>
            </div>
            <div className="flex w-full min-w-0 flex-col gap-1.5 sm:ml-auto sm:min-w-0 sm:flex-1 sm:max-w-[min(38rem,calc(100vw-2rem))] md:max-w-[min(44rem,calc(100vw-2.5rem))]">
              <form
                className="flex w-full min-w-0 shrink-0 gap-2"
                onSubmit={(e) => {
                  e.preventDefault();
                  searchInputRef.current?.blur();
                }}
              >
                <div className="relative min-w-0 flex-1">
                  <input
                    ref={searchInputRef}
                    type="search"
                    name="q"
                    value={searchQuery}
                    onChange={(e) => setSearchQuery(e.target.value)}
                    placeholder="搜索天文知识..."
                    autoComplete="off"
                    className="w-full rounded-lg border border-white/25 bg-transparent py-1.5 pl-8 pr-2.5 text-xs text-white placeholder-blue-300/80 transition-all focus:border-sky-400/80 focus:outline-none focus:ring-1 focus:ring-sky-400/30 sm:py-2 sm:pl-9 sm:text-sm"
                  />
                  <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-blue-300/90 sm:left-3 sm:h-4 sm:w-4" />
                </div>
                <button
                  type="submit"
                  className="shrink-0 rounded-lg bg-sky-500/90 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-sky-400/95 active:bg-sky-500 sm:px-4 sm:py-2 sm:text-sm"
                >
                  搜索
                </button>
              </form>
              <div className="flex max-h-[2.125rem] flex-wrap gap-x-1 gap-y-0 overflow-hidden sm:max-h-[2.375rem] sm:gap-x-1.5">
                {QUICK_KEYWORDS.map((word) => {
                  const active = searchQuery.trim() === word;
                  return (
                    <button
                      key={word}
                      type="button"
                      onClick={() => toggleKeyword(word)}
                      className={`shrink-0 border-0 bg-transparent px-0 py-0 text-left !text-[11px] font-normal leading-snug transition-colors sm:!text-[12px] ${
                        active
                          ? "font-semibold text-sky-300 underline decoration-sky-400/80 underline-offset-2"
                          : "text-blue-100/90 hover:text-white"
                      }`}
                    >
                      {word}
                    </button>
                  );
                })}
              </div>
            </div>
          </div>
        </div>

        {/* 搜索结果 + 分页 */}
        <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
          <div className="mb-1.5 shrink-0">
            <h3 className="text-sm font-medium text-white sm:text-base">
              找到 <span className="text-sky-400">{filteredResults.length}</span> 条结果
            </h3>
          </div>

          {filteredResults.length > 0 && (
            <>
              <div className="grid min-h-0 flex-1 auto-rows-max content-start grid-cols-2 gap-2 overflow-hidden sm:grid-cols-3 md:grid-cols-5 md:gap-2">
                {pageItems.map((result) => {
                  const Icon = getTypeIcon(result.type);
                  return (
                    <article
                      key={result.id}
                      className="group flex min-h-0 min-w-0 flex-col overflow-hidden rounded-lg border border-white/20 bg-white/10 transition-all hover:border-sky-400/45 hover:shadow-md hover:shadow-sky-400/10"
                    >
                      <div className="relative h-[8.75rem] w-full shrink-0 overflow-hidden bg-zinc-900/50 sm:h-[10rem] md:h-[11.5rem]">
                        {result.image ? (
                          <img
                            src={result.image}
                            alt=""
                            className="absolute inset-0 h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
                          />
                        ) : (
                          <div className="flex h-full w-full items-center justify-center text-blue-300/50">
                            <Icon className="h-8 w-8 sm:h-9 sm:w-9" strokeWidth={1.25} />
                          </div>
                        )}
                        {result.type === "视频" ? (
                          <div
                            className="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center bg-black/20 transition-colors group-hover:bg-black/30"
                            aria-hidden
                          >
                            <span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/95 text-sky-600 shadow-md ring-2 ring-white/40 sm:h-10 sm:w-10">
                              <Play className="ml-0.5 h-4 w-4 fill-current sm:h-[1.15rem] sm:w-[1.15rem]" strokeWidth={0} />
                            </span>
                          </div>
                        ) : null}
                        <span className="absolute right-1 top-1 z-[2] rounded bg-black/50 px-1 py-px text-[9px] font-medium text-white backdrop-blur-sm sm:text-[10px]">
                          {result.type}
                        </span>
                      </div>
                      <div className="flex flex-col gap-1 px-1.5 py-1.5 sm:px-2 sm:py-2">
                        <h4 className="line-clamp-2 shrink-0 text-xs font-bold leading-snug text-white group-hover:text-sky-200 sm:text-sm">
                          {result.title}
                        </h4>
                        {result.content.trim() ? (
                          <p className="line-clamp-2 text-xs leading-relaxed text-blue-100/85 sm:text-[13px]">
                            {result.content.trim()}
                          </p>
                        ) : null}
                        <div className="mt-0.5 grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 border-t border-white/10 pt-1">
                          <div className="flex min-w-0 flex-wrap items-center gap-1 sm:gap-1.5">
                            {result.tags.length > 0 ? (
                              <>
                                {result.tags.slice(0, 3).map((tag) => (
                                  <span
                                    key={tag}
                                    className="max-w-full truncate rounded bg-sky-500/15 px-1 py-0.5 text-[10px] text-sky-200/95 ring-1 ring-sky-400/20 sm:text-xs"
                                  >
                                    {tag}
                                  </span>
                                ))}
                                {result.tags.length > 3 ? (
                                  <span className="text-[10px] text-blue-300/65 sm:text-xs">
                                    +{result.tags.length - 3}
                                  </span>
                                ) : null}
                              </>
                            ) : null}
                          </div>
                          <time
                            className="shrink-0 text-right text-[10px] tabular-nums text-blue-300/75 sm:text-xs"
                            dateTime={result.date}
                          >
                            {result.date}
                          </time>
                        </div>
                      </div>
                    </article>
                  );
                })}
              </div>

              <nav
                className="mt-1.5 w-full min-w-0 shrink-0 overflow-x-auto overflow-y-visible pb-0.5 [-ms-overflow-style:none] [scrollbar-width:none] sm:mt-2 [&::-webkit-scrollbar]:hidden"
                aria-label="分页"
              >
                <div className="ml-auto flex w-max max-w-none flex-nowrap items-center gap-1.5 sm:gap-2">
                  <span className="shrink-0 text-[11px] tabular-nums text-blue-200 sm:text-xs">
                    第 {currentPage} / {totalPages} 页
                  </span>
                  <button
                    type="button"
                    onClick={() => goPage(currentPage - 1)}
                    disabled={currentPage <= 1}
                    className="flex shrink-0 items-center gap-1 rounded-md border border-white/20 bg-white/5 px-2 py-1 text-[11px] text-white transition-colors hover:bg-white/10 disabled:pointer-events-none disabled:opacity-35 sm:px-2.5 sm:py-1.5 sm:text-xs"
                  >
                    <ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden />
                    上一页
                  </button>
                  <div className="flex shrink-0 flex-nowrap items-center gap-1">
                    {buildPageList(totalPages, currentPage).map((item, idx) =>
                      item === "ellipsis" ? (
                        <span
                          key={`e-${idx}`}
                          className="shrink-0 px-0.5 text-[11px] text-blue-200/90 sm:text-xs"
                          aria-hidden
                        >

                        </span>
                      ) : (
                        <button
                          key={item}
                          type="button"
                          onClick={() => goPage(item)}
                          aria-current={item === currentPage ? "page" : undefined}
                          className={`min-w-[1.75rem] shrink-0 rounded-md border border-white/15 px-1.5 py-1 text-[11px] font-medium transition-colors sm:min-w-[2rem] sm:text-xs ${
                            item === currentPage
                              ? "border-sky-400/50 bg-sky-500/90 text-blue-950"
                              : "bg-white/10 text-white hover:border-white/25 hover:bg-white/15"
                          }`}
                        >
                          {item}
                        </button>
                      )
                    )}
                  </div>
                  <button
                    type="button"
                    onClick={() => goPage(currentPage + 1)}
                    disabled={currentPage >= totalPages}
                    className="flex shrink-0 items-center gap-1 rounded-md border border-white/20 bg-white/5 px-2 py-1 text-[11px] text-white transition-colors hover:bg-white/10 disabled:pointer-events-none disabled:opacity-35 sm:px-2.5 sm:py-1.5 sm:text-xs"
                  >
                    下一页
                    <ChevronRight className="h-3.5 w-3.5 shrink-0" aria-hidden />
                  </button>
                </div>
              </nav>
            </>
          )}

          {filteredResults.length === 0 && (
            <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 py-4 text-center">
              <Search className="h-10 w-10 text-blue-400 opacity-50 sm:h-12 sm:w-12" />
              <p className="text-sm text-blue-300 sm:text-base">未找到匹配的结果</p>
              <p className="text-xs text-blue-400/90">请尝试其他搜索词或点击上方关键词</p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}