diff --git a/src/app/auth/components/auth-screen.tsx b/src/app/auth/components/auth-screen.tsx
index 9828a01b..7ae6cdaf 100644
--- a/src/app/auth/components/auth-screen.tsx
+++ b/src/app/auth/components/auth-screen.tsx
@@ -24,8 +24,8 @@ export function AuthScreen() {
const router = useRouter();
// 邮箱登录成功 → 通知 User + 跳转
- // 用 **pendingRedirect flag**(不是 useRef transition detection)——
- // re-mount 时 flag 在 auth context 里**持久**,区分"刚按了"和"re-visit"准确无误。
+ // 用 pendingRedirect flag(不是 useRef transition detection)——
+ // re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => {
console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
pending: state.pendingRedirect,
diff --git a/src/app/chat/components/chat-header.tsx b/src/app/chat/components/chat-header.tsx
index 6bf693bf..7e0bdcdb 100644
--- a/src/app/chat/components/chat-header.tsx
+++ b/src/app/chat/components/chat-header.tsx
@@ -8,7 +8,7 @@
* - 游客模式(isGuest=true):渲染 `GuestBanner` 提示注册(点击 → /auth)
* - 登录模式(isGuest=false):渲染 `MenuButton`(点击 → push 到 /sidebar)
*
- * 注意:菜单按钮直接 push 到侧边栏路由,**不**打开下拉菜单
+ * 注意:菜单按钮直接 push 到侧边栏路由,不打开下拉菜单
* (Dart 端 `MenuButton(onTap: _goSidebar)` 行为)。
*/
import { useRouter } from "next/navigation";
diff --git a/src/app/chat/components/chat-quota-exhausted-banner.module.css b/src/app/chat/components/chat-quota-exhausted-banner.module.css
new file mode 100644
index 00000000..7c82fe05
--- /dev/null
+++ b/src/app/chat/components/chat-quota-exhausted-banner.module.css
@@ -0,0 +1,50 @@
+/* ChatQuotaExhaustedBanner — 游客配额耗尽横幅
+ * 视觉规格(与设计稿对齐):
+ * - 粉→品红渐变背景(与 splash gradient 一致)
+ * - 居中文案 + 渐变 pill 按钮
+ * - 圆角 + 阴影(与 /sidebar 已有卡片的视觉重量对齐)
+ */
+
+.banner {
+ flex: 0 0 auto;
+ margin: 0 var(--spacing-lg, 16px) var(--spacing-lg, 16px);
+ padding: var(--spacing-xl, 24px) var(--spacing-lg, 16px);
+ border-radius: 24px;
+ background: linear-gradient(135deg, #f96ade 0%, #f657a0 100%);
+ text-align: center;
+ box-shadow: 0 4px 16px rgba(248, 77, 150, 0.25);
+}
+
+.text {
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.4;
+ margin: 0 0 var(--spacing-lg, 16px);
+ /* 与 splash 标题的粉色字保持视觉一致(白底渐变叠加层需要白字保证可读性) */
+ opacity: 0.95;
+}
+
+.cta {
+ width: 100%;
+ padding: 0.875rem 1.5rem;
+ border: none;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #ffb8e0 0%, #f57ec0 100%);
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 700;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ transition:
+ transform 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.cta:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
+}
+
+.cta:active {
+ transform: scale(0.98);
+}
diff --git a/src/app/chat/components/chat-quota-exhausted-banner.tsx b/src/app/chat/components/chat-quota-exhausted-banner.tsx
new file mode 100644
index 00000000..96dbb2b4
--- /dev/null
+++ b/src/app/chat/components/chat-quota-exhausted-banner.tsx
@@ -0,0 +1,73 @@
+"use client";
+/**
+ * ChatQuotaExhaustedBanner 游客配额耗尽横幅
+ *
+ * 何时显示:
+ * - isGuest === true(仅游客业务事实"非游客无配额上限")
+ * - quotaLoaded === true(避免一进 /chat 就跳 banner)
+ * - guestTotalQuota <= 0(用 total 而非 remaining —— 配额刚减为 0 不立即跳)
+ *
+ * 视觉规格(与设计稿对齐):
+ * - 粉→品红渐变背景(与 splash 渐变一致)
+ * - 大字号粉色/白色提示文案("The limit for free chat times has been reached today")
+ * - 粉→品红渐变 pill 按钮"Unlock your membership to continue" → 跳 /subscription
+ *
+ * 业务关系:
+ * - 与 `src/app/sidebar/components/vip-benefits-card.tsx` 的 "Activate VIP Membership" 行为一致
+ * - 与 `src/app/sidebar/components/voice-package-card.tsx` 的 "Buy Package" 行为一致
+ *
+ * 不做的事:
+ * - 不直接管理 state machine(chat 机器不感知 UI 层)
+ * - 不显示具体剩余分钟数(已用 total<=0 表达"耗尽"足够)
+ */
+import { useRouter } from "next/navigation";
+
+import { ROUTES } from "@/router/routes";
+
+import styles from "./chat-quota-exhausted-banner.module.css";
+
+export interface ChatQuotaExhaustedBannerProps {
+ /**
+ * 自定义点击回调(不传则默认 router.push(ROUTES.subscription))
+ * - 测试时可传 mock
+ * - 嵌入其他场景时(如 nested overlay)可传自定义
+ */
+ onUnlock?: () => void;
+}
+
+export function ChatQuotaExhaustedBanner({
+ onUnlock,
+}: ChatQuotaExhaustedBannerProps) {
+ const router = useRouter();
+
+ const handleClick = () => {
+ if (onUnlock) {
+ onUnlock();
+ return;
+ }
+ router.push(ROUTES.subscription);
+ };
+
+ return (
+
+
+ The limit for free chat times
+
+ has been reached today
+
+
+
+ );
+}
diff --git a/src/app/chat/components/chat-screen.tsx b/src/app/chat/components/chat-screen.tsx
index 346e8ee7..c2103c1a 100644
--- a/src/app/chat/components/chat-screen.tsx
+++ b/src/app/chat/components/chat-screen.tsx
@@ -2,12 +2,10 @@
/**
* ChatScreen 编排层
*
- * 原始 Dart: lib/ui/chat/chat_screen.dart(172 行)
- *
* 职责(本文件):
- * - **订阅 auth 状态机**(loginStatus)—— 派生 isGuest / **派鉴权生命周期事件**给 chat 机器
+ * - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器
* - 屏幕生命周期事件(init / visible / invisible)
- * - **不**直接管理 WebSocket / **不**直接读 AuthStorage(除 token 取值)
+ * - 不直接管理 WebSocket / 不直接读 AuthStorage(除 token 取值)
*
* 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
@@ -16,13 +14,13 @@
* - PwaInstallOverlay:PWA 安装提示触发器
* - BrowserHintOverlay:浏览器提示
*
- * **鉴权解耦**(**事件驱动**):
- * - chat 机器**不**感知鉴权 / **不**管 WebSocket —— 由 chat-screen 派生 loginStatus
- * - chat-screen **只**派 3 个事件:
- * - `ChatGuestLogin` → 游客会话(**断** WS = 不连)
- * - `ChatNonGuestLogin { token }` → 非游客会话(**连** WS)
+ * 鉴权解耦(事件驱动):
+ * - chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus
+ * - chat-screen 只派 3 个事件:
+ * - `ChatGuestLogin` → 游客会话(断 WS = 不连)
+ * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor)
- * - chat 机器**内部**用 fromCallback actor 完整管理 WS 生命周期
+ * - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
*/
import { useEffect, useRef } from "react";
import Image from "next/image";
@@ -38,9 +36,11 @@ import { BrowserHintOverlay } from "./browser-hint-overlay";
import { ChatArea } from "./chat-area";
import { ChatHeader } from "./chat-header";
import { ChatInputBar } from "./chat-input-bar";
+import { ChatQuotaExhaustedBanner } from "./chat-quota-exhausted-banner";
import { PwaInstallOverlay } from "./pwa-install-overlay";
import styles from "./chat-screen.module.css";
+
/**
* 派生 isGuest —— 单一来源:`auth.loginStatus`
*/
@@ -49,16 +49,16 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
}
/**
- * 按 loginStatus 派鉴权生命周期事件(chat 机器**不**感知 loginStatus —— 这层转换在此)
+ * 按 loginStatus 派鉴权生命周期事件(chat 机器不感知 loginStatus —— 这层转换在此)
*
- * 设计:**不**直接管理 WS / **不**读 AuthStorage(除取 token)
+ * 设计:不直接管理 WS / 不读 AuthStorage(除取 token)
*/
function dispatchAuthLifecycle(
loginStatus: LoginStatus,
chatDispatch: ReturnType,
): void {
if (loginStatus === "guest") {
- // 游客:派 ChatGuestLogin(chat 机器**不**连 WS —— 业务事实"断 WS")
+ // 游客:派 ChatGuestLogin(chat 机器不连 WS —— 业务事实"断 WS")
chatDispatch({ type: "ChatGuestLogin" });
return;
}
@@ -81,9 +81,20 @@ export function ChatScreen() {
const authState = useAuthState();
const chatDispatch = useChatDispatch();
- // isGuest 派生(chat-screen 是**唯一**知道这个值的地方 —— chat 机器无 isGuest 字段)
+ // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
+ // 游客配额耗尽 → 显示 Unlock CTA banner(替代 ChatInputBar)
+ // 三重条件:游客 + 配额加载完 + 用户被配额 guard 拦截过至少一次
+ // - quotaLoaded 避免一进 /chat 就跳 banner(loadQuota 还在 in-flight)
+ // - quotaExceededTrigger 而非 guestTotalQuota / guestRemainingQuota ——
+ // trigger 由 chat 机器的 guard 在 ChatSendMessage / ChatSendImage 被拦截时 +1,
+ // 是 "用户被配额拒绝过" 的权威信号(避免依赖 quota 数值本身的边界判断)
+ const showExhaustedBanner =
+ isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
+
+
+
// ─────────────────────────────────────────────────────────────
// 屏幕生命周期 + 派初次鉴权生命周期事件
// ─────────────────────────────────────────────────────────────
@@ -91,7 +102,7 @@ export function ChatScreen() {
dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
return () => {
};
- // 注:mount useEffect 故意**不**依赖 loginStatus —— 首次 mount 时机是固定的
+ // 注:mount useEffect 故意不依赖 loginStatus —— 首次 mount 时机是固定的
// ("用户进 /chat 那一刻"),loginStatus 由 splash/auth-screen 提前 settle。
// loginStatus 后续变化由下方"副作用 ②" 统一处理。
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -110,7 +121,7 @@ export function ChatScreen() {
const prev = prevLoginStatusRef.current;
prevLoginStatusRef.current = authState.loginStatus;
if (prev === null) return; // 首次 mount 由 mount useEffect 处理
- if (prev === authState.loginStatus) return; // 登录态**没**变
+ if (prev === authState.loginStatus) return; // 登录态没变
dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
}, [authState.loginStatus, chatDispatch]);
@@ -138,7 +149,12 @@ export function ChatScreen() {
isGuest={isGuest}
/>
-
+ {showExhaustedBanner ? (
+
+ ) : (
+
+ )}
+
{/* 浏览器提示(应用内浏览器检测) */}
diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts
index 8b298016..5a830fd3 100644
--- a/src/app/chat/components/index.ts
+++ b/src/app/chat/components/index.ts
@@ -8,7 +8,9 @@ export * from "./chat-area";
export * from "./chat-header";
export * from "./chat-input-bar";
export * from "./chat-input-text-field";
+export * from "./chat-quota-exhausted-banner";
export * from "./chat-screen";
+
export * from "./chat-send-button";
export * from "./date-header";
export * from "./fullscreen-image-viewer";
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx
index dea5e557..e55671b6 100644
--- a/src/app/chat/components/message-content.tsx
+++ b/src/app/chat/components/message-content.tsx
@@ -29,7 +29,7 @@ export function MessageContent({ content, imageUrl, isFromAI }: MessageContentPr
style={{
display: "flex",
flexDirection: "column",
- // **关键**:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
+ // 关键:在 row flex 父中撑开(不依赖 content 循环)—— 让 TextBubble 能拿到真实宽度
flex: "1 1 0",
// 防超长 content(图片 / 超长 url)撑爆父
minWidth: 0,
diff --git a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx
index c753a45d..a8157fd1 100644
--- a/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx
+++ b/src/app/chat/deviceid/[deviceId]/DeepLinkPersist.tsx
@@ -7,7 +7,7 @@
* `redirect` 回调(`app_router.dart:60-78`)。
*
* 设计:
- * - **不**走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
+ * - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
* - deviceId 走 `AuthStorage.getInstance().setDeviceId`(与 `token_interceptor.ts` 同源;
* 写入键 `cozsweet.deviceId`)。
* - fbid 走 `window.localStorage.setItem(StorageKeys.facebookId, ...)`,
diff --git a/src/app/chat/deviceid/[deviceId]/page.tsx b/src/app/chat/deviceid/[deviceId]/page.tsx
index 31de2bb0..687530a1 100644
--- a/src/app/chat/deviceid/[deviceId]/page.tsx
+++ b/src/app/chat/deviceid/[deviceId]/page.tsx
@@ -9,7 +9,7 @@
* Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` /
* `next typegen` 生成。
*
- * 这里**不**调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好;
+ * 这里不调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好;
* 跳转推迟到 Client 完成持久化之后。
*/
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 8b5815ec..3b935c5e 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -4,7 +4,7 @@
* Server Component:把访客直接送入 `/splash`,由 splash 页(Client)根据
* `authStorage.hasLoginToken()` 决定是否再跳 `/chat`。
*
- * 这里**不**做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly
+ * 这里不做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly
* cookie(见 `src/data/storage/auth/auth_storage.ts` 的 TODO)。
* `src/proxy.ts` 已为未来 cookie 化预留了基于 `login_token` cookie 的 short-circuit。
*/
diff --git a/src/app/splash/components/splash-button.module.css b/src/app/splash/components/splash-button.module.css
index 34bce9af..a1b4ca30 100644
--- a/src/app/splash/components/splash-button.module.css
+++ b/src/app/splash/components/splash-button.module.css
@@ -12,6 +12,7 @@
font-family: var(--font-athelas);
font-size: var(--font-size-xxl);
font-weight: 700;
+ font-style: italic;
color: #ffffff;
background: transparent;
border: 0;
@@ -43,6 +44,7 @@
color: #ffffff;
font-weight: 700;
cursor: pointer;
+ font-style: italic;
display: inline-flex;
align-items: center;
justify-content: center;
diff --git a/src/app/splash/components/splash-button.tsx b/src/app/splash/components/splash-button.tsx
index ab82cac4..6e4c81c9 100644
--- a/src/app/splash/components/splash-button.tsx
+++ b/src/app/splash/components/splash-button.tsx
@@ -2,14 +2,14 @@
/**
* Splash 底部按钮组
*
- * 本组件是 splash 鉴权流程的**纯 UI 入口**:
+ * 本组件是 splash 鉴权流程的纯 UI 入口:
* - 消费 `useAuthState` / `useAuthDispatch`(仅用于 isLoading 显示 + 派事件)
- * - **Skip 按钮 = 显式游客登录入口**(派发 `AuthGuestLoginSubmitted`)
+ * - Skip 按钮 = 显式游客登录入口(派发 `AuthGuestLoginSubmitted`)
* - 社交登录按钮仅派发 `AuthFacebookLoginSubmitted` 事件,由状态机负责调 NextAuth
*
- * **派事件策略**(**单**事件):
+ * 派事件策略(单事件):
* - 派业务事件(`AuthGuestLoginSubmitted` / `AuthFacebookLoginSubmitted`)→ 状态机开始跑
- * - `pendingRedirect: true` 在**登录成功 onDone** 里**自动**置(auth-machine.ts `onLoginSuccess` helper)
+ * - `pendingRedirect: true` 在登录成功 onDone 里自动置(auth-machine.ts `onLoginSuccess` helper)
* - splash-screen 看到 flag=true + loginStatus 非 notLoggedIn → 跳 /chat
*/
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
@@ -35,7 +35,7 @@ export function SplashButton() {
return (
- {/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted,**不**做路由跳转) */}
+ {/* Skip 按钮 = 显式游客登录入口(派发 AuthGuestLoginSubmitted,不做路由跳转) */}