代码部分,全部在这里,直接用就行
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>