diff --git a/app/[locale]/admin/events/EventForm.tsx b/app/[locale]/admin/events/EventForm.tsx index 2e19960..d931fed 100644 --- a/app/[locale]/admin/events/EventForm.tsx +++ b/app/[locale]/admin/events/EventForm.tsx @@ -58,6 +58,15 @@ export function EventForm({ initial }: Props) { status: (fd.get("status") as EventStatus) ?? "draft", }; + // endTime > startTime 客户端校验(issue #302 P2-1)。 + // 后端有相同校验作为权威,前端这层是防止管理员在 UI 上明显手抖。 + // 两个 ISO 字符串字典序与时间序一致,可以直接 string compare。 + if (req.startTime && req.endTime && req.endTime <= req.startTime) { + setError("结束时间必须晚于开始时间"); + setSubmitting(false); + return; + } + try { if (initial) { await updateEvent(initial.id, req); diff --git a/app/[locale]/events/[id]/InterestButton.tsx b/app/[locale]/events/[id]/InterestButton.tsx index dc423f9..87fbab4 100644 --- a/app/[locale]/events/[id]/InterestButton.tsx +++ b/app/[locale]/events/[id]/InterestButton.tsx @@ -11,7 +11,7 @@ * 不做 SWR 集成:交互简单(只关心自己的点击),直接 useState + fetch 更直白 */ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/lib/use-auth"; @@ -37,6 +37,24 @@ export function InterestButton({ const [count, setCount] = useState(initialCount); const [interested, setInterested] = useState(initialInterested); const [loading, setLoading] = useState(false); + // 失败提示(issue #302 P1-1):之前 catch 静默吞错,乐观 UI 回滚后用户 + // 看到数字"动了一下又回去"以为按钮坏了。短暂展示一行红字 3 秒消失, + // 与 SettingsForm 的 toast 思路一致但内联化(按钮位置紧凑,不强插全局 toast) + const [errorMsg, setErrorMsg] = useState(null); + const errorTimerRef = useRef | null>(null); + + // 卸载时清掉 timer,避免对已 unmount 的组件 setState 报警 + useEffect(() => { + return () => { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + }; + }, []); + + function flashError(msg: string) { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + setErrorMsg(msg); + errorTimerRef.current = setTimeout(() => setErrorMsg(null), 3000); + } if (status === "unauthenticated") { return ( @@ -74,28 +92,40 @@ export function InterestButton({ // 用后端返回的权威值覆盖乐观值,避免竞争 setCount(json.data.count); setInterested(json.data.interested); - } catch { - // 回滚 + } catch (err) { + // 回滚 + 显式 toast,避免静默吞错(issue #302 P1-1) setInterested(prevInterested); setCount(prevCount); + flashError(err instanceof Error ? err.message : "操作失败,请重试"); } finally { setLoading(false); } }; return ( - +
+ + {errorMsg && ( + + {errorMsg} + + )} +
); } diff --git a/sentry.client.config.ts b/sentry.client.config.ts index af093f2..3e07a76 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -6,6 +6,9 @@ * - tracesSampleRate 0.1:10% 的页面 transaction 采样足够看性能趋势 * - 关闭 Session Replay:它是另外的独立配额(小),开了容易炸 * - 不启用 profiling(需要付费) + * + * beforeSend:过滤敏感请求头,避免 satoken / cookie / authorization 等 + * 凭据被原样上报到 Sentry(合规 + 减少误用攻击面,issue #302 P1-2)。 */ import * as Sentry from "@sentry/nextjs"; @@ -19,4 +22,16 @@ Sentry.init({ replaysOnErrorSampleRate: 0, // 线上开 false 省日志;排障时临时改 true debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, }); diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index c2e149e..46f301c 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -1,15 +1,32 @@ /** * Sentry Edge runtime 初始化(middleware 及未来可能出现的 Edge routes)。 - * 当前仓库根目录的 middleware.ts 即在此 runtime 执行(IP geo → locale cookie)。 - * 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打告警。 + * 当前仓库根目录的 proxy.ts(Next.js 16 改名)即在此 runtime 执行。 + * + * 启用条件:production 构建 + DSN 已配置,避免 DSN 漏配时 SDK 启动时打 + * 告警。读 SENTRY_DSN 私有 env(issue #302 P2-2),fallback 到旧的 + * NEXT_PUBLIC_SENTRY_DSN 兼容现有部署。 + * + * beforeSend:过滤敏感请求头(issue #302 P1-2),避免凭据上报。 */ import * as Sentry from "@sentry/nextjs"; -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, tracesSampleRate: 0.1, debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, }); diff --git a/sentry.server.config.ts b/sentry.server.config.ts index e240328..ae52292 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -1,15 +1,37 @@ /** * Sentry Node.js runtime 初始化(Next.js API routes / Server Components / RSC)。 - * 与 client 同策略:production 且 DSN 已配置时启用,traces 10%,无 replay/profiling。 - * 加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告警日志(Copilot CR)。 + * + * 与 client 同策略:production 且 DSN 已配置时启用,traces 10%, + * 无 replay/profiling。加 DSN 校验是为了避免漏配 env 时 SDK 初始化打告 + * 警日志(Copilot CR)。 + * + * Server / Edge 端读 SENTRY_DSN(私有 env)而非 NEXT_PUBLIC_SENTRY_DSN + * (issue #302 P2-2)。NEXT_PUBLIC_ 前缀的 env 会打进客户端 bundle, + * client config 必须用 public 那份;server / edge 没必要再暴露一份, + * 走私有 env 更干净。两个 env 都没配时回退读 NEXT_PUBLIC_ 兜底,避免 + * 旧部署在迁移过程中突然丢 Sentry 上报。 + * + * beforeSend:过滤敏感请求头(issue #302 P1-2),与 client config 同款。 */ import * as Sentry from "@sentry/nextjs"; -const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; +const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, tracesSampleRate: 0.1, debug: false, + beforeSend(event) { + if (event.request?.headers) { + const h = event.request.headers as Record; + delete h.satoken; + delete h.Satoken; + delete h.cookie; + delete h.Cookie; + delete h.authorization; + delete h.Authorization; + } + return event; + }, });