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 66a3899f..c2103c1a 100644 --- a/src/app/chat/components/chat-screen.tsx +++ b/src/app/chat/components/chat-screen.tsx @@ -2,8 +2,6 @@ /** * ChatScreen 编排层 * - * 原始 Dart: lib/ui/chat/chat_screen.dart(172 行) - * * 职责(本文件): * - 订阅 auth 状态机(loginStatus)—— 派生 isGuest / 派鉴权生命周期事件给 chat 机器 * - 屏幕生命周期事件(init / visible / invisible) @@ -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` */ @@ -84,6 +84,17 @@ export function ChatScreen() { // 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; + + + // ───────────────────────────────────────────────────────────── // 屏幕生命周期 + 派初次鉴权生命周期事件 // ───────────────────────────────────────────────────────────── @@ -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/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index ced0799a..cdffd6cf 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -1,19 +1,5 @@ "use client"; -/** - * ChatStorage 完整实现(基于 unstorage 抽象) - * - * 对齐 Dart 端 `ChatStorage`(lib/data/services/storage/chat/chat_storage.dart): - * - 游客每日聊天配额(GuestChatQuota:{ remaining, date }) - * - 游客总配额(int) - * - * 注意:本类与 `LocalChatStorage`(IndexedDB / Dexie)是不同类: - * - ChatStorage = 游客配额等元数据(KV) - * - LocalChatStorage = 聊天记录本身(IndexedDB) - * - * 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。 - */ - import { GuestChatQuotaSchema, type GuestChatQuotaData, diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 896a6349..d286952c 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -1,16 +1,5 @@ "use client"; -/** - * ChatContext:基于 XState v5 的 React Context Provider - * - * 业务事件通过 `useMachine(chatMachine).send()` 派发。 - * XState 自动处理 HTTP 副作用(invoke + fromPromise actors)。 - * - * 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。 - * 内部将 XState 状态机快照映射回原 `ChatState` 形状。 - * - * 注:WebSocket 长生命周期 actor 由 chat-screen.tsx 监听 auth 状态机驱动—— - * 鉴权解耦(loginStatus 在 auth,chat 机器不感知鉴权)。 - */ + import { type Dispatch, type ReactNode, diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 92b368ba..e56d66a5 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -33,7 +33,8 @@ import { setup, assign } from "xstate"; import { ChatStorage } from "@/data/storage/chat/chat_storage"; -import { formatDate } from "@/utils/date"; +import { formatDate, todayString } from "@/utils/date"; + import { ChatState, initialState } from "./chat-state"; import type { ChatEvent } from "./chat-events"; @@ -85,8 +86,9 @@ export const chatMachine = setup({ // 持久化两个额度( fire-and-forget,不 await —— assign 是 sync) // daily quota 需要 today 字符串(让 ChatStorage 跨天判断 reset) - const today = formatDate(); + const today = todayString(); ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); + ChatStorage.getInstance().setGuestTotalQuota(newTotal); console.log("[chat-machine] appendUserMessage", { @@ -124,9 +126,12 @@ export const chatMachine = setup({ const newTotal = context.wsConnected ? context.guestTotalQuota : Math.max(0, context.guestTotalQuota - 1); - const today = formatDate(); + + const today = todayString(); void ChatStorage.getInstance().setGuestDailyChatQuota(newRemaining, today); + void ChatStorage.getInstance().setGuestTotalQuota(newTotal); + console.log("[chat-machine] appendUserImage", { oldMessagesCount: context.messages.length, wsConnected: context.wsConnected, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 02ec6607..85dabdae 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -3,13 +3,6 @@ * * 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。 * - * 历史: - * - `isGuest: boolean` 字段已删(由 chat-screen 派生自 `auth.loginStatus === "guest"`) - * - `guestRemainingQuota` / `guestTotalQuota` default 改 0: - * - 游客:chat-screen 派 `ChatInit` → readInitData 调 ChatStorage → onDone 条件赋 - * - 非游客:永不被赋值(业务事实"非游客无消息限制")—— 0 - 1 = max(0, -1) = 0 天然 no-op - * - 0 同时表示"未初始化"和"已耗尽" —— UI 用 `if (isGuest && ...)` 天然过滤 - * * 新增 `wsConnected: boolean`(仅 userSession 期间 true): * - WS 已连 → 消息发送走 WS,不消耗次数 * - WS 未连 → 消息发送走 HTTP,消耗次数