舒尔特方格开源
代码部分,全部在这里,直接用就行
<!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><selectclassName="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 ? (<buttononClick={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 ? (<buttononClick={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>) : (<buttononClick={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>)}<buttononClick={() => 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><buttononClick={() => 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><divclassName="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 (<buttonkey={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"><buttononClick={() => 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><buttononClick={() => {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>