舒尔特方格开源

代码部分,全部在这里,直接用就行

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Schulte 方格</title>
  <!-- React 18 UMD -->
  <script src="https://unpkg.com/react@18/umd/react.development.js"></script>
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
  <!-- Babel for in-browser JSX transform -->
  <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  <!-- TailwindCSS (CDN) -->
  <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
</head>
<body class="bg-neutral-100">
  <div id="root"></div>

  <script type="text/babel">
    const { useEffect, useMemo, useRef, useState } = React;

    function SchulteGridApp() {
      const sizeOptions = [3,4,5,6,7,8,9,10];
      const [size, setSize] = useState(5);
      const [numbers, setNumbers] = useState([]);
      const [target, setTarget] = useState(1);
      const [running, setRunning] = useState(false);
      const [elapsed, setElapsed] = useState(0);
      const [mistakeId, setMistakeId] = useState(null);
      const [finishedAt, setFinishedAt] = useState(null);
      const timerRef = useRef(null);
      const startTsRef = useRef(null);
      const pausedOffsetRef = useRef(0);

      const total = useMemo(() => size * size, [size]);

      const bestKey = useMemo(() => "schulte_best_" + size, [size]);
      const bestMs = useMemo(() => {
        const v = localStorage.getItem(bestKey);
        return v ? parseInt(v) : null;
      }, [bestKey]);

      const format = (ms) => {
        if (ms == null) return "--:--.--";
        const s = Math.floor(ms / 1000);
        const cs = Math.floor((ms % 1000) / 10);
        const m = Math.floor(s / 60);
        const s2 = s % 60;
        return String(m).padStart(2,"0") + ":" + String(s2).padStart(2,"0") + "." + String(cs).padStart(2,"0");
      };

      const shuffleNew = (n) => {
        const arr = Array.from({length: n}, (_,i) => i+1);
        for (let i = arr.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          const tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;
        }
        return arr;
      };

      const reset = (keepSize = true) => {
        const newSize = keepSize ? size : 5;
        const n = newSize * newSize;
        setNumbers(shuffleNew(n));
        setTarget(1);
        setRunning(false);
        setElapsed(0);
        setMistakeId(null);
        setFinishedAt(null);
        pausedOffsetRef.current = 0;
        startTsRef.current = null;
        if (timerRef.current) {
          clearInterval(timerRef.current);
          timerRef.current = null;
        }
      };

      useEffect(() => { reset(true); }, [size]);

      useEffect(() => {
        if (!running) return;
        if (timerRef.current) return;
        timerRef.current = setInterval(() => {
          if (startTsRef.current != null) {
            setElapsed(Date.now() - startTsRef.current + pausedOffsetRef.current);
          }
        }, 16);
        return () => {
          if (timerRef.current) {
            clearInterval(timerRef.current);
            timerRef.current = null;
          }
        };
      }, [running]);

      const handleCellClick = (num) => {
        if (!running && target === 1) {
          startTsRef.current = Date.now();
          setRunning(true);
        }
        if (num === target) {
          const next = target + 1;
          setTarget(next);
          setMistakeId(null);
          if (next > total) {
            const finalMs = Date.now() - (startTsRef.current ?? Date.now()) + pausedOffsetRef.current;
            setRunning(false);
            setFinishedAt(finalMs);
            setElapsed(finalMs);
            if (timerRef.current) {
              clearInterval(timerRef.current);
              timerRef.current = null;
            }
            if (bestMs == null || finalMs < bestMs) {
              localStorage.setItem(bestKey, String(finalMs));
            }
          }
        } else {
          setMistakeId(num);
          try { if (navigator.vibrate) navigator.vibrate(40); } catch(e) {}
        }
      };

      const pause = () => {
        if (!running) return;
        setRunning(false);
        if (timerRef.current) {
          clearInterval(timerRef.current);
          timerRef.current = null;
        }
        if (startTsRef.current != null) {
          pausedOffsetRef.current = elapsed;
        }
      };

      const resume = () => {
        if (running) return;
        setRunning(true);
        startTsRef.current = Date.now();
      };

      const gridTemplate = useMemo(() => ({
        gridTemplateColumns: "repeat(" + size + ", 1fr)",
      }), [size]);

      const cellSizeClass = useMemo(() => {
        if (size <= 3) return "text-4xl";
        if (size === 4) return "text-3xl";
        if (size === 5) return "text-2xl";
        if (size <= 7) return "text-xl";
        if (size <= 9) return "text-lg";
        return "text-base";
      }, [size]);

      return (
        <div className="min-h-screen w-full bg-neutral-50 text-neutral-900 dark:bg-neutral-950 dark:text-neutral-50 flex items-center justify-center p-4">
          <div className="w-full max-w-5xl">
            <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
              <div>
                <h1 className="text-2xl font-bold tracking-tight">Schulte 方格</h1>
                <p className="text-sm text-neutral-500">点击 1 → {total},锻炼专注与视野。首次点击开始计时。</p>
              </div>
              <div className="flex flex-wrap items-center gap-2">
                <label className="text-sm">尺寸</label>
                <select
                  className="px-3 py-2 rounded-xl border border-neutral-300 bg-white text-sm dark:bg-neutral-900 dark:border-neutral-700"
                  value={size}
                  onChange={(e) => setSize(parseInt(e.target.value))}
                >
                  {sizeOptions.map((s) => (
                    <option key={s} value={s}>{s} × {s}({s * s})</option>
                  ))}
                </select>
                <div className="flex items-baseline gap-2">
                  <span className="text-sm text-neutral-500">用时</span>
                  <span className="font-mono text-xl tabular-nums">{format(elapsed)}</span>
                </div>
                <div className="flex items-baseline gap-2">
                  <span className="text-sm text-neutral-500">最佳({size}×{size})</span>
                  <span className="font-mono text-lg tabular-nums">{format(bestMs)}</span>
                </div>
              </div>
            </div>

            <div className="flex flex-wrap items-center gap-2 mb-4">
              {!running && target === 1 ? (
                <button
                  onClick={resume}
                  className="px-4 py-2 rounded-2xl bg-black text-white text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-white dark:text-black"
                >
                  开始
                </button>
              ) : (
                <>
                  {running ? (
                    <button
                      onClick={pause}
                      className="px-4 py-2 rounded-2xl bg-neutral-900 text-white text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-neutral-100 dark:text-black"
                    >暂停</button>
                  ) : (
                    <button
                      onClick={resume}
                      className="px-4 py-2 rounded-2xl bg-neutral-900 text-white text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-neutral-100 dark:text-black"
                    >继续</button>
                  )}
                  <button
                    onClick={() => reset(true)}
                    className="px-4 py-2 rounded-2xl bg-white border text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-neutral-900 dark:border-neutral-700"
                  >重置</button>
                  <button
                    onClick={() => setNumbers(shuffleNew(total))}
                    className="px-4 py-2 rounded-2xl bg-white border text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-neutral-900 dark:border-neutral-700"
                  >重新打乱</button>
                </>
              )}
            </div>

            <div
              className="grid gap-2 rounded-3xl p-3 bg-white/70 backdrop-blur border border-neutral-200 shadow-sm dark:bg-neutral-900/60 dark:border-neutral-800"
              style={gridTemplate}
            >
              {numbers.map((num) => {
                const isDone = num < target;
                const isMistake = mistakeId === num;
                return (
                  <button
                    key={num}
                    onClick={() => handleCellClick(num)}
                    className={[
                      "relative select-none aspect-square rounded-xl border text-center font-semibold transition-all duration-150 flex items-center justify-center",
                      "bg-neutral-50 dark:bg-neutral-900",
                      "border-neutral-200 dark:border-neutral-800",
                      isDone && "opacity-60",
                      isMistake && "animate-shake border-red-400 ring-2 ring-red-400",
                      cellSizeClass,
                    ].filter(Boolean).join(" ")}
                    aria-label={"数字 " + num}
                  >
                    <span className="tabular-nums">{num}</span>
                  </button>
                );
              })}
            </div>

            {finishedAt != null && (
              <div className="mt-4 p-4 rounded-2xl border bg-white shadow-sm dark:bg-neutral-900 dark:border-neutral-800">
                <div className="flex flex-wrap items-center justify-between gap-3">
                  <div className="flex items-baseline gap-3">
                    <span className="text-sm text-neutral-500">本次用时</span>
                    <span className="font-mono text-2xl tabular-nums">{format(finishedAt)}</span>
                    {bestMs != null && finishedAt <= bestMs && (
                      <span className="text-xs rounded-full px-2 py-1 bg-emerald-600 text-white">新纪录!</span>
                    )}
                  </div>
                  <div className="flex gap-2">
                    <button
                      onClick={() => reset(true)}
                      className="px-4 py-2 rounded-2xl bg-black text-white text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-white dark:text-black"
                    >再来一局</button>
                    <button
                      onClick={() => {
                        localStorage.removeItem(bestKey);
                        setFinishedAt((v) => (v == null ? 0 : v - 1));
                      }}
                      className="px-4 py-2 rounded-2xl bg-white border text-sm font-semibold shadow-sm active:scale-[0.98] dark:bg-neutral-900 dark:border-neutral-700"
                    >清除本尺寸最佳</button>
                  </div>
                </div>
              </div>
            )}
          </div>

          <style>{`
            @keyframes shake { 10%, 90% { transform: translateX(-1px); } 20%, 80% { transform: translateX(2px); } 30%, 50%, 70% { transform: translateX(-4px);} 40%, 60% { transform: translateX(4px);} }
            .animate-shake { animation: shake 0.4s both; }
          `}</style>
        </div>
      );
    }

    const root = ReactDOM.createRoot(document.getElementById('root'));
    root.render(<SchulteGridApp />);
  </script>
</body>
</html>
相关推荐
white-persist2 小时前
【burp手机真机抓包】Burp Suite 在真机(Android and IOS)抓包手机APP + 微信小程序详细教程
android·前端·ios·智能手机·微信小程序·小程序·原型模式
说私域2 小时前
定制开发开源AI智能名片S2B2C商城小程序的会员制运营研究——以“老铁用户”培养为核心目标
人工智能·小程序·开源
lbh2 小时前
Chrome DevTools 详解(二):Console 面板
前端·javascript·浏览器
ObjectX前端实验室2 小时前
【react18原理探究实践】更新阶段 Render 与 Diff 算法详解
前端·react.js
wxr06163 小时前
部署Spring Boot项目+mysql并允许前端本地访问的步骤
前端·javascript·vue.js·阿里云·vue3·springboot
万邦科技Lafite3 小时前
如何对接API接口?需要用到哪些软件工具?
java·前端·python·api·开放api·电商开放平台
知识分享小能手3 小时前
微信小程序入门学习教程,从入门到精通,WXSS样式处理语法基础(9)
前端·javascript·vscode·学习·微信小程序·小程序·vue
看晴天了3 小时前
🌈 Tailwind CSS 常用类名总结
前端
看晴天了3 小时前
Tailwind的安装,配置,使用步骤
前端