Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/[locale]/admin/events/EventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
62 changes: 46 additions & 16 deletions app/[locale]/events/[id]/InterestButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string | null>(null);
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
Expand Down Expand Up @@ -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 (
<button
type="button"
disabled={loading}
onClick={toggle}
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
interested
? "border-[#CC0000] bg-[#CC0000] text-white hover:bg-transparent hover:text-[#CC0000]"
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
}`}
>
{interested ? "已标记 · " : "感兴趣 · "}
{count}
</button>
<div className="flex items-center gap-3">
<button
type="button"
disabled={loading}
onClick={toggle}
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
interested
? "border-[#CC0000] bg-[#CC0000] text-white hover:bg-transparent hover:text-[#CC0000]"
: "border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)]"
}`}
>
{interested ? "已标记 · " : "感兴趣 · "}
{count}
</button>
{errorMsg && (
<span
role="alert"
aria-live="polite"
className="font-mono text-xs text-red-600 dark:text-red-400"
>
{errorMsg}
</span>
)}
</div>
);
}
15 changes: 15 additions & 0 deletions sentry.client.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string, string>;
delete h.satoken;
delete h.Satoken;
delete h.cookie;
delete h.Cookie;
delete h.authorization;
delete h.Authorization;
}
return event;
},
});
23 changes: 20 additions & 3 deletions sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
delete h.satoken;
delete h.Satoken;
delete h.cookie;
delete h.Cookie;
delete h.authorization;
delete h.Authorization;
}
return event;
},
});
28 changes: 25 additions & 3 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
delete h.satoken;
delete h.Satoken;
delete h.cookie;
delete h.Cookie;
delete h.authorization;
delete h.Authorization;
}
return event;
},
});
Loading