fix: AudioContext 复用 + 盲盒加载错误提示 + icon 按钮 aria-label

- #27: panic/blindbox/ShareCardModal/AuthModal 中 icon-only 按钮补 aria-label
- #28: playChime 缓存复用单个 AudioContext,避免超出浏览器 ~6 个上限
- #29: 已完成(上一批次 ApiError.name = "ApiError")
- #30: blindbox lobby fetchRooms 失败时显示"加载失败/点击重试"
- #31: 已完成(theme.ts VALID_THEMES 校验)
This commit is contained in:
2026-02-26 20:25:56 +08:00
parent f4a8fd7fee
commit deba7ab2bb
6 changed files with 41 additions and 5 deletions
+3
View File
@@ -334,6 +334,7 @@ export default function BlindboxRoomPage() {
<div className="flex w-full max-w-sm items-center gap-3"> <div className="flex w-full max-w-sm items-center gap-3">
<button <button
onClick={() => router.push("/blindbox")} onClick={() => router.push("/blindbox")}
aria-label="返回"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
> >
<ArrowLeft size={16} className="text-muted" /> <ArrowLeft size={16} className="text-muted" />
@@ -363,6 +364,7 @@ export default function BlindboxRoomPage() {
<button <button
onClick={() => setShowInvite(!showInvite)} onClick={() => setShowInvite(!showInvite)}
aria-label="邀请成员"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-purple-400 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/25" className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-purple-400 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/25"
> >
<Share2 size={14} /> <Share2 size={14} />
@@ -486,6 +488,7 @@ export default function BlindboxRoomPage() {
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!input.trim() || submitting} disabled={!input.trim() || submitting}
aria-label="提交想法"
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30" className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
> >
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />} {submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
+21 -2
View File
@@ -42,6 +42,7 @@ export default function BlindboxLobbyPage() {
const [joinCode, setJoinCode] = useState(""); const [joinCode, setJoinCode] = useState("");
const [joining, setJoining] = useState(false); const [joining, setJoining] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loadError, setLoadError] = useState(false);
useEffect(() => { useEffect(() => {
const registered = isRegistered(); const registered = isRegistered();
@@ -65,12 +66,14 @@ export default function BlindboxLobbyPage() {
const p = getCachedProfile(); const p = getCachedProfile();
if (!p) return; if (!p) return;
setLoading(true); setLoading(true);
setLoadError(false);
try { try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`); const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`);
if (!res.ok) throw new Error();
const data = await res.json(); const data = await res.json();
setRooms(data.rooms ?? []); setRooms(Array.isArray(data.rooms) ? data.rooms : []);
} catch { } catch {
/* ignore */ setLoadError(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -219,6 +222,22 @@ export default function BlindboxLobbyPage() {
</motion.div> </motion.div>
) : loading ? ( ) : loading ? (
<BlindboxListSkeleton /> <BlindboxListSkeleton />
) : loadError ? (
<motion.div
key="load-error"
className="mt-16 flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Package size={36} className="text-purple-400/30" strokeWidth={1.5} />
<p className="text-sm text-muted"></p>
<button
onClick={fetchRooms}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
>
</button>
</motion.div>
) : rooms.length === 0 ? ( ) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */ /* ============ Layer 2: Logged in, no rooms — Create first ============ */
<motion.div <motion.div
+3
View File
@@ -297,6 +297,7 @@ export default function PanicPage() {
{(selectedLocation || locationQuery) && !loading && ( {(selectedLocation || locationQuery) && !loading && (
<button <button
onClick={clearLocation} onClick={clearLocation}
aria-label="清除位置"
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-muted hover:text-secondary" className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-muted hover:text-secondary"
> >
<X size={14} /> <X size={14} />
@@ -399,6 +400,7 @@ export default function PanicPage() {
{cuisine && !loading && ( {cuisine && !loading && (
<button <button
onClick={() => setCuisine("")} onClick={() => setCuisine("")}
aria-label="清除口味"
className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted hover:text-secondary" className="absolute right-2 flex h-4 w-4 items-center justify-center rounded-full text-muted hover:text-secondary"
> >
<X size={12} /> <X size={12} />
@@ -515,6 +517,7 @@ export default function PanicPage() {
<button <button
type="submit" type="submit"
disabled={loading || roomCode.length !== 4} disabled={loading || roomCode.length !== 4}
aria-label="加入房间"
className="flex h-11 w-11 items-center justify-center rounded-xl bg-elevated text-secondary ring-1 ring-subtle transition-colors hover:bg-subtle disabled:opacity-30" className="flex h-11 w-11 items-center justify-center rounded-xl bg-elevated text-secondary ring-1 ring-subtle transition-colors hover:bg-subtle disabled:opacity-30"
> >
<LogIn size={18} /> <LogIn size={18} />
+1
View File
@@ -188,6 +188,7 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "隐藏密码" : "显示密码"}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted transition-colors active:text-secondary" className="absolute right-3 top-1/2 -translate-y-1/2 text-muted transition-colors active:text-secondary"
> >
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />} {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
+1
View File
@@ -130,6 +130,7 @@ export default function ShareCardModal({
<div className="relative flex shrink-0 justify-center"> <div className="relative flex shrink-0 justify-center">
<button <button
onClick={onClose} onClick={onClose}
aria-label="关闭"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors active:bg-black/60" className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors active:bg-black/60"
> >
<X size={16} /> <X size={16} />
+12 -3
View File
@@ -62,9 +62,20 @@ export function fireCelebration() {
setTimeout(frame, 300); setTimeout(frame, 300);
} }
let _audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!_audioCtx || _audioCtx.state === "closed") {
_audioCtx = new AudioContext();
}
return _audioCtx;
}
export function playChime() { export function playChime() {
try { try {
const ctx = new AudioContext(); const ctx = getAudioContext();
if (ctx.state === "suspended") ctx.resume();
const gain = ctx.createGain(); const gain = ctx.createGain();
gain.connect(ctx.destination); gain.connect(ctx.destination);
gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.setValueAtTime(0.15, ctx.currentTime);
@@ -88,8 +99,6 @@ export function playChime() {
osc.start(start); osc.start(start);
osc.stop(start + 0.6); osc.stop(start + 0.6);
}); });
setTimeout(() => ctx.close(), 2000);
} catch { } catch {
// Audio not available — silent fallback // Audio not available — silent fallback
} }