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,消耗次数